diff --git a/src/bridge/auth.test.ts b/src/bridge/auth.test.ts index b11943c..b0fcf65 100644 --- a/src/bridge/auth.test.ts +++ b/src/bridge/auth.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Repository } from '../db/repository.js'; -import { requireAuth, requireAdmin, fetchGiteaOrgsForUser } from './auth.js'; +import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive } from './auth.js'; +import type { AuthConfig } from '../config.js'; import type { Request, Response, NextFunction } from 'express'; function mockReqRes(overrides: Partial = {}) { @@ -172,3 +173,51 @@ describe('fetchGiteaOrgsForUser', () => { } }); }); + +// Audit 2026-06-09: provider completeness + primaryProvider gating. These pin +// the security-critical fail-closed / restriction semantics behind the new +// AuthForm (config editable from Settings UI). +const COMPLETE = { clientId: 'id', clientSecret: 'sec', callbackUrl: 'https://x/cb' }; +const COMPLETE_GITEA = { ...COMPLETE, baseUrl: 'https://gitea' }; +function authCfg( + providers: Partial, + primaryProvider?: 'google' | 'gitea', +): AuthConfig { + return { + sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [], + primaryProvider, providers, + } as AuthConfig; +} + +describe('isProviderConfigured', () => { + it('true only when all required fields are present', () => { + expect(isProviderConfigured(COMPLETE, 'google')).toBe(true); + expect(isProviderConfigured(COMPLETE_GITEA, 'gitea')).toBe(true); + }); + it('gitea requires baseUrl', () => { + expect(isProviderConfigured(COMPLETE, 'gitea')).toBe(false); + }); + it('false when any field is missing or provider undefined', () => { + expect(isProviderConfigured({ ...COMPLETE, clientSecret: '' }, 'google')).toBe(false); + expect(isProviderConfigured({ ...COMPLETE, callbackUrl: undefined }, 'google')).toBe(false); + expect(isProviderConfigured(undefined, 'google')).toBe(false); + }); +}); + +describe('isProviderActive (primaryProvider restriction)', () => { + it('both active when both complete and no primary', () => { + const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }); + expect(isProviderActive(c, 'google')).toBe(true); + expect(isProviderActive(c, 'gitea')).toBe(true); + }); + it('a valid primary restricts to that provider only', () => { + const c = authCfg({ google: COMPLETE, gitea: COMPLETE_GITEA }, 'google'); + expect(isProviderActive(c, 'google')).toBe(true); + expect(isProviderActive(c, 'gitea')).toBe(false); + }); + it('an invalid primary (unconfigured provider) is ignored', () => { + const c = authCfg({ gitea: COMPLETE_GITEA }, 'google'); + expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable + expect(isProviderActive(c, 'google')).toBe(false); // google not configured + }); +}); diff --git a/src/bridge/auth.ts b/src/bridge/auth.ts index bc271f0..2688952 100644 --- a/src/bridge/auth.ts +++ b/src/bridge/auth.ts @@ -9,9 +9,10 @@ import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import type { Database } from 'better-sqlite3'; -import type { AuthConfig } from '../config.js'; +import type { AuthConfig, AuthProviderConfig } from '../config.js'; import type { Repository } from '../db/repository.js'; import { logger } from '../logger.js'; +import { randomBytes } from 'crypto'; /** * WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。 @@ -43,6 +44,36 @@ function escapeHtml(s: string): string { .replace(/'/g, '''); } +/** + * A provider is usable only when ALL fields needed to complete an OAuth round + * trip are present (Gitea additionally needs base_url). Used to gate auth + * activation and login-button visibility so a partial config saved from the + * Settings UI can't enable auth in a state where nobody can log in. + */ +export function isProviderConfigured( + p: AuthProviderConfig | undefined, + kind: 'google' | 'gitea', +): p is AuthProviderConfig { + if (!p || !p.clientId || !p.clientSecret || !p.callbackUrl) return false; + if (kind === 'gitea' && !p.baseUrl) return false; + return true; +} + +/** + * Whether a provider should actually be LIVE (strategy + /auth/ route). + * A valid `primaryProvider` restricts auth to that single provider, so the + * restriction is enforced at the route layer too — not just hidden on the login + * page (otherwise the "disabled" provider's route stayed open to direct URLs). + * An invalid primary (pointing at an unconfigured provider) is ignored. + */ +export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean { + if (!isProviderConfigured(authConfig.providers[kind], kind)) return false; + const primary = authConfig.primaryProvider; + if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google'; + if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea'; + return true; +} + /** * auth-login.html をレンダリングする。 * primary_provider 設定と各プロバイダの configured 状態に応じて @@ -51,9 +82,15 @@ function escapeHtml(s: string): string { */ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string { const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8'); - const primary = authConfig.primaryProvider; - const googleConfigured = !!authConfig.providers.google?.clientId; - const giteaConfigured = !!authConfig.providers.gitea?.clientId; + const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google'); + const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea'); + // Ignore a primaryProvider that points to an unconfigured provider — otherwise + // it would hide the only working login button and lock the operator out. + const primary = + (authConfig.primaryProvider === 'google' && googleConfigured) || + (authConfig.primaryProvider === 'gitea' && giteaConfigured) + ? authConfig.primaryProvider + : undefined; // Decide which buttons to show let showGoogle: boolean; @@ -315,7 +352,8 @@ export async function fetchGiteaOrgsForUser( function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void { const googleConfig = authConfig.providers.google; - if (!googleConfig) return; + if (!isProviderConfigured(googleConfig, 'google')) return; + if (!isProviderActive(authConfig, 'google')) return; passport.use( new GoogleStrategy( @@ -331,7 +369,7 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void await handleOAuthCallback( repo, - authConfig.adminEmails, + authConfig.adminEmails ?? [], 'google', profile.id, email, @@ -346,7 +384,8 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void { const giteaConfig = authConfig.providers.gitea; - if (!giteaConfig) return; + if (!isProviderConfigured(giteaConfig, 'gitea')) return; + if (!isProviderActive(authConfig, 'gitea')) return; const baseUrl = giteaConfig.baseUrl ?? ''; @@ -401,7 +440,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void { name, avatarUrl, }); - if (user.status === 'pending' && authConfig.adminEmails.includes(email)) { + if (user.status === 'pending' && (authConfig.adminEmails ?? []).includes(email)) { repo.updateUser(user.id, { status: 'active', role: 'admin' }); const updated = repo.getUserById(user.id); if (updated) user = updated; @@ -457,7 +496,7 @@ function createAuthRouter( }); // Google OAuth - if (authConfig.providers.google) { + if (isProviderActive(authConfig, 'google')) { router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); router.get( @@ -475,7 +514,7 @@ function createAuthRouter( } // Gitea OAuth - if (authConfig.providers.gitea) { + if (isProviderActive(authConfig, 'gitea')) { router.get('/gitea', passport.authenticate('gitea')); router.get( @@ -533,9 +572,22 @@ export function setupAuth( ): AuthMiddlewares { const db = repo.getDb(); + // express-session throws "secret option required" (→ 500 on every request) if + // the secret is empty. Auth can be enabled from the Settings UI before a + // session_secret is set, so fall back to a random per-process secret with a + // warning rather than bricking the server. Sessions reset on restart until a + // stable value is configured. + let sessionSecret = authConfig.sessionSecret; + if (!sessionSecret || sessionSecret.length === 0) { + sessionSecret = randomBytes(32).toString('hex'); + logger.warn( + '[auth] auth.session_secret is unset — using a random per-process secret; sessions reset on restart. Set a stable value in Settings → Authentication.', + ); + } + // セッションミドルウェア const sessionMiddleware = session({ - secret: authConfig.sessionSecret, + secret: sessionSecret, resave: false, saveUninitialized: false, store: createSqliteSessionStore(db), diff --git a/src/bridge/server.ts b/src/bridge/server.ts index df49133..51769bc 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js'; import { setUserFolderToolDeps } from '../engine/tools/user-folder.js'; import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setAppDocsDeps } from '../engine/tools/app-docs.js'; -import { setupAuth, requireAuth, requireAdmin } from './auth.js'; +import { setupAuth, requireAuth, requireAdmin, isProviderConfigured } from './auth.js'; import { canUserSeeTask } from './visibility.js'; import { mountAdminApi } from './admin-api.js'; import { createAdminGatewayApi } from './admin-gateway-api.js'; @@ -219,7 +219,33 @@ export function createCoreServer(opts: CoreServerOptions): { app.use('/api/local/reflection', express.json()); // === Auth setup === - const authActive = !!(opts.authConfig?.providers); + // Auth activates when a provider is COMPLETELY configured (clientId + + // clientSecret + callbackUrl, plus baseUrl for Gitea). + // + // FAIL CLOSED on a partial config: if the operator clearly INTENDED auth + // (a provider has a client_id) but it's incomplete (typo'd / missing + // secret/callback), we must NOT silently fall back to no-auth — that would + // fail OPEN and expose /api/local, /api/config, etc. without authentication. + // Refuse to start instead, with a clear message. (A bare `auth` block with no + // client_id at all is treated as genuine no-auth mode.) + const _authProviders = opts.authConfig?.providers; + const authUsable = + isProviderConfigured(_authProviders?.google, 'google') || + isProviderConfigured(_authProviders?.gitea, 'gitea'); + // "Intended" = ANY OAuth field is present on a provider (not just client_id). + // Saving only client_secret/callback_url/base_url, or clearing client_id by + // mistake, must still fail closed rather than drop to no-auth. + const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) => + !!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl); + const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea); + if (authIntended && !authUsable) { + throw new Error( + '[auth] auth is partially configured: a provider has a client_id but is missing ' + + 'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' + + 'insecure no-auth state — complete the provider config or remove it from config.yaml.', + ); + } + const authActive = authUsable; let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined; if (authActive) { diff --git a/src/config-manager.ts b/src/config-manager.ts index 54f5ded..63edbc4 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -7,7 +7,15 @@ import { createHash } from 'crypto'; import { logger } from './logger.js'; const MASKED = '********'; -const SENSITIVE_PATHS = ['tools.xAuthToken', 'tools.xCt0']; +const SENSITIVE_PATHS = [ + 'tools.xAuthToken', + 'tools.xCt0', + // Auth secrets now editable via AuthForm — mask in GET, restore on save so a + // partial edit doesn't expose or clobber them. + 'auth.sessionSecret', + 'auth.providers.google.clientSecret', + 'auth.providers.gitea.clientSecret', +]; /** * Keys stripped from `getConfigForApi` output. The v2 contract (design doc @@ -59,8 +67,13 @@ export class ConfigManager { obj = obj?.[parts[i]]; if (!obj) break; } - if (obj && parts[parts.length - 1] in obj) { - obj[parts[parts.length - 1]] = MASKED; + // Only mask a NON-EMPTY secret. Masking an empty/undefined value would + // make an unset secret look configured in the UI, and the mask-restore on + // save would then re-write the empty value (codex P2: empty session_secret + // would stay empty → random per-restart; empty OAuth secret → login fails). + const lastKey = parts[parts.length - 1]; + if (obj && typeof obj[lastKey] === 'string' && obj[lastKey].length > 0) { + obj[lastKey] = MASKED; } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7a09d7d..fc0a545 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,6 +16,7 @@ import { useTaskNotifications } from './hooks/useTaskNotifications'; import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications'; import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState'; import { confirmDiscardUnsaved } from './lib/unsavedGuard'; +import { useBackdropClose } from './lib/useBackdropClose'; import { TopBar } from './components/layout/TopBar'; import { NavDrawer } from './components/layout/NavDrawer'; import { useEdgeSwipe } from './hooks/useEdgeSwipe'; @@ -157,6 +158,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable setOverride((o) => (o && o.key === overrideKey ? o : null)); }, [overrideKey]); const [tabletDetailOpen, setTabletDetailOpen] = useState(false); + const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false)); const [navDrawerOpen, setNavDrawerOpen] = useState(false); const hamburgerRef = useRef(null); const compactMode = useCompactNav(isAdmin, authEnabled); @@ -632,7 +634,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable {/* Tablet: detail overlay */} {tabletDetailOpen && panelOpen && ( -
setTabletDetailOpen(false)}> +
e.stopPropagation()}> {localTaskId && ( ('markdown'); const [saving, setSaving] = useState(false); + const backdrop = useBackdropClose(() => { if (!saving) onClose(); }); if (!open) return null; @@ -46,7 +48,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop return (
!saving && onClose()} + {...backdrop} >
(''); const [resultExpanded, setResultExpanded] = useState(false); const qc = useQueryClient(); + const backdrop = useBackdropClose(onClose); const piecesQuery = usePieceList(); @@ -65,7 +67,7 @@ export function ContinueWithPieceDialog({ return (
{ if (e.target === e.currentTarget) onClose(); }} + {...backdrop} >
{/* Header */} diff --git a/ui/src/components/embed/EmbedModal.tsx b/ui/src/components/embed/EmbedModal.tsx index b46296a..f8d3946 100644 --- a/ui/src/components/embed/EmbedModal.tsx +++ b/ui/src/components/embed/EmbedModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useCallback, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; +import { useBackdropClose } from '../../lib/useBackdropClose'; interface EmbedModalProps { open: boolean; @@ -8,6 +9,7 @@ interface EmbedModalProps { } export function EmbedModal({ open, onClose, children }: EmbedModalProps) { + const backdrop = useBackdropClose(onClose); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }, [onClose]); @@ -28,10 +30,10 @@ export function EmbedModal({ open, onClose, children }: EmbedModalProps) { return createPortal(
- {/* Backdrop */} -
+ {/* Backdrop — the close handler lives here (the actual clicked element), + not the transparent outer div, so target===currentTarget holds. */} +
{/* Modal content */}
+
(null); const firstItemRef = useRef(null); + const backdrop = useBackdropClose(onClose); useEffect(() => { if (!open) return; @@ -151,7 +153,7 @@ export function NavDrawer({ <>
+

Authentication

+

+ ログイン認証。providers を未設定にすると 認証なし(全員ローカル admin)で動作する。 + 変更後はサーバ再起動が必要。 +

+ +
+ Primary Provider + + 単一プロバイダーに限定する場合に指定。未指定なら設定済みの全プロバイダーでログイン可 +
+ +
+ Admin Emails + onChange('auth.adminEmails', v)} + placeholder="admin@example.com" + /> + admin ロールを付与するメールアドレス +
+ +
+ + 本番(HTTPS)では有効推奨。HTTP のローカル開発では無効 +
+ +
+ Session Max Age (ms) + onChange('auth.sessionMaxAge', v ? Number(v) : undefined)} /> + セッションの有効期間(ミリ秒) +
+ +
+ Session Secret + onChange('auth.sessionSecret', v)} /> + セッション署名鍵。十分長いランダム文字列を設定(保存後はマスク表示) +
+ +

Google OAuth

+
+ Client ID + onChange('auth.providers.google.clientId', v)} /> +
+
+ Client Secret + onChange('auth.providers.google.clientSecret', v)} /> +
+
+ Callback URL + onChange('auth.providers.google.callbackUrl', v)} /> +
+ +

Gitea OAuth

+
+ Base URL + onChange('auth.providers.gitea.baseUrl', v)} /> +
+
+ Client ID + onChange('auth.providers.gitea.clientId', v)} /> +
+
+ Client Secret + onChange('auth.providers.gitea.clientSecret', v)} /> +
+
+ Callback URL + onChange('auth.providers.gitea.callbackUrl', v)} /> +
+
+ ); +} diff --git a/ui/src/components/settings/BrowserSettingsForm.tsx b/ui/src/components/settings/BrowserSettingsForm.tsx index 5723865..7b9aeb4 100644 --- a/ui/src/components/settings/BrowserSettingsForm.tsx +++ b/ui/src/components/settings/BrowserSettingsForm.tsx @@ -91,6 +91,13 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) { onChange={v => onChange('browser.maxSessions', Number(v))} /> 同時に起動できるセッションの最大数。デフォルト: 3
+ +
+ Task Session Idle TTL (秒) + onChange('browser.taskSessionIdleTtl', v ? Number(v) : undefined)} /> + タスク用 CDP セッションをアイドル後に破棄するまでの秒数(GC)。デフォルト: 300 +
); } diff --git a/ui/src/components/settings/ConfigForm.tsx b/ui/src/components/settings/ConfigForm.tsx index 8061874..506c604 100644 --- a/ui/src/components/settings/ConfigForm.tsx +++ b/ui/src/components/settings/ConfigForm.tsx @@ -28,6 +28,7 @@ import { SshForm } from './SshForm'; import { GatewayServerForm } from './GatewayServerForm'; import { NotesForm } from './NotesForm'; import { PushNotificationsForm } from './PushNotificationsForm'; +import { AuthForm } from './AuthForm'; import { useAuthState } from '../../App'; @@ -189,6 +190,7 @@ function ConfigFormInner({ section }: ConfigFormProps) { case 'reflection': return ; case 'notes': return ; case 'push-notifications': return ; + case 'auth': return ; // ── Tools sub-sections — Step 9 split the legacy grab-bag // ToolsForm into focused per-category forms. Each binds to the diff --git a/ui/src/components/settings/SettingsSidebar.tsx b/ui/src/components/settings/SettingsSidebar.tsx index 28f49ec..610d4aa 100644 --- a/ui/src/components/settings/SettingsSidebar.tsx +++ b/ui/src/components/settings/SettingsSidebar.tsx @@ -34,6 +34,7 @@ const CONFIG_GROUPS = [ { id: 'branding', label: 'Branding' }, { id: 'paths-storage', label: 'Paths & Storage' }, { id: 'execution', label: 'Execution' }, + { id: 'auth', label: 'Authentication' }, { id: 'push-notifications', label: 'Web Push (Server)' }, ], }, @@ -68,6 +69,7 @@ const CONFIG_GROUPS = [ { id: 'tools-browser', label: 'Browser Runtime' }, { id: 'tools-media', label: 'Media & Documents' }, { id: 'tools-external', label: 'External Services' }, + { id: 'search-filter', label: 'Search Filter' }, { id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' }, ], }, @@ -102,7 +104,6 @@ export const LEGACY_SECTION_REDIRECT: Record = { workspace: 'paths-storage', tools: 'tools-web', 'browser-settings': 'tools-browser', - 'search-filter': 'tools-web', // browser-sessions never had a Settings page in the new layout — // it lives in User Folder. Keep mapping so an old URL still goes // somewhere sensible. diff --git a/ui/src/components/settings/ToolsExternalForm.tsx b/ui/src/components/settings/ToolsExternalForm.tsx index 051e761..cf14ea1 100644 --- a/ui/src/components/settings/ToolsExternalForm.tsx +++ b/ui/src/components/settings/ToolsExternalForm.tsx @@ -63,6 +63,39 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) { placeholder="/path/to/chrome/profile" /> Cookie 抽出用の Chrome プロファイルディレクトリ。
+
+ X Media Download + + X 投稿の画像等メディアの自動ダウンロード。デフォルト: auto +
+
+ X Video Download + + X 投稿の動画の取得モード。デフォルト: thumbnail(帯域節約) +
+
+ X Media Max (MB) + onChange('tools.xMediaMaxMb', v ? Number(v) : undefined)} /> + 1 メディアあたりの最大ダウンロードサイズ(MB) +
+
+ X Media Fetch Timeout (秒) + onChange('tools.xMediaFetchTimeoutSeconds', v ? Number(v) : undefined)} /> + メディア取得のタイムアウト(秒) +
@@ -74,6 +107,12 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) { onChange('tools.googleMapsApiKey', v)} /> Google Maps Places / Directions API キー。未設定時は Nominatim / OSRM(無料)を使用。
+
+ Maps Timeout (秒) + onChange('tools.mapsTimeout', v ? Number(v) : undefined)} /> + Maps / Nominatim / OSRM 呼び出しのタイムアウト(秒)。デフォルト: 30 +
diff --git a/ui/src/components/userfolder/SaveAsScriptDialog.tsx b/ui/src/components/userfolder/SaveAsScriptDialog.tsx index 12bfe94..7c21794 100644 --- a/ui/src/components/userfolder/SaveAsScriptDialog.tsx +++ b/ui/src/components/userfolder/SaveAsScriptDialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { listBrowserSessionProfiles } from '../../api'; +import { useBackdropClose } from '../../lib/useBackdropClose'; export interface ParamHint { name: string; @@ -56,6 +57,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs const [overwrite, setOverwrite] = useState(false); const [validationError, setValidationError] = useState(null); const [conflictError, setConflictError] = useState(null); + const backdrop = useBackdropClose(onClose); const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({ queryKey: ['browser-session-profiles'], @@ -141,7 +143,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs /* Backdrop */
{ if (e.target === e.currentTarget) onClose(); }} + {...backdrop} >
{/* Header */} diff --git a/ui/src/components/userfolder/SubscriptionsPanel.tsx b/ui/src/components/userfolder/SubscriptionsPanel.tsx index d611645..e231541 100644 --- a/ui/src/components/userfolder/SubscriptionsPanel.tsx +++ b/ui/src/components/userfolder/SubscriptionsPanel.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MarkdownText } from '../../lib/markdown-text'; +import { useBackdropClose } from '../../lib/useBackdropClose'; interface Subscription { consumer_user_id: string; @@ -110,11 +111,12 @@ function NoteContentModal({ }); const title = (note.data?.fm.title as string | undefined) || fileName; + const backdrop = useBackdropClose(onClose); return (
...
+ * + * Close only happens when BOTH the mousedown and the click target are the + * backdrop itself, so a genuine backdrop click still closes, but a + * press-inside / release-on-backdrop drag does not. + */ +export function useBackdropClose(onClose: () => void) { + const downOnBackdrop = useRef(false); + return { + onMouseDown: (e: React.MouseEvent) => { + downOnBackdrop.current = e.target === e.currentTarget; + }, + onClick: (e: React.MouseEvent) => { + if (downOnBackdrop.current && e.target === e.currentTarget) { + onClose(); + } + downOnBackdrop.current = false; + }, + }; +} diff --git a/ui/src/pages/PiecesPage.tsx b/ui/src/pages/PiecesPage.tsx index 87bbcbe..193d115 100644 --- a/ui/src/pages/PiecesPage.tsx +++ b/ui/src/pages/PiecesPage.tsx @@ -5,6 +5,7 @@ import { usePieceList } from '../hooks/usePieces'; import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api'; import { PieceEditor } from '../components/settings/PieceEditor'; import { splitPieces } from '../lib/splitPieces'; +import { useBackdropClose } from '../lib/useBackdropClose'; type PieceSource = 'builtin' | 'user-custom' | 'global-custom'; @@ -136,6 +137,7 @@ function PiecesSidebar({ const [duplicateName, setDuplicateName] = useState(''); const [duplicateError, setDuplicateError] = useState(null); const [duplicating, setDuplicating] = useState(false); + const duplicateBackdrop = useBackdropClose(() => cancelDuplicate()); const notifyError = (label: string, err: unknown) => { const msg = `${label}: ${err instanceof Error ? err.message : String(err)}`; @@ -286,7 +288,7 @@ function PiecesSidebar({ aria-modal="true" aria-labelledby="dup-piece-label" className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4" - onClick={cancelDuplicate} + {...duplicateBackdrop} >