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
520 lines
19 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|