import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import { AlertTriangle, LogOut, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { TooltipProvider } from '@/components/ui/tooltip'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { SessionProvider } from '@/contexts/SessionContext'; import { LoginPage } from '@/components/pages/login-page'; import { OnboardingWizardPage, type AdditionalOnboardingStep, } from '@/components/pages/onboarding-wizard-page'; import { SkeletonSwap, useSkeletonSwap } from '@/components/interior/skeleton-swap'; import { ApiProvider, createApiClient, useApi, type ApiClient } from '@/lib/api'; import { DebugTaskProvider } from '@/contexts/DebugTaskContext'; import { TaskBadge } from '@/components/debug/task-badge'; import { AdaptivePointer } from '@/components/ui/adaptive-pointer'; import { useFinePointer } from '@/hooks/use-media-query'; import { GlobalContextMenu } from '@/components/ui/global-context-menu'; import { InsecureRemoteAccessBanner } from '@/components/insecure-remote-access-banner'; import { actionErrorMessage, ActionFeedbackProvider, ActionFeedbackViewport, useActionFeedback, } from '@/contexts/ActionFeedbackContext'; import type { AgreementsPayload } from '@/lib/api/types'; import { appRouter } from '@/router'; import { browserPath, DEVELOPER_ONBOARDING_RETURN_KEY, DEVELOPER_ONBOARDING_URL, DEVELOPER_SETTINGS_URL, developerOnboardingReturnPath, isDeveloperOnboardingLocation, } from '@/lib/onboarding-navigation'; import { onboardingExecution } from '@/lib/onboarding-actions'; interface AppProps { onboardingSteps?: AdditionalOnboardingStep[]; } export default function App({ onboardingSteps = [] }: AppProps) { return ( ); } // #194: read a one-shot `?token=` login param and immediately strip // it from the URL (replaceState) so the credential doesn't linger in the // address bar / browser history / Referer header. Named `token` to match the // requested query key; the value is the WebUI login password. function consumeUrlToken(): string | null { if (typeof window === 'undefined') { return null; } try { const url = new URL(window.location.href); const value = url.searchParams.get('token'); if (!value) { return null; } url.searchParams.delete('token'); window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`); return value; } catch { return null; } } function AuthBoundary({ onboardingSteps }: { onboardingSteps: AdditionalOnboardingStep[] }) { const finePointer = useFinePointer(); const [authChecked, setAuthChecked] = useState(false); const [authed, setAuthed] = useState(false); const [mustChange, setMustChange] = useState(false); const [status, setStatus] = useState('未连接'); // Agreement consent gate, shown after login but BEFORE the forced password // change. `agreements === null` while the post-auth fetch is in flight. const [agreements, setAgreements] = useState(null); const [agreementsError, setAgreementsError] = useState(null); const [needsConsent, setNeedsConsent] = useState(false); const [onboardingFinished, setOnboardingFinished] = useState(false); const [replayingOnboarding, setReplayingOnboarding] = useState( () => typeof window !== 'undefined' && isDeveloperOnboardingLocation(window.location), ); const onboardingReturnPath = useRef(null); // The password from *this* session's login, carried into the forced // change-password gate so it doesn't have to render an old-password field // (which browsers autofill, misleading users on upgrade). Stays undefined // for a returning session that's already authed but still must change. const [loginPassword, setLoginPassword] = useState(undefined); const [urlLoginPassword, setUrlLoginPassword] = useState(undefined); const [urlNeedsTotp, setUrlNeedsTotp] = useState(false); const authLoading = !authChecked || (authed && agreements === null && agreementsError === null); const { showSkeleton: showAuthSkeleton } = useSkeletonSwap({ ready: !authLoading }); const client = useMemo( () => createApiClient({ onUnauthorized: () => { setAuthed(false); setStatus('未授权'); }, }), [], ); const refreshAgreements = useCallback(async () => { setAgreements(null); setAgreementsError(null); try { const payload = await client.agreements.get(); setAgreements(payload); setNeedsConsent(payload.consentRequired); } catch (error) { console.error('agreement status load failed', error); setAgreementsError(actionErrorMessage(error)); } }, [client]); useEffect(() => { (async () => { let ok = await client.status(); // #194: `?token=` in the URL logs in without typing the // password. Only attempted when no stored token already authed us; the // value is the WebUI login password (verified by /api/login server-side, // so a wrong one just falls through to the login page). The param is // consumed once and stripped from the URL first — a password in the // address bar leaks via browser history / access logs / the Referer. let urlPassword: string | undefined; if (!ok) { const pw = consumeUrlToken(); if (pw) { const result = await client.login(pw); if (result.ok) { ok = true; urlPassword = pw; } else if ('needsTotp' in result && result.needsTotp) { setUrlLoginPassword(pw); setUrlNeedsTotp(true); } } } if (ok) { setAuthed(true); setStatus('已连接'); const [nextMustChange] = await Promise.all([ client.mustChangePassword(), refreshAgreements(), ]); setMustChange(nextMustChange); // Carry the URL password into the forced change-password gate so it // needn't re-prompt for the old password (matches the login flow). if (urlPassword) { setLoginPassword(urlPassword); } } setAuthChecked(true); })(); }, [client, refreshAgreements]); const handleLoggedOut = useCallback(() => { // Reset the URL so the next login lands on the overview page, matching // the pre-router behaviour, and clear every post-auth gate. window.history.replaceState({}, '', '/'); setAuthed(false); setStatus('未连接'); setMustChange(false); setAgreements(null); setAgreementsError(null); setNeedsConsent(false); setOnboardingFinished(false); setReplayingOnboarding(false); onboardingReturnPath.current = null; setLoginPassword(undefined); setUrlLoginPassword(undefined); setUrlNeedsTotp(false); }, []); const handleDecline = useCallback(async () => { await client.logout(); handleLoggedOut(); }, [client, handleLoggedOut]); const restartOnboarding = useCallback(() => { const currentPath = browserPath(window.location); const returnPath = isDeveloperOnboardingLocation(window.location) ? DEVELOPER_SETTINGS_URL : currentPath; onboardingReturnPath.current = returnPath; const currentState = typeof window.history.state === 'object' && window.history.state !== null ? (window.history.state as Record) : {}; setReplayingOnboarding(true); appRouter.history.push(DEVELOPER_ONBOARDING_URL, { ...currentState, [DEVELOPER_ONBOARDING_RETURN_KEY]: returnPath, }); }, []); const exitOnboardingReplay = useCallback(() => { const returnPath = onboardingReturnPath.current ?? developerOnboardingReturnPath(window.history.state); onboardingReturnPath.current = null; if (returnPath && isDeveloperOnboardingLocation(window.location)) { // The developer page pushed the replay entry, so browser Back is the // authoritative way to restore both the URL and its router history. appRouter.history.back(); return; } appRouter.history.replace(returnPath ?? DEVELOPER_SETTINGS_URL); setReplayingOnboarding(false); }, []); useEffect(() => { const syncReplayToLocation = () => { const replaying = isDeveloperOnboardingLocation(appRouter.history.location); if (!replaying) { onboardingReturnPath.current = null; } setReplayingOnboarding(replaying); }; return appRouter.history.subscribe(syncReplayToLocation); }, []); let view: React.ReactNode; if (authLoading || showAuthSkeleton) { view = {authChecked ? '加载中…' : '初始化中…'}; } else if (!authed) { view = ( { setAuthed(true); setStatus('已连接'); setMustChange(needsChange); setLoginPassword(password); setUrlLoginPassword(undefined); setUrlNeedsTotp(false); setOnboardingFinished(false); void refreshAgreements(); }} /> ); } else if (agreementsError) { view = ( { void refreshAgreements(); }} onLogout={() => { void handleDecline(); }} /> ); } else if ( agreements && (replayingOnboarding || (!onboardingFinished && (needsConsent || mustChange || onboardingSteps.length > 0))) ) { view = ( setNeedsConsent(false)} onPasswordComplete={() => { setMustChange(false); setLoginPassword(undefined); }} onComplete={() => { if (replayingOnboarding) { exitOnboardingReplay(); return; } setOnboardingFinished(true); }} onStale={refreshAgreements} onDecline={replayingOnboarding ? exitOnboardingReplay : handleDecline} /> ); } else { view = ( ); } return ( {view} ); } function AgreementLoadFailure({ detail, onRetry, onLogout, }: { detail: string; onRetry: () => void; onLogout: () => void; }) { return (

协议状态加载失败

无法确认当前用户协议与隐私政策状态。为避免绕过必要确认,控制台暂不放行。

{detail}

); } function Splash({ children }: { children: React.ReactNode }) { return (
{null}
); } function LoginGate({ onAuthed, initialPassword, initialNeedsTotp, }: { onAuthed: (mustChange: boolean, password: string) => void; initialPassword?: string; initialNeedsTotp?: boolean; }) { const api = useApi(); const { startAction, dismiss } = useActionFeedback(); const handleLogin = useCallback( async (password: string, secondFactor?: { totp?: string; recoveryCode?: string }) => { const handle = startAction({ title: '正在登录', detail: secondFactor ? '正在验证一次性密码' : '正在验证 WebUI 访问密码', }); const result = await api.login(password, secondFactor); if (result.ok) { handle.succeed({ title: '登录成功', detail: '正在进入控制台' }); onAuthed(result.mustChangePassword, password); return { success: true }; } if ('message' in result) { handle.fail(result.message, { title: '登录失败' }); return { success: false, error: result.message }; } dismiss(); return { success: false, needsTotp: true }; }, [api, dismiss, onAuthed, startAction], ); return ( ); } function OnboardingGate({ payload, needsConsent, mustChangePassword, replay, knownOldPassword, onConsentComplete, onPasswordComplete, onComplete, additionalSteps, onStale, onDecline, }: { payload: AgreementsPayload; needsConsent: boolean; mustChangePassword: boolean; replay: boolean; knownOldPassword?: string; onConsentComplete: () => void; onPasswordComplete: () => void; onComplete: () => void; additionalSteps: AdditionalOnboardingStep[]; onStale: () => void; onDecline: () => void; }) { const api = useApi(); const { runAction } = useActionFeedback(); const execution = onboardingExecution(replay, { changePassword: (oldPassword, newPassword) => api.changePassword(oldPassword, newPassword), acceptAgreements: async () => { const result = await runAction( { title: '正在记录协议确认', detail: `协议版本 ${payload.version.slice(0, 8)}`, successTitle: '协议确认已记录', errorTitle: '协议确认失败', resultError: (consent) => (consent.success ? null : consent.message), }, () => api.agreements.recordConsent(payload.version), ); if (result.success) { return { success: true }; } if (result.currentVersion && result.currentVersion !== payload.version) { onStale(); return { success: false, message: '协议已更新,已为你载入最新版本,请重新阅读后确认。', }; } return { success: false, message: result.message ?? '提交失败,请重试' }; }, }); return ( undefined : onConsentComplete} onPasswordComplete={replay ? () => undefined : onPasswordComplete} onComplete={onComplete} additionalSteps={additionalSteps} checkStrength={(password) => api.checkPasswordStrength(password)} submitPassword={execution.actions.changePassword} onAccept={execution.actions.acceptAgreements} /> ); }