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>;
}