Files
makelore/src/pages/Login/index.tsx

465 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import { 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';
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;
};
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;
}
export function Login() {
const navigate = useNavigate();
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) {
navigate('/module-select', { replace: true });
}
}, [isAuthenticated, navigate, submitting]);
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;
}
navigate('/module-select', { replace: true });
} 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>
<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>
<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>
)}
<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;