sync: update from private repo (63a6e76)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
d6d8e83867
commit
3848b5efd7
@ -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<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
|
||||
});
|
||||
});
|
||||
|
||||
@ -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/<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 をレンダリングする。
|
||||
* 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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<HTMLButtonElement>(null);
|
||||
const compactMode = useCompactNav(isAdmin, authEnabled);
|
||||
@ -632,7 +634,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
|
||||
{/* Tablet: detail overlay */}
|
||||
{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()}>
|
||||
{localTaskId && (
|
||||
<LocalDetailPanel
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardWidgetKind } from '../../api';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@ -35,6 +36,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
|
||||
const [title, setTitle] = useState('');
|
||||
const [kind, setKind] = useState<DashboardWidgetKind>('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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={() => !saving && onClose()}
|
||||
{...backdrop}
|
||||
>
|
||||
<div
|
||||
className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"
|
||||
|
||||
@ -4,6 +4,7 @@ import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface PrevJobInfo {
|
||||
id: string;
|
||||
@ -26,6 +27,7 @@ export function ContinueWithPieceDialog({
|
||||
const [instruction, setInstruction] = useState<string>('');
|
||||
const [resultExpanded, setResultExpanded] = useState<boolean>(false);
|
||||
const qc = useQueryClient();
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
const piecesQuery = usePieceList();
|
||||
|
||||
@ -65,7 +67,7 @@ export function ContinueWithPieceDialog({
|
||||
return (
|
||||
<div
|
||||
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]">
|
||||
{/* Header */}
|
||||
|
||||
@ -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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
{/* Backdrop — the close handler lives here (the actual clicked element),
|
||||
not the transparent outer div, so target===currentTarget holds. */}
|
||||
<div className="absolute inset-0 bg-black/50" {...backdrop} />
|
||||
|
||||
{/* Modal content */}
|
||||
<div
|
||||
|
||||
@ -7,6 +7,7 @@ import hljs from 'highlight.js';
|
||||
import { updateLocalFileContent } from '../../api';
|
||||
import { EmbedBlock } from '../embed/EmbedBlock';
|
||||
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
|
||||
@ -625,6 +626,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
const [error, setError] = useState('');
|
||||
const [currentContent, setCurrentContent] = useState(content);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
const canEdit = editable && taskId != null && section && filePath;
|
||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||
@ -734,7 +736,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
const modalWidth = 'min(1400px, 96vw)';
|
||||
|
||||
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
|
||||
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))' }}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
export interface NavItem {
|
||||
id: PageId;
|
||||
@ -97,6 +98,7 @@ export function NavDrawer({
|
||||
}: NavDrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -151,7 +153,7 @@ export function NavDrawer({
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
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'
|
||||
}`}
|
||||
|
||||
116
ui/src/components/settings/AuthForm.tsx
Normal file
116
ui/src/components/settings/AuthForm.tsx
Normal 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 Cookie(HTTPS のみで Cookie 送信)
|
||||
</label>
|
||||
<HelpText>本番(HTTPS)では有効推奨。HTTP のローカル開発では無効</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>
|
||||
);
|
||||
}
|
||||
@ -91,6 +91,13 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => onChange('browser.maxSessions', Number(v))} />
|
||||
<HelpText>同時に起動できるセッションの最大数。デフォルト: 3</HelpText>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 <ReflectionForm {...formProps} />;
|
||||
case 'notes': return <NotesForm {...formProps} />;
|
||||
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
|
||||
case 'auth': return <AuthForm {...formProps} />;
|
||||
|
||||
// ── Tools sub-sections — Step 9 split the legacy grab-bag
|
||||
// ToolsForm into focused per-category forms. Each binds to the
|
||||
|
||||
@ -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<string, string> = {
|
||||
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.
|
||||
|
||||
@ -63,6 +63,39 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
</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 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)} />
|
||||
<HelpText>Google Maps Places / Directions API キー。未設定時は Nominatim / OSRM(無料)を使用。</HelpText>
|
||||
</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 className="space-y-5 pt-2 border-t border-hairline">
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [conflictError, setConflictError] = useState<string | null>(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 */
|
||||
<div
|
||||
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]">
|
||||
{/* Header */}
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
>
|
||||
<div
|
||||
className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
|
||||
|
||||
@ -12,6 +12,7 @@ const SETTINGS_SECTIONS = [
|
||||
'branding',
|
||||
'paths-storage',
|
||||
'execution',
|
||||
'auth',
|
||||
'push-notifications',
|
||||
// LLM group
|
||||
'llm-workers',
|
||||
|
||||
33
ui/src/lib/useBackdropClose.ts
Normal file
33
ui/src/lib/useBackdropClose.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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<string | null>(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}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user