Files
NianToB/src/pages/YinianLogin/index.tsx

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;