sync: update from private repo (63a6e76)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 03:17:43 +00:00
parent d6d8e83867
commit 3848b5efd7
20 changed files with 386 additions and 29 deletions

View File

@ -3,7 +3,8 @@ import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { Repository } from '../db/repository.js'; 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'; import type { Request, Response, NextFunction } from 'express';
function mockReqRes(overrides: Partial<Request> = {}) { function mockReqRes(overrides: Partial<Request> = {}) {
@ -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<AuthConfig['providers']>,
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
});
});

View File

@ -9,9 +9,10 @@ import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3'; 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 type { Repository } from '../db/repository.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
/** /**
* WebSocket upgrade IncomingMessage * WebSocket upgrade IncomingMessage
@ -43,6 +44,36 @@ function escapeHtml(s: string): string {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/**
* 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/<kind> 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 * auth-login.html
* primary_provider configured * primary_provider configured
@ -51,9 +82,15 @@ function escapeHtml(s: string): string {
*/ */
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string { function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8'); const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
const primary = authConfig.primaryProvider; const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google');
const googleConfigured = !!authConfig.providers.google?.clientId; const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea');
const giteaConfigured = !!authConfig.providers.gitea?.clientId; // 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 // Decide which buttons to show
let showGoogle: boolean; let showGoogle: boolean;
@ -315,7 +352,8 @@ export async function fetchGiteaOrgsForUser(
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void { function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
const googleConfig = authConfig.providers.google; const googleConfig = authConfig.providers.google;
if (!googleConfig) return; if (!isProviderConfigured(googleConfig, 'google')) return;
if (!isProviderActive(authConfig, 'google')) return;
passport.use( passport.use(
new GoogleStrategy( new GoogleStrategy(
@ -331,7 +369,7 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void
await handleOAuthCallback( await handleOAuthCallback(
repo, repo,
authConfig.adminEmails, authConfig.adminEmails ?? [],
'google', 'google',
profile.id, profile.id,
email, email,
@ -346,7 +384,8 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void { function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
const giteaConfig = authConfig.providers.gitea; const giteaConfig = authConfig.providers.gitea;
if (!giteaConfig) return; if (!isProviderConfigured(giteaConfig, 'gitea')) return;
if (!isProviderActive(authConfig, 'gitea')) return;
const baseUrl = giteaConfig.baseUrl ?? ''; const baseUrl = giteaConfig.baseUrl ?? '';
@ -401,7 +440,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
name, name,
avatarUrl, 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' }); repo.updateUser(user.id, { status: 'active', role: 'admin' });
const updated = repo.getUserById(user.id); const updated = repo.getUserById(user.id);
if (updated) user = updated; if (updated) user = updated;
@ -457,7 +496,7 @@ function createAuthRouter(
}); });
// Google OAuth // Google OAuth
if (authConfig.providers.google) { if (isProviderActive(authConfig, 'google')) {
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
router.get( router.get(
@ -475,7 +514,7 @@ function createAuthRouter(
} }
// Gitea OAuth // Gitea OAuth
if (authConfig.providers.gitea) { if (isProviderActive(authConfig, 'gitea')) {
router.get('/gitea', passport.authenticate('gitea')); router.get('/gitea', passport.authenticate('gitea'));
router.get( router.get(
@ -533,9 +572,22 @@ export function setupAuth(
): AuthMiddlewares { ): AuthMiddlewares {
const db = repo.getDb(); 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({ const sessionMiddleware = session({
secret: authConfig.sessionSecret, secret: sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
store: createSqliteSessionStore(db), store: createSqliteSessionStore(db),

View File

@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js'; import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.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 { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js'; import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-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()); app.use('/api/local/reflection', express.json());
// === Auth setup === // === 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; let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
if (authActive) { if (authActive) {

View File

@ -7,7 +7,15 @@ import { createHash } from 'crypto';
import { logger } from './logger.js'; import { logger } from './logger.js';
const MASKED = '********'; 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 * Keys stripped from `getConfigForApi` output. The v2 contract (design doc
@ -59,8 +67,13 @@ export class ConfigManager {
obj = obj?.[parts[i]]; obj = obj?.[parts[i]];
if (!obj) break; if (!obj) break;
} }
if (obj && parts[parts.length - 1] in obj) { // Only mask a NON-EMPTY secret. Masking an empty/undefined value would
obj[parts[parts.length - 1]] = MASKED; // 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;
} }
} }

View File

@ -16,6 +16,7 @@ import { useTaskNotifications } from './hooks/useTaskNotifications';
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications'; import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState'; import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState';
import { confirmDiscardUnsaved } from './lib/unsavedGuard'; import { confirmDiscardUnsaved } from './lib/unsavedGuard';
import { useBackdropClose } from './lib/useBackdropClose';
import { TopBar } from './components/layout/TopBar'; import { TopBar } from './components/layout/TopBar';
import { NavDrawer } from './components/layout/NavDrawer'; import { NavDrawer } from './components/layout/NavDrawer';
import { useEdgeSwipe } from './hooks/useEdgeSwipe'; 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)); setOverride((o) => (o && o.key === overrideKey ? o : null));
}, [overrideKey]); }, [overrideKey]);
const [tabletDetailOpen, setTabletDetailOpen] = useState(false); const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
const [navDrawerOpen, setNavDrawerOpen] = useState(false); const [navDrawerOpen, setNavDrawerOpen] = useState(false);
const hamburgerRef = useRef<HTMLButtonElement>(null); const hamburgerRef = useRef<HTMLButtonElement>(null);
const compactMode = useCompactNav(isAdmin, authEnabled); const compactMode = useCompactNav(isAdmin, authEnabled);
@ -632,7 +634,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{/* Tablet: detail overlay */} {/* Tablet: detail overlay */}
{tabletDetailOpen && panelOpen && ( {tabletDetailOpen && panelOpen && (
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" onClick={() => setTabletDetailOpen(false)}> <div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}> <div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
{localTaskId && ( {localTaskId && (
<LocalDetailPanel <LocalDetailPanel

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { DashboardWidgetKind } from '../../api'; import type { DashboardWidgetKind } from '../../api';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface Props { interface Props {
open: boolean; open: boolean;
@ -35,6 +36,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [kind, setKind] = useState<DashboardWidgetKind>('markdown'); const [kind, setKind] = useState<DashboardWidgetKind>('markdown');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const backdrop = useBackdropClose(() => { if (!saving) onClose(); });
if (!open) return null; if (!open) return null;
@ -46,7 +48,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={() => !saving && onClose()} {...backdrop}
> >
<div <div
className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3" className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"

View File

@ -4,6 +4,7 @@ import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
import { usePieceList } from '../../hooks/usePieces'; import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces'; import { resolvePieceOptions } from '../../lib/splitPieces';
import { MarkdownText } from '../../lib/markdown-text'; import { MarkdownText } from '../../lib/markdown-text';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface PrevJobInfo { interface PrevJobInfo {
id: string; id: string;
@ -26,6 +27,7 @@ export function ContinueWithPieceDialog({
const [instruction, setInstruction] = useState<string>(''); const [instruction, setInstruction] = useState<string>('');
const [resultExpanded, setResultExpanded] = useState<boolean>(false); const [resultExpanded, setResultExpanded] = useState<boolean>(false);
const qc = useQueryClient(); const qc = useQueryClient();
const backdrop = useBackdropClose(onClose);
const piecesQuery = usePieceList(); const piecesQuery = usePieceList();
@ -65,7 +67,7 @@ export function ContinueWithPieceDialog({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={e => { if (e.target === e.currentTarget) onClose(); }} {...backdrop}
> >
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]"> <div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */} {/* Header */}

View File

@ -1,5 +1,6 @@
import { useEffect, useCallback, type ReactNode } from 'react'; import { useEffect, useCallback, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface EmbedModalProps { interface EmbedModalProps {
open: boolean; open: boolean;
@ -8,6 +9,7 @@ interface EmbedModalProps {
} }
export function EmbedModal({ open, onClose, children }: EmbedModalProps) { export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
const backdrop = useBackdropClose(onClose);
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
}, [onClose]); }, [onClose]);
@ -28,10 +30,10 @@ export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
> >
{/* Backdrop */} {/* Backdrop the close handler lives here (the actual clicked element),
<div className="absolute inset-0 bg-black/50" /> not the transparent outer div, so target===currentTarget holds. */}
<div className="absolute inset-0 bg-black/50" {...backdrop} />
{/* Modal content */} {/* Modal content */}
<div <div

View File

@ -7,6 +7,7 @@ import hljs from 'highlight.js';
import { updateLocalFileContent } from '../../api'; import { updateLocalFileContent } from '../../api';
import { EmbedBlock } from '../embed/EmbedBlock'; import { EmbedBlock } from '../embed/EmbedBlock';
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect'; import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
import { useBackdropClose } from '../../lib/useBackdropClose';
mermaid.initialize({ startOnLoad: false, theme: 'default' }); mermaid.initialize({ startOnLoad: false, theme: 'default' });
@ -625,6 +626,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
const [error, setError] = useState(''); const [error, setError] = useState('');
const [currentContent, setCurrentContent] = useState(content); const [currentContent, setCurrentContent] = useState(content);
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
const backdrop = useBackdropClose(onClose);
const canEdit = editable && taskId != null && section && filePath; const canEdit = editable && taskId != null && section && filePath;
const isMarkdownFile = /\.(md|markdown)$/i.test(name); const isMarkdownFile = /\.(md|markdown)$/i.test(name);
@ -734,7 +736,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
const modalWidth = 'min(1400px, 96vw)'; const modalWidth = 'min(1400px, 96vw)';
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" {...backdrop}>
<div <div
className="bg-surface rounded-md border border-hairline shadow-md flex flex-col overflow-hidden" className="bg-surface rounded-md border border-hairline shadow-md flex flex-col overflow-hidden"
style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }} style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }}

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import type { PageId } from '../../lib/urlState'; import type { PageId } from '../../lib/urlState';
import { useBackdropClose } from '../../lib/useBackdropClose';
export interface NavItem { export interface NavItem {
id: PageId; id: PageId;
@ -97,6 +98,7 @@ export function NavDrawer({
}: NavDrawerProps) { }: NavDrawerProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const firstItemRef = useRef<HTMLButtonElement>(null); const firstItemRef = useRef<HTMLButtonElement>(null);
const backdrop = useBackdropClose(onClose);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -151,7 +153,7 @@ export function NavDrawer({
<> <>
<div <div
aria-hidden aria-hidden
onClick={onClose} {...backdrop}
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${ className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none' open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`} }`}

View File

@ -0,0 +1,116 @@
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
import type { SectionFormProps } from './types';
/**
* Authentication config (`auth.*`): session policy, admin allowlist, and the
* Google / Gitea OAuth providers. Secrets (session secret, client secrets) are
* server-masked (config-manager SENSITIVE_PATHS) they render as ******** and
* saving without re-entering keeps the stored value.
*
* Leaving the whole `auth` block empty (no providers) keeps the app in no-auth
* mode every visitor is treated as a local admin.
*/
export function AuthForm({ config, onChange }: SectionFormProps) {
const auth = config.auth ?? {};
const providers = auth.providers ?? {};
const google = providers.google ?? {};
const gitea = providers.gitea ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Authentication</h2>
<p className="text-[13px] text-slate-500">
providers <strong> admin</strong>
</p>
<div>
<FieldLabel>Primary Provider</FieldLabel>
<select
value={auth.primaryProvider ?? ''}
onChange={e => onChange('auth.primaryProvider', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value=""></option>
<option value="google">google </option>
<option value="gitea">gitea </option>
</select>
<HelpText></HelpText>
</div>
<div>
<FieldLabel>Admin Emails</FieldLabel>
<StringArrayEditor
value={auth.adminEmails ?? []}
onChange={v => onChange('auth.adminEmails', v)}
placeholder="admin@example.com"
/>
<HelpText>admin </HelpText>
</div>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={auth.secureCookie === true}
onChange={e => onChange('auth.secureCookie', e.target.checked)} className="rounded" />
Secure CookieHTTPS Cookie
</label>
<HelpText>HTTPSHTTP </HelpText>
</div>
<div>
<FieldLabel>Session Max Age (ms)</FieldLabel>
<FieldInput type="number" value={auth.sessionMaxAge ?? ''}
onChange={v => onChange('auth.sessionMaxAge', v ? Number(v) : undefined)} />
<HelpText></HelpText>
</div>
<div>
<FieldLabel>Session Secret</FieldLabel>
<FieldInput type="password" value={auth.sessionSecret ?? ''}
onChange={v => onChange('auth.sessionSecret', v)} />
<HelpText></HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Google OAuth</h3>
<div>
<FieldLabel>Client ID</FieldLabel>
<FieldInput value={google.clientId ?? ''}
onChange={v => onChange('auth.providers.google.clientId', v)} />
</div>
<div>
<FieldLabel>Client Secret</FieldLabel>
<FieldInput type="password" value={google.clientSecret ?? ''}
onChange={v => onChange('auth.providers.google.clientSecret', v)} />
</div>
<div>
<FieldLabel>Callback URL</FieldLabel>
<FieldInput value={google.callbackUrl ?? ''} placeholder="https://example.com/auth/google/callback"
onChange={v => onChange('auth.providers.google.callbackUrl', v)} />
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Gitea OAuth</h3>
<div>
<FieldLabel>Base URL</FieldLabel>
<FieldInput value={gitea.baseUrl ?? ''} placeholder="https://gitea.example.com"
onChange={v => onChange('auth.providers.gitea.baseUrl', v)} />
</div>
<div>
<FieldLabel>Client ID</FieldLabel>
<FieldInput value={gitea.clientId ?? ''}
onChange={v => onChange('auth.providers.gitea.clientId', v)} />
</div>
<div>
<FieldLabel>Client Secret</FieldLabel>
<FieldInput type="password" value={gitea.clientSecret ?? ''}
onChange={v => onChange('auth.providers.gitea.clientSecret', v)} />
</div>
<div>
<FieldLabel>Callback URL</FieldLabel>
<FieldInput value={gitea.callbackUrl ?? ''} placeholder="https://example.com/auth/gitea/callback"
onChange={v => onChange('auth.providers.gitea.callbackUrl', v)} />
</div>
</div>
);
}

View File

@ -91,6 +91,13 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('browser.maxSessions', Number(v))} /> onChange={v => onChange('browser.maxSessions', Number(v))} />
<HelpText>デフォルト: 3</HelpText> <HelpText>デフォルト: 3</HelpText>
</div> </div>
<div>
<FieldLabel>Task Session Idle TTL ()</FieldLabel>
<FieldInput type="number" value={browser.taskSessionIdleTtl ?? ''}
onChange={v => onChange('browser.taskSessionIdleTtl', v ? Number(v) : undefined)} />
<HelpText> CDP GCデフォルト: 300</HelpText>
</div>
</div> </div>
); );
} }

View File

@ -28,6 +28,7 @@ import { SshForm } from './SshForm';
import { GatewayServerForm } from './GatewayServerForm'; import { GatewayServerForm } from './GatewayServerForm';
import { NotesForm } from './NotesForm'; import { NotesForm } from './NotesForm';
import { PushNotificationsForm } from './PushNotificationsForm'; import { PushNotificationsForm } from './PushNotificationsForm';
import { AuthForm } from './AuthForm';
import { useAuthState } from '../../App'; import { useAuthState } from '../../App';
@ -189,6 +190,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'reflection': return <ReflectionForm {...formProps} />; case 'reflection': return <ReflectionForm {...formProps} />;
case 'notes': return <NotesForm {...formProps} />; case 'notes': return <NotesForm {...formProps} />;
case 'push-notifications': return <PushNotificationsForm {...formProps} />; case 'push-notifications': return <PushNotificationsForm {...formProps} />;
case 'auth': return <AuthForm {...formProps} />;
// ── Tools sub-sections — Step 9 split the legacy grab-bag // ── Tools sub-sections — Step 9 split the legacy grab-bag
// ToolsForm into focused per-category forms. Each binds to the // ToolsForm into focused per-category forms. Each binds to the

View File

@ -34,6 +34,7 @@ const CONFIG_GROUPS = [
{ id: 'branding', label: 'Branding' }, { id: 'branding', label: 'Branding' },
{ id: 'paths-storage', label: 'Paths & Storage' }, { id: 'paths-storage', label: 'Paths & Storage' },
{ id: 'execution', label: 'Execution' }, { id: 'execution', label: 'Execution' },
{ id: 'auth', label: 'Authentication' },
{ id: 'push-notifications', label: 'Web Push (Server)' }, { id: 'push-notifications', label: 'Web Push (Server)' },
], ],
}, },
@ -68,6 +69,7 @@ const CONFIG_GROUPS = [
{ id: 'tools-browser', label: 'Browser Runtime' }, { id: 'tools-browser', label: 'Browser Runtime' },
{ id: 'tools-media', label: 'Media & Documents' }, { id: 'tools-media', label: 'Media & Documents' },
{ id: 'tools-external', label: 'External Services' }, { id: 'tools-external', label: 'External Services' },
{ id: 'search-filter', label: 'Search Filter' },
{ id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' }, { id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' },
], ],
}, },
@ -102,7 +104,6 @@ export const LEGACY_SECTION_REDIRECT: Record<string, string> = {
workspace: 'paths-storage', workspace: 'paths-storage',
tools: 'tools-web', tools: 'tools-web',
'browser-settings': 'tools-browser', 'browser-settings': 'tools-browser',
'search-filter': 'tools-web',
// browser-sessions never had a Settings page in the new layout — // browser-sessions never had a Settings page in the new layout —
// it lives in User Folder. Keep mapping so an old URL still goes // it lives in User Folder. Keep mapping so an old URL still goes
// somewhere sensible. // somewhere sensible.

View File

@ -63,6 +63,39 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
placeholder="/path/to/chrome/profile" /> placeholder="/path/to/chrome/profile" />
<HelpText>Cookie Chrome </HelpText> <HelpText>Cookie Chrome </HelpText>
</div> </div>
<div>
<FieldLabel>X Media Download</FieldLabel>
<select value={tools.xDownloadMedia ?? 'auto'}
onChange={e => onChange('tools.xDownloadMedia', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
<option value="auto">auto/</option>
<option value="never">never</option>
</select>
<HelpText>X 稿デフォルト: auto</HelpText>
</div>
<div>
<FieldLabel>X Video Download</FieldLabel>
<select value={tools.xDownloadVideo ?? 'thumbnail'}
onChange={e => onChange('tools.xDownloadVideo', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
<option value="thumbnail">thumbnail</option>
<option value="full">full</option>
<option value="never">never</option>
</select>
<HelpText>X 稿デフォルト: thumbnail</HelpText>
</div>
<div>
<FieldLabel>X Media Max (MB)</FieldLabel>
<FieldInput type="number" value={tools.xMediaMaxMb ?? ''}
onChange={v => onChange('tools.xMediaMaxMb', v ? Number(v) : undefined)} />
<HelpText>1 MB</HelpText>
</div>
<div>
<FieldLabel>X Media Fetch Timeout ()</FieldLabel>
<FieldInput type="number" value={tools.xMediaFetchTimeoutSeconds ?? ''}
onChange={v => onChange('tools.xMediaFetchTimeoutSeconds', v ? Number(v) : undefined)} />
<HelpText></HelpText>
</div>
</section> </section>
<section className="space-y-5 pt-2 border-t border-hairline"> <section className="space-y-5 pt-2 border-t border-hairline">
@ -74,6 +107,12 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} /> <FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
<HelpText>Google Maps Places / Directions API Nominatim / OSRM使</HelpText> <HelpText>Google Maps Places / Directions API Nominatim / OSRM使</HelpText>
</div> </div>
<div>
<FieldLabel>Maps Timeout ()</FieldLabel>
<FieldInput type="number" value={tools.mapsTimeout ?? ''}
onChange={v => onChange('tools.mapsTimeout', v ? Number(v) : undefined)} />
<HelpText>Maps / Nominatim / OSRM デフォルト: 30</HelpText>
</div>
</section> </section>
<section className="space-y-5 pt-2 border-t border-hairline"> <section className="space-y-5 pt-2 border-t border-hairline">

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listBrowserSessionProfiles } from '../../api'; import { listBrowserSessionProfiles } from '../../api';
import { useBackdropClose } from '../../lib/useBackdropClose';
export interface ParamHint { export interface ParamHint {
name: string; name: string;
@ -56,6 +57,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs
const [overwrite, setOverwrite] = useState(false); const [overwrite, setOverwrite] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [conflictError, setConflictError] = useState<string | null>(null); const [conflictError, setConflictError] = useState<string | null>(null);
const backdrop = useBackdropClose(onClose);
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({ const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
queryKey: ['browser-session-profiles'], queryKey: ['browser-session-profiles'],
@ -141,7 +143,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs
/* Backdrop */ /* Backdrop */
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={e => { if (e.target === e.currentTarget) onClose(); }} {...backdrop}
> >
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]"> <div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */} {/* Header */}

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MarkdownText } from '../../lib/markdown-text'; import { MarkdownText } from '../../lib/markdown-text';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface Subscription { interface Subscription {
consumer_user_id: string; consumer_user_id: string;
@ -110,11 +111,12 @@ function NoteContentModal({
}); });
const title = (note.data?.fm.title as string | undefined) || fileName; const title = (note.data?.fm.title as string | undefined) || fileName;
const backdrop = useBackdropClose(onClose);
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
onClick={onClose} {...backdrop}
> >
<div <div
className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden" className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"

View File

@ -12,6 +12,7 @@ const SETTINGS_SECTIONS = [
'branding', 'branding',
'paths-storage', 'paths-storage',
'execution', 'execution',
'auth',
'push-notifications', 'push-notifications',
// LLM group // LLM group
'llm-workers', 'llm-workers',

View File

@ -0,0 +1,33 @@
import { useRef } from 'react';
/**
* Backdrop "click outside to close" that does NOT fire when a drag started
* inside the modal (e.g. selecting text in an input) and was released on the
* backdrop. A plain `onClick={onClose}` closes in that case because the browser
* dispatches `click` on the common ancestor of mousedown+mouseup which is the
* backdrop losing the user's edits.
*
* Spread the returned props on the backdrop element and REMOVE the old
* `onClick={onClose}`:
*
* const backdrop = useBackdropClose(onClose);
* <div className="fixed inset-0 ..." {...backdrop}> ... </div>
*
* 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;
},
};
}

View File

@ -5,6 +5,7 @@ import { usePieceList } from '../hooks/usePieces';
import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api'; import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api';
import { PieceEditor } from '../components/settings/PieceEditor'; import { PieceEditor } from '../components/settings/PieceEditor';
import { splitPieces } from '../lib/splitPieces'; import { splitPieces } from '../lib/splitPieces';
import { useBackdropClose } from '../lib/useBackdropClose';
type PieceSource = 'builtin' | 'user-custom' | 'global-custom'; type PieceSource = 'builtin' | 'user-custom' | 'global-custom';
@ -136,6 +137,7 @@ function PiecesSidebar({
const [duplicateName, setDuplicateName] = useState(''); const [duplicateName, setDuplicateName] = useState('');
const [duplicateError, setDuplicateError] = useState<string | null>(null); const [duplicateError, setDuplicateError] = useState<string | null>(null);
const [duplicating, setDuplicating] = useState(false); const [duplicating, setDuplicating] = useState(false);
const duplicateBackdrop = useBackdropClose(() => cancelDuplicate());
const notifyError = (label: string, err: unknown) => { const notifyError = (label: string, err: unknown) => {
const msg = `${label}: ${err instanceof Error ? err.message : String(err)}`; const msg = `${label}: ${err instanceof Error ? err.message : String(err)}`;
@ -286,7 +288,7 @@ function PiecesSidebar({
aria-modal="true" aria-modal="true"
aria-labelledby="dup-piece-label" aria-labelledby="dup-piece-label"
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4" className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4"
onClick={cancelDuplicate} {...duplicateBackdrop}
> >
<div <div
className="w-full max-w-sm rounded-lg border border-hairline bg-canvas p-4 shadow-xl" className="w-full max-w-sm rounded-lg border border-hairline bg-canvas p-4 shadow-xl"