227 lines
8.8 KiB
TypeScript
227 lines
8.8 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
|
import { Loader2, LockKeyhole, RefreshCw } from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useYinianStore } from '@/stores/yinian';
|
|
import { YinianPanel, yinianPrimaryButton } from '@/components/yinian/ui';
|
|
import logoSvg from '@/assets/logo.svg';
|
|
import type { YinianImageCaptcha } from '../../../shared/yinian';
|
|
import type { YinianServerStatus } from '../../../shared/yinian';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
export function YinianLogin() {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('setup');
|
|
const status = useYinianStore((state) => state.status);
|
|
const error = useYinianStore((state) => state.error);
|
|
const loginWithPassword = useYinianStore((state) => state.loginWithPassword);
|
|
|
|
const [account, setAccount] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [rememberPassword, setRememberPassword] = useState(false);
|
|
const [captchaCode, setCaptchaCode] = useState('');
|
|
const [captcha, setCaptcha] = useState<YinianImageCaptcha | null>(null);
|
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
|
const [serverStatus, setServerStatus] = useState<YinianServerStatus | null>(null);
|
|
const isLoading = status === 'loading';
|
|
const serverConfigured = Boolean(serverStatus?.apiBaseUrl) || serverStatus?.mode === 'mock';
|
|
const captchaSrc = useMemo(() => {
|
|
if (!captcha) return '';
|
|
if (captcha.image?.startsWith('data:')) return captcha.image;
|
|
if (captcha.image) return captcha.image;
|
|
if (captcha.imageBase64) {
|
|
return `data:${captcha.mimeType || 'image/png'};base64,${captcha.imageBase64}`;
|
|
}
|
|
return '';
|
|
}, [captcha]);
|
|
const captchaRequired = Boolean(captchaSrc);
|
|
|
|
const refreshCaptcha = async () => {
|
|
setCaptchaLoading(true);
|
|
try {
|
|
const randomStr = crypto.randomUUID();
|
|
const next = await window.yinian.auth.createImageCaptcha(randomStr);
|
|
setCaptcha(next);
|
|
setCaptchaCode('');
|
|
} catch {
|
|
setCaptcha(null);
|
|
} finally {
|
|
setCaptchaLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void window.yinian.app.getServerStatus()
|
|
.then(setServerStatus)
|
|
.catch(() => setServerStatus(null));
|
|
void refreshCaptcha();
|
|
void window.yinian.auth.getSavedCredentials()
|
|
.then((credentials) => {
|
|
if (!credentials) return;
|
|
setAccount(credentials.account);
|
|
setPassword(credentials.password ?? '');
|
|
setRememberPassword(credentials.rememberPassword);
|
|
})
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
const handlePasswordLogin = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
await loginWithPassword({
|
|
account: account.trim(),
|
|
password,
|
|
captchaCode: captchaCode.trim(),
|
|
randomStr: captcha?.randomStr,
|
|
});
|
|
if (rememberPassword) {
|
|
void window.yinian.auth.saveCredentials({
|
|
account: account.trim(),
|
|
password,
|
|
rememberPassword: true,
|
|
}).catch(() => undefined);
|
|
} else {
|
|
void window.yinian.auth.clearSavedCredentials().catch(() => undefined);
|
|
}
|
|
navigate('/', { replace: true });
|
|
};
|
|
|
|
return (
|
|
<div data-testid="yinian-login-page" className="yinian-app-surface flex min-h-screen text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
|
<section className="yinian-visual-band hidden w-[44%] min-w-[460px] flex-col items-center justify-center border-r border-[#D5E8F3]/75 px-12 py-10 text-slate-950 lg:flex">
|
|
<div className="flex flex-col items-center gap-6 text-center">
|
|
<span className="flex h-24 w-24 items-center justify-center rounded-lg bg-white/70 ring-1 ring-[#D5E8F3]">
|
|
<img src={logoSvg} alt={t('login.appName')} className="h-20 w-20 rounded-3xl" />
|
|
</span>
|
|
<h1 className="text-3xl font-semibold tracking-normal">{t('login.slogan')}</h1>
|
|
</div>
|
|
</section>
|
|
|
|
<main className="flex flex-1 items-center justify-center px-6 py-10">
|
|
<YinianPanel className="w-full max-w-[430px]">
|
|
<div className="p-6 pb-0">
|
|
<div className="mb-3 flex items-center gap-2 lg:hidden">
|
|
<img src={logoSvg} alt={t('login.appName')} className="h-9 w-9 rounded-xl" />
|
|
<span className="text-base font-semibold">{t('login.appName')}</span>
|
|
</div>
|
|
<h1 className="text-2xl font-semibold tracking-normal">{t('login.title')}</h1>
|
|
<p className="mt-1.5 text-sm text-muted-foreground">{t('login.slogan')}</p>
|
|
{serverStatus && (
|
|
<div className={`mt-4 rounded-lg border px-3 py-2 text-xs ${
|
|
serverConfigured
|
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200'
|
|
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200'
|
|
}`}
|
|
>
|
|
{serverConfigured
|
|
? t('login.serverEnabled', { baseUrl: serverStatus.apiBaseUrl ? `: ${serverStatus.apiBaseUrl}` : '' })
|
|
: serverStatus.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-6">
|
|
<form className="space-y-4" onSubmit={handlePasswordLogin}>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="account">{t('login.account')}</Label>
|
|
<Input
|
|
id="account"
|
|
value={account}
|
|
onChange={(event) => setAccount(event.target.value)}
|
|
placeholder={t('login.accountPlaceholder')}
|
|
autoComplete="username"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t('login.password')}</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
placeholder={t('login.passwordPlaceholder')}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
<CaptchaField
|
|
value={captchaCode}
|
|
onChange={setCaptchaCode}
|
|
imageSrc={captchaSrc}
|
|
loading={captchaLoading}
|
|
onRefresh={refreshCaptcha}
|
|
label={t('login.captcha')}
|
|
placeholder={t('login.captchaPlaceholder')}
|
|
refreshTitle={t('login.refreshCaptcha')}
|
|
/>
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={rememberPassword}
|
|
onChange={(event) => setRememberPassword(event.target.checked)}
|
|
className="h-4 w-4 rounded border-slate-300"
|
|
/>
|
|
{t('login.rememberPassword')}
|
|
</label>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
<Button type="submit" className={`w-full ${yinianPrimaryButton}`} disabled={isLoading || !serverConfigured || (captchaRequired && !captchaCode.trim())}>
|
|
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LockKeyhole className="mr-2 h-4 w-4" />}
|
|
{t('login.submit')}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</YinianPanel>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CaptchaField({
|
|
value,
|
|
onChange,
|
|
imageSrc,
|
|
loading,
|
|
onRefresh,
|
|
label,
|
|
placeholder,
|
|
refreshTitle,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
imageSrc: string;
|
|
loading: boolean;
|
|
onRefresh: () => void | Promise<void>;
|
|
label: string;
|
|
placeholder: string;
|
|
refreshTitle: string;
|
|
}) {
|
|
if (!imageSrc) return null;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="captcha-code">{label}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="captcha-code"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder={placeholder}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void onRefresh()}
|
|
className="flex h-10 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border border-slate-200 bg-white text-xs text-muted-foreground transition-colors hover:bg-slate-50 dark:border-white/10 dark:bg-slate-900 dark:hover:bg-white/5"
|
|
title={refreshTitle}
|
|
>
|
|
{loading ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<img src={imageSrc} alt={label} className="h-full w-full object-contain" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default YinianLogin;
|