File size: 5,540 Bytes
9b403c4 501eb9e 9b403c4 501eb9e 9b403c4 501eb9e 9b403c4 501eb9e 9b403c4 501eb9e 9b403c4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ShieldCheck } from 'lucide-react';
import { NativeApi } from '@api/native';
import { Button } from '@components/ui/button';
import { Input } from '@components/ui/input';
import { Label } from '@components/ui/label';
import type { NativeAuthStatus } from '@typings/native';
interface Props {
status: NativeAuthStatus | null;
loading: boolean;
onRetry: () => Promise<void>;
onLogin: (email: string, password: string, signal: AbortSignal) => Promise<void>;
onError: (message: string) => void;
}
export function NativeAccountAccess({ status, loading, onRetry, onLogin, onError }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [recovering, setRecovering] = useState(false);
const [pending, setPending] = useState(false);
const [notice, setNotice] = useState('');
const request = useRef<AbortController>();
const embedded = window.self !== window.top;
useEffect(() => () => request.current?.abort(), []);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (embedded || request.current || !email.trim() || (!recovering && !password)) return;
const controller = new AbortController(); request.current = controller;
setPending(true); setNotice(''); onError('');
try {
if (recovering) {
// 匿名恢复同样先取预登录 CSRF,邮件提交不会绕过会话保护。
const next = await new NativeApi().authStatus(controller.signal);
const api = new NativeApi(next.csrf_token);
await api.ensureCsrf(controller.signal);
await api.recover(email.trim(), controller.signal);
if (!controller.signal.aborted) setNotice('如果此邮箱可以恢复登录,你会收到重置邮件。请在当前浏览器中打开邮件链接。');
} else await onLogin(email.trim(), password, controller.signal);
} catch (error) {
if (!controller.signal.aborted) onError(error instanceof Error ? error.message : '请求失败,请重试');
} finally {
// 密码只在提交期间留在组件内存,任何结果都清空输入,不保存到浏览器存储。
setPassword(''); request.current = undefined;
if (!controller.signal.aborted) setPending(false);
}
};
return <section className="max-w-xl rounded-lg border border-console-border bg-console-surface-base p-5" aria-labelledby="native-login-heading">
<ShieldCheck className="mb-3 h-8 w-8 text-console-status-info" />
<h2 id="native-login-heading" className="text-lg font-semibold">{loading ? '正在检查登录状态' : !status || !status.initialized || status.bootstrap.mode === 'locked' ? '安全初始化' : recovering ? '找回密码' : '管理员登录'}</h2>
{loading ? <p role="status" className="mt-3 text-sm text-console-text-secondary">正在连接认证服务…</p> : !status ? <div className="mt-3 space-y-4"><p className="text-sm text-console-text-secondary">暂时无法确认认证配置,请重试连接。</p><Button variant="outline" onClick={() => void onRetry()}>重新检查认证</Button></div> : !status.initialized || status.bootstrap.mode === 'locked' ? <div className="mt-3 space-y-4">
<p className="text-sm text-console-text-secondary">{status.bootstrap.reason || '认证服务尚未配置,管理功能已锁定。'}</p>
<p className="text-sm text-console-text-secondary">请由运营者配置 Supabase Auth,并在服务端明确授权管理员账号。此页面不提供公开注册或首次访问者创建管理员。</p>
<Button variant="outline" onClick={() => void onRetry()}>重新检查认证</Button>
</div> : <>
<p className="mb-5 mt-2 text-sm text-console-text-secondary">{recovering ? '输入管理员邮箱,我们会发送密码重置邮件。请使用此浏览器打开邮件中的链接。' : '使用已授权的 Supabase 管理员账号登录。会话通过 HttpOnly cookie 保存;前端不保存密码或管理员 token。'}</p>
{embedded && <p role="alert" className="mb-4 text-sm text-amber-700">嵌入页面无法可靠保存安全登录cookie,请先点击上方“在独立页面登录”,再登录或发送重置邮件。</p>}
<form onSubmit={submit} className="space-y-4" autoComplete="off">
<div><Label htmlFor="admin-email">管理员邮箱</Label><Input id="admin-email" type="email" maxLength={254} autoComplete="off" spellCheck={false} value={email} onChange={event => setEmail(event.target.value)} required disabled={pending || embedded} className="mt-2" /></div>
{!recovering && <div><Label htmlFor="admin-password">密码</Label><Input id="admin-password" type="password" maxLength={256} autoComplete="off" value={password} onChange={event => setPassword(event.target.value)} required disabled={pending || embedded} className="mt-2" /></div>}
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={pending || embedded || !email.trim() || (!recovering && !password)}>{pending ? '正在提交…' : recovering ? '发送重置邮件' : '登录原生市场'}</Button><Button type="button" variant="ghost" disabled={pending || embedded} onClick={() => { setRecovering(value => !value); setPassword(''); setNotice(''); onError(''); }}>{recovering ? '返回登录' : '忘记密码'}</Button></div>
</form>
{notice && <p role="status" className="mt-4 rounded-md bg-blue-50 p-3 text-sm text-blue-900">{notice}</p>}
</>}
</section>;
}
|