483 lines
18 KiB
TypeScript
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;
|