Files
SnowLuma/packages/webui/src/App.tsx
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

520 lines
19 KiB
TypeScript

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 (
<ActionFeedbackProvider>
<ThemeProvider>
<InsecureRemoteAccessBanner />
<ActionFeedbackViewport />
<AdaptivePointer />
<GlobalContextMenu />
<AuthBoundary onboardingSteps={onboardingSteps} />
</ThemeProvider>
</ActionFeedbackProvider>
);
}
// #194: read a one-shot `?token=<password>` 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<AgreementsPayload | null>(null);
const [agreementsError, setAgreementsError] = useState<string | null>(null);
const [needsConsent, setNeedsConsent] = useState(false);
const [onboardingFinished, setOnboardingFinished] = useState(false);
const [replayingOnboarding, setReplayingOnboarding] = useState(
() => typeof window !== 'undefined' && isDeveloperOnboardingLocation(window.location),
);
const onboardingReturnPath = useRef<string | null>(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<string | undefined>(undefined);
const [urlLoginPassword, setUrlLoginPassword] = useState<string | undefined>(undefined);
const [urlNeedsTotp, setUrlNeedsTotp] = useState(false);
const authLoading = !authChecked || (authed && agreements === null && agreementsError === null);
const { showSkeleton: showAuthSkeleton } = useSkeletonSwap({ ready: !authLoading });
const client = useMemo<ApiClient>(
() =>
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=<password>` 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<string, unknown>)
: {};
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 = <Splash>{authChecked ? '加载中…' : '初始化中…'}</Splash>;
} else if (!authed) {
view = (
<LoginGate
initialPassword={urlLoginPassword}
initialNeedsTotp={urlNeedsTotp}
onAuthed={(needsChange, password) => {
setAuthed(true);
setStatus('已连接');
setMustChange(needsChange);
setLoginPassword(password);
setUrlLoginPassword(undefined);
setUrlNeedsTotp(false);
setOnboardingFinished(false);
void refreshAgreements();
}}
/>
);
} else if (agreementsError) {
view = (
<AgreementLoadFailure
detail={agreementsError}
onRetry={() => {
void refreshAgreements();
}}
onLogout={() => {
void handleDecline();
}}
/>
);
} else if (
agreements &&
(replayingOnboarding ||
(!onboardingFinished && (needsConsent || mustChange || onboardingSteps.length > 0)))
) {
view = (
<OnboardingGate
payload={agreements}
needsConsent={replayingOnboarding || needsConsent}
mustChangePassword={replayingOnboarding || mustChange}
replay={replayingOnboarding}
additionalSteps={onboardingSteps}
knownOldPassword={loginPassword}
onConsentComplete={() => setNeedsConsent(false)}
onPasswordComplete={() => {
setMustChange(false);
setLoginPassword(undefined);
}}
onComplete={() => {
if (replayingOnboarding) {
exitOnboardingReplay();
return;
}
setOnboardingFinished(true);
}}
onStale={refreshAgreements}
onDecline={replayingOnboarding ? exitOnboardingReplay : handleDecline}
/>
);
} else {
view = (
<SessionProvider
value={{
status,
onLogoutComplete: handleLoggedOut,
restartOnboarding,
}}
>
<RouterProvider router={appRouter} />
</SessionProvider>
);
}
return (
<ApiProvider client={client}>
<DebugTaskProvider>
<TooltipProvider delayDuration={finePointer ? 150 : Infinity}>
{view}
</TooltipProvider>
<TaskBadge />
</DebugTaskProvider>
</ApiProvider>
);
}
function AgreementLoadFailure({
detail,
onRetry,
onLogout,
}: {
detail: string;
onRetry: () => void;
onLogout: () => void;
}) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<Card className="w-full max-w-lg border-destructive/30">
<CardContent className="p-7 sm:p-9">
<div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div className="min-w-0">
<h1 className="text-base font-semibold">协议状态加载失败</h1>
<p
role="alert"
className="mt-1.5 text-sm leading-relaxed text-muted-foreground"
>
无法确认当前用户协议与隐私政策状态。为避免绕过必要确认,控制台暂不放行。
</p>
<p className="mt-2 break-words text-xs text-destructive">{detail}</p>
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onLogout}>
<LogOut className="size-4" />
退出登录
</Button>
<Button type="button" onClick={onRetry}>
<RefreshCw className="size-4" />
重新加载
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function Splash({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background text-sm text-muted-foreground">
<SkeletonSwap
ready={false}
lines={3}
lineHeight={24}
reserve={72}
label={typeof children === 'string' ? children : 'WebUI'}
className="w-64 max-w-[70vw]"
>
{null}
</SkeletonSwap>
</div>
);
}
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 (
<LoginPage
onLogin={handleLogin}
initialPassword={initialPassword}
initialNeedsTotp={initialNeedsTotp}
/>
);
}
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 (
<OnboardingWizardPage
documents={payload.documents}
agreementVersion={payload.version}
needsConsent={needsConsent}
mustChangePassword={mustChangePassword}
knownOldPassword={knownOldPassword}
passwordMode={execution.passwordMode}
onDecline={onDecline}
onConsentComplete={replay ? () => undefined : onConsentComplete}
onPasswordComplete={replay ? () => undefined : onPasswordComplete}
onComplete={onComplete}
additionalSteps={additionalSteps}
checkStrength={(password) => api.checkPasswordStrength(password)}
submitPassword={execution.actions.changePassword}
onAccept={execution.actions.acceptAgreements}
/>
);
}