Files
makelore/src/pages/Login/index.tsx
T
2026-10-11 12:28:02 +08:00

483 lines
18 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
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';
import { codingRecoveryAccount, readCodingLoginRecovery } from '@/lib/coding-login-recovery';
const LOGIN_SERVICE_ERROR_MESSAGE = '登录服务暂时不可用,请稍后重试。';
const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。';
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
const PHONE_PATTERN = /^1\d{10}$/;
const DEFAULT_LOGIN_RETURN_PATH = '/module-select';
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;
};
type RememberedPasswordResponse = {
success?: unknown;
available?: unknown;
credentials?: {
username?: unknown;
password?: unknown;
} | null;
};
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();
if (!message) return null;
const normalized = message.toLowerCase();
if (
message.length > 180
|| /<!doctype\b|<html\b|<head\b|<body\b/i.test(message)
|| /\b50[234]\b/.test(normalized)
|| normalized.includes('bad gateway')
|| normalized.includes('service unavailable')
) {
return LOGIN_SERVICE_ERROR_MESSAGE;
}
if (normalized.includes('timeout') || normalized.includes('timed out') || message.includes('超时')) {
return '登录超时,请稍后重试。';
}
return /[\u3400-\u9fff]/.test(message) ? message : LOGIN_ERROR_MESSAGE;
}
function getSafeLoginReturnPath(state: unknown): string {
if (!state || typeof state !== 'object') return DEFAULT_LOGIN_RETURN_PATH;
const value = Reflect.get(state, 'from');
return typeof value === 'string'
&& value.startsWith('/')
&& value[1] !== '/'
&& value[1] !== '\\'
? value
: DEFAULT_LOGIN_RETURN_PATH;
}
export function Login() {
const navigate = useNavigate();
const location = useLocation();
const returnPath = getSafeLoginReturnPath(location.state);
const reauthenticate = location.state?.reauthenticate === true;
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 [rememberPassword, setRememberPassword] = useState(false);
const [rememberPasswordAvailable, setRememberPasswordAvailable] = useState(false);
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 (async () => {
try {
const response = await hostApiFetch<PublicConfigResponse>(
'/api/auth/public-config',
{ cache: 'no-store' },
);
if (active) setLinks(projectPublicLinks(response));
} catch {
if (active) setLinks(EMPTY_LINKS);
}
try {
const response = await hostApiFetch<RememberedPasswordResponse>(
'/api/auth/remembered-password',
{ cache: 'no-store' },
);
if (!active) return;
const available = response.success === true && response.available === true;
setRememberPasswordAvailable(available);
if (
available
&& response.credentials
&& typeof response.credentials.username === 'string'
&& response.credentials.username.trim()
&& typeof response.credentials.password === 'string'
&& response.credentials.password
) {
setUsername(response.credentials.username.trim());
setPassword(response.credentials.password);
setRememberPassword(true);
}
} catch {
if (active) setRememberPasswordAvailable(false);
}
})();
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 && !reauthenticate) {
navigate(returnPath, { replace: true });
}
}, [isAuthenticated, navigate, returnPath, submitting, reauthenticate]);
const finishLogin = async (login: () => Promise<void>) => {
setSubmitError(null);
setSubmitting(true);
try {
await login();
const accessToken = useAuthStore.getState().accessToken;
if (!accessToken) throw new Error('Login did not return an access token');
try {
await importUserModelConfig(accessToken);
} catch (syncError) {
await logout();
throw syncError;
}
const recovery = readCodingLoginRecovery(location.state?.codingRecovery);
const sameAccount = recovery?.account === codingRecoveryAccount(useAuthStore.getState().user);
navigate(returnPath, { replace: true, state: sameAccount ? { codingRecovery: recovery } : undefined });
} catch (loginError) {
setSubmitError(loginError instanceof Error ? loginError.message : String(loginError));
} finally {
setSubmitting(false);
}
};
const handlePasswordSubmit = (event: FormEvent) => {
event.preventDefault();
if (!agreed || !username.trim() || !password) return;
void finishLogin(() => loginWithPassword({
username: username.trim(),
password,
rememberPassword,
}));
};
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]">
<CardHeader className="space-y-5 pb-4">
<div className="flex items-center gap-3">
<img src={logoSvg} alt="Makelore" className="h-9 w-12 shrink-0 object-contain" />
<div className="min-w-0">
<CardTitle className="text-xl tracking-normal">Makelore</CardTitle>
<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">
{displayError && (
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{displayError}
</div>
)}
{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>
<label
className="flex w-fit items-center gap-2 text-sm text-muted-foreground"
title={rememberPasswordAvailable ? undefined : '当前环境无法使用系统安全存储'}
>
<input
type="checkbox"
checked={rememberPassword}
disabled={!rememberPasswordAvailable}
onChange={(event) => setRememberPassword(event.target.checked)}
/>
<span>记住密码</span>
</label>
</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>
)}
<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>
);
}
export default Login;