merge: integrate native login for diagnostic package

This commit is contained in:
2026-08-19 12:01:42 +08:00
9 changed files with 1539 additions and 859 deletions

View File

@@ -1,14 +1,74 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { ExternalLink, Loader2, LogIn } from 'lucide-react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { hostApiFetch } from '@/lib/host-api';
import { useAuthStore } from '@/stores/auth';
import { useProviderStore } from '@/stores/providers';
import logoSvg from '@/assets/logo.svg';
const LOGIN_SERVICE_ERROR_MESSAGE = '登录服务暂时不可用,请稍后重试。';
const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。';
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
const PHONE_PATTERN = /^1\d{10}$/;
type LoginMode = 'password' | 'mobile';
type PublicLinks = {
termsUrl: string | null;
privacyUrl: string | null;
forgotPasswordUrl: string | null;
};
type PublicConfigResponse = {
success?: unknown;
links?: unknown;
};
type CaptchaResponse = {
success?: unknown;
image?: {
mimeType?: unknown;
dataBase64?: unknown;
};
};
type SmsResponse = {
success?: unknown;
error?: unknown;
};
const EMPTY_LINKS: PublicLinks = {
termsUrl: null,
privacyUrl: null,
forgotPasswordUrl: null,
};
function getSafeExternalUrl(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null;
try {
const url = new URL(value);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
} catch {
return null;
}
}
function projectPublicLinks(response: PublicConfigResponse): PublicLinks {
if (response.success !== true || !response.links || typeof response.links !== 'object') {
return EMPTY_LINKS;
}
const links = response.links as Record<string, unknown>;
return {
termsUrl: getSafeExternalUrl(links.termsUrl),
privacyUrl: getSafeExternalUrl(links.privacyUrl),
forgotPasswordUrl: getSafeExternalUrl(links.forgotPasswordUrl),
};
}
function getLoginErrorMessage(error: string | null): string | null {
const message = error?.trim();
@@ -34,31 +94,124 @@ function getLoginErrorMessage(error: string | null): string | null {
export function Login() {
const navigate = useNavigate();
const loginWithBrowser = useAuthStore((state) => state.loginWithBrowser);
const loginWithPassword = useAuthStore((state) => state.loginWithPassword);
const loginWithMobile = useAuthStore((state) => state.loginWithMobile);
const loading = useAuthStore((state) => state.loading);
const error = useAuthStore((state) => state.error);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const logout = useAuthStore((state) => state.logout);
const importUserModelConfig = useProviderStore((state) => state.importUserModelConfig);
const [mode, setMode] = useState<LoginMode>('password');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [imageCode, setImageCode] = useState('');
const [smsCode, setSmsCode] = useState('');
const [agreed, setAgreed] = useState(false);
const [links, setLinks] = useState<PublicLinks>(EMPTY_LINKS);
const [captchaRandomStr, setCaptchaRandomStr] = useState<string | null>(null);
const [captchaImage, setCaptchaImage] = useState<string | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [cooldownActive, setCooldownActive] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const captchaRequestRef = useRef(0);
const captchaStartedRef = useRef(false);
const displayError = getLoginErrorMessage(submitError || error);
useEffect(() => {
let active = true;
void hostApiFetch<PublicConfigResponse>('/api/auth/public-config', { cache: 'no-store' })
.then((response) => {
if (active) setLinks(projectPublicLinks(response));
})
.catch(() => {
if (active) setLinks(EMPTY_LINKS);
});
return () => {
active = false;
};
}, []);
const loadCaptcha = useCallback(async () => {
captchaStartedRef.current = true;
const requestId = ++captchaRequestRef.current;
const randomStr = crypto.randomUUID();
setCaptchaLoading(true);
setCaptchaRandomStr(null);
setCaptchaImage(null);
setImageCode('');
setSubmitError(null);
try {
const response = await hostApiFetch<CaptchaResponse>(
`/api/auth/mobile-image-code?randomStr=${encodeURIComponent(randomStr)}`,
{ cache: 'no-store' },
);
if (requestId !== captchaRequestRef.current) return;
const mimeType = response.image?.mimeType;
const dataBase64 = response.image?.dataBase64;
if (
response.success !== true
|| typeof mimeType !== 'string'
|| !/^image\/[a-z0-9.+-]+$/i.test(mimeType)
|| typeof dataBase64 !== 'string'
|| !dataBase64
) {
throw new Error(CAPTCHA_ERROR_MESSAGE);
}
setCaptchaRandomStr(randomStr);
setCaptchaImage(`data:${mimeType};base64,${dataBase64}`);
} catch {
if (requestId === captchaRequestRef.current) {
setSubmitError(CAPTCHA_ERROR_MESSAGE);
}
} finally {
if (requestId === captchaRequestRef.current) setCaptchaLoading(false);
}
}, []);
useEffect(() => {
if (mode !== 'mobile' || cooldownActive || captchaImage || captchaLoading || captchaStartedRef.current) return;
void loadCaptcha();
}, [captchaImage, captchaLoading, cooldownActive, loadCaptcha, mode]);
useEffect(() => {
if (!cooldownActive) return;
const startedAt = Date.now();
const ticker = window.setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
setCooldown(Math.max(0, 60 - elapsedSeconds));
}, 1_000);
const expiry = window.setTimeout(() => {
setCooldownActive(false);
setCooldown(0);
void loadCaptcha();
}, 60_000);
return () => {
window.clearInterval(ticker);
window.clearTimeout(expiry);
};
}, [cooldownActive, loadCaptcha]);
useEffect(() => () => {
captchaRequestRef.current += 1;
}, []);
useEffect(() => {
if (isAuthenticated() && !submitting) {
navigate('/module-select', { replace: true });
}
}, [isAuthenticated, navigate, submitting]);
const handleBrowserLogin = async () => {
const finishLogin = async (login: () => Promise<void>) => {
setSubmitError(null);
setSubmitting(true);
try {
await loginWithBrowser();
await login();
const accessToken = useAuthStore.getState().accessToken;
if (!accessToken) {
throw new Error('Makelore sign-in did not return an access token');
}
if (!accessToken) throw new Error('Login did not return an access token');
try {
await importUserModelConfig(accessToken);
} catch (syncError) {
@@ -73,6 +226,75 @@ export function Login() {
}
};
const handlePasswordSubmit = (event: FormEvent) => {
event.preventDefault();
if (!agreed || !username.trim() || !password) return;
void finishLogin(() => loginWithPassword({ username: username.trim(), password }));
};
const handleMobileSubmit = (event: FormEvent) => {
event.preventDefault();
if (!agreed || !PHONE_PATTERN.test(phone) || !smsCode.trim()) return;
void finishLogin(() => loginWithMobile({ phone, code: smsCode.trim() }));
};
const handleModeChange = (nextMode: LoginMode) => {
if (nextMode === mode) return;
setMode(nextMode);
if (nextMode === 'mobile' && !cooldownActive) {
void loadCaptcha();
}
};
const handleSendCode = async () => {
if (
sendingCode
|| cooldownActive
|| !PHONE_PATTERN.test(phone)
|| !captchaRandomStr
|| !captchaImage
|| !imageCode.trim()
) return;
const challenge = captchaRandomStr;
setSendingCode(true);
setSubmitError(null);
try {
const response = await hostApiFetch<SmsResponse>('/api/auth/mobile-code', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
phone,
imageRandomStr: challenge,
imageCode: imageCode.trim(),
}),
});
if (response.success !== true) {
throw new Error(SMS_ERROR_MESSAGE);
}
captchaRequestRef.current += 1;
setCaptchaRandomStr(null);
setCaptchaImage(null);
setImageCode('');
setCooldown(60);
setCooldownActive(true);
} catch (sendError) {
const message = sendError instanceof Error ? sendError.message : SMS_ERROR_MESSAGE;
await loadCaptcha();
setSubmitError(message);
} finally {
setSendingCode(false);
}
};
const busy = loading || submitting;
const passwordReady = agreed && Boolean(username.trim()) && Boolean(password) && !busy;
const mobileReady = agreed && PHONE_PATTERN.test(phone) && Boolean(smsCode.trim()) && !busy;
const sendReady = PHONE_PATTERN.test(phone)
&& Boolean(captchaRandomStr && captchaImage && imageCode.trim())
&& !sendingCode
&& !cooldownActive;
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<Card className="w-full max-w-[420px]">
@@ -84,31 +306,100 @@ export function Login() {
<p className="text-sm text-muted-foreground"> Makelore </p>
</div>
</div>
<div role="tablist" aria-label="登录方式" className="grid grid-cols-2 rounded-md bg-muted p-1">
<button
type="button"
role="tab"
aria-selected={mode === 'password'}
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
onClick={() => handleModeChange('password')}
>
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'mobile'}
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
onClick={() => handleModeChange('mobile')}
>
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">
访
</p>
{displayError && (
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{displayError}
</div>
)}
<Button type="button" className="w-full" disabled={loading || submitting} onClick={handleBrowserLogin}>
{loading || submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
</Button>
{mode === 'password' ? (
<form className="space-y-4" onSubmit={handlePasswordSubmit}>
<div className="space-y-2">
<Label htmlFor="login-username"></Label>
<Input id="login-username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="login-password"></Label>
{links.forgotPasswordUrl && (
<a className="text-xs text-primary hover:underline" href={links.forgotPasswordUrl} target="_blank" rel="noopener noreferrer"></a>
)}
</div>
<Input id="login-password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} />
</div>
<Button type="submit" className="w-full" disabled={!passwordReady}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
) : (
<form className="space-y-4" onSubmit={handleMobileSubmit}>
<div className="space-y-2">
<Label htmlFor="login-phone"></Label>
<Input id="login-phone" type="tel" inputMode="numeric" autoComplete="tel" maxLength={11} value={phone} onChange={(event) => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} />
</div>
<div className="space-y-2">
<Label htmlFor="login-image-code"></Label>
<div className="flex gap-2">
<Input id="login-image-code" autoComplete="off" value={imageCode} onChange={(event) => setImageCode(event.target.value)} />
<button
type="button"
aria-label="刷新图形验证码"
className="flex h-10 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted"
disabled={cooldownActive}
onClick={() => void loadCaptcha()}
>
{captchaLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : captchaImage ? <img src={captchaImage} alt="图形验证码" className="h-full w-full object-contain" /> : <RefreshCw className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="login-sms-code"></Label>
<div className="flex gap-2">
<Input id="login-sms-code" inputMode="numeric" autoComplete="one-time-code" value={smsCode} onChange={(event) => setSmsCode(event.target.value)} />
<Button type="button" variant="outline" className="w-28 shrink-0" disabled={!sendReady} onClick={() => void handleSendCode()}>
{sendingCode ? '发送中…' : cooldownActive ? `${cooldown}` : '获取验证码'}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={!mobileReady}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</form>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LogIn className="h-3.5 w-3.5" />
</div>
<label className="flex items-start gap-2 text-xs leading-5 text-muted-foreground">
<input type="checkbox" className="mt-1" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
<span>
{links.termsUrl ? <a className="mx-1 text-primary hover:underline" href={links.termsUrl} target="_blank" rel="noopener noreferrer"></a> : '用户协议'}
{links.privacyUrl ? <a className="ml-1 text-primary hover:underline" href={links.privacyUrl} target="_blank" rel="noopener noreferrer"></a> : '隐私政策'}
</span>
</label>
</CardContent>
</Card>
</main>

View File

@@ -1,4 +1,4 @@
import { create } from 'zustand';
import { create, type StoreApi } from 'zustand';
import { persist } from 'zustand/middleware';
import { hostApiFetch } from '@/lib/host-api';
import {
@@ -64,6 +64,16 @@ type RefreshSessionOptions = {
forceRefresh?: boolean;
};
type PasswordLoginInput = {
username: string;
password: string;
};
type MobileLoginInput = {
phone: string;
code: string;
};
type AuthState = {
initialized: boolean;
loading: boolean;
@@ -80,7 +90,8 @@ type AuthState = {
user: AuthUser | null;
moduleAccess: ModuleAccess;
init: () => Promise<void>;
loginWithBrowser: () => Promise<void>;
loginWithPassword: (input: PasswordLoginInput) => Promise<void>;
loginWithMobile: (input: MobileLoginInput) => Promise<void>;
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
getValidAccessToken: () => Promise<string | null>;
markActivity: () => Promise<void>;
@@ -242,6 +253,50 @@ async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAc
}
}
async function loginViaHost(
path: '/api/auth/login' | '/api/auth/mobile-login',
input: PasswordLoginInput | MobileLoginInput,
set: StoreApi<AuthState>['setState'],
): Promise<void> {
advanceAuthSessionEpoch();
const operationEpoch = authSessionEpoch;
set({ loading: true, error: null });
try {
const response = await hostApiFetch<AuthTokenResponse>(path, {
method: 'POST',
body: JSON.stringify(input),
});
const session = parseMainSession(response.session);
if (!response.success || !response.token || !session) {
throw new Error(response.error || 'Login failed');
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({
initialized: true,
loading: false,
error: null,
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const terminal = isTerminalAuthError(error);
const message = terminal
? '登录已过期,请重新授权。'
: (error instanceof Error ? error.message : String(error));
advanceAuthSessionEpoch();
set({ loading: false, error: message, ...getClearedSession() });
throw new Error(message, { cause: error });
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -371,44 +426,9 @@ export const useAuthStore = create<AuthState>()(
set({ initialized: true, loading: false, error: null, moduleAccess });
},
loginWithBrowser: async () => {
advanceAuthSessionEpoch();
const operationEpoch = authSessionEpoch;
set({ loading: true, error: null });
try {
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/browser/start', {
method: 'POST',
});
const session = parseMainSession(response.session);
if (!response.success || !response.token || !session) {
throw new Error(response.error || 'Browser authorization failed');
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
loginWithPassword: (input) => loginViaHost('/api/auth/login', input, set),
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({
initialized: true,
loading: false,
error: null,
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const terminal = isTerminalAuthError(error);
const message = terminal
? '登录已过期,请重新授权。'
: (error instanceof Error ? error.message : String(error));
advanceAuthSessionEpoch();
set({ loading: false, error: message, ...getClearedSession() });
throw new Error(message, { cause: error });
}
},
loginWithMobile: (input) => loginViaHost('/api/auth/mobile-login', input, set),
refreshSession: async (options = {}) => {
const state = get();