Files
pangolin/web/usercenter/components/Login.tsx
T
wangjia 5b89de656e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 7s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 26s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 11s
ci-pangolin / Go — build + test (pull_request) Successful in 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 26s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m36s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m3s
Deploy Site / deploy-site (push) Failing after 13m20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 14m42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m52s
feat(web): 5 项 UI 修复 — 统一浅色/登录回跳/logo locale/用户名下拉/Docs 真页
用户中心:
- 统一浅色:theme.tsx 无偏好时不再跟随系统 prefers-color-scheme:dark(登录页变
  黑真因),恒浅色;保留手动切换。
- 登录回跳:UserCenter 加 safeRedirect 白名单,登录成功按 ?redirect= 回来源页
  (官网带 /,登录后回主页),无则进 overview。
- logo locale:新增 i18n brandName(zh 穿山甲/其余 Pangolin),Login/UserCenter
  /Subscription 三处引用。
- 存 pg_uc_email(getMe 时)/ clearSession 删,供官网读用户名。

官网(全走 CSS class 合 CSP,无内联 style):
- logo locale:i18n nav.brand(zh 穿山甲/其余 Pangolin),Header+Footer。
- 登录态头部显示用户名(读同源 pg_uc_email)+ 下拉菜单(进入用户中心/切换用户/
  退出登录,复用 .langmenu 风格);未登录 Log in 带 ?redirect=/ 回跳。
- Docs 四卡片补真内容页(en+zh:quickstart/faq/protocol/privacy + Doc.astro
  布局),卡片改回 <a href>;Protocol 改正 sing-box+REALITY(去 WireGuard)。

验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:52:04 +08:00

170 lines
7.2 KiB
TypeScript

'use client';
// Login.tsx — 登录(凭证 → 可选 TOTP 二段式)。承袭 ucapp.jsx UCLogin 视觉。
import React, { useEffect, useState } from 'react';
import { Icon, Mark } from './icons';
import { card, input, LangSeg } from './shared';
import { useUI } from '../lib/theme';
import { makeT } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { ApiError } from '../lib/api/types';
import { bilingual } from '../lib/api/errors';
export default function Login({ onDone }: { onDone: () => void }) {
const { lang, setLang } = useUI();
const t = makeT(lang);
const api = getClient();
const [email, setEmail] = useState('');
const [pw, setPw] = useState('');
const [step, setStep] = useState<'cred' | 'totp'>('cred');
const [otp, setOtp] = useState('');
const [pending, setPending] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [lockLeft, setLockLeft] = useState(0);
const ok = /\S+@\S+\.\S+/.test(email) && pw.length > 0;
// 锁定倒计时(限流在服务端,前端只做失败提示与倒计时)
useEffect(() => {
if (lockLeft <= 0) return;
const id = setInterval(() => setLockLeft((s) => Math.max(0, s - 1)), 1000);
return () => clearInterval(id);
}, [lockLeft]);
async function submitCred() {
if (!ok || busy || lockLeft > 0) return;
setBusy(true);
setErr('');
try {
const r = await api.login(email.trim(), pw);
if (r.kind === 'totp_required') {
setPending(r.pendingToken);
setStep('totp');
} else {
onDone();
}
} catch (e) {
setErr(bilingual(e, lang));
if (e instanceof ApiError && e.code === 'account_locked' && e.retryAfter) setLockLeft(e.retryAfter);
} finally {
setBusy(false);
}
}
async function submitOtp() {
if (otp.length !== 6 || busy) return;
setBusy(true);
setErr('');
try {
await api.loginTotp(pending, otp);
onDone();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
const credDisabled = !ok || busy || lockLeft > 0;
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
<div style={{ ...card, width: 400, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 26 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Mark size={32} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div>
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
</div>
</div>
<LangSeg lang={lang} setLang={setLang} />
</div>
{step === 'cred' ? (
<>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 22, color: 'var(--fg1)' }}>{t('loginTitle')}</div>
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '6px 0 24px' }}>{t('loginSub')}</div>
<label style={{ display: 'block', marginBottom: 14 }}>
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('emailLabel')}</span>
<input style={input} value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('emailPh')} />
</label>
<label style={{ display: 'block' }}>
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('pwLabel')}</span>
<input
style={input}
type="password"
value={pw}
onChange={(e) => setPw(e.target.value)}
placeholder={t('pwPh')}
onKeyDown={(e) => e.key === 'Enter' && submitCred()}
/>
</label>
<div style={{ textAlign: 'right', margin: '10px 0 18px' }}>
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, cursor: 'pointer' }}>{t('forgotPw')}</span>
</div>
{err && <ErrorLine text={lockLeft > 0 ? `${err} · ${t('lockedCountdown', { s: lockLeft })}` : err} />}
<button
onClick={submitCred}
disabled={credDisabled}
style={primaryBtn(!credDisabled)}
>
{busy ? t('loading') : t('doLogin')}
</button>
<div style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18 }}>{t('noAccount')}</div>
</>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 6 }}>
<Icon name="shield-check" size={20} color="var(--accent)" />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>{t('twoFATitle')}</span>
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-subtle)', border: '1px solid var(--accent-border)', padding: '2px 8px', borderRadius: 999 }}>PRO</span>
</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', margin: '4px 0 20px', lineHeight: 1.6 }}>{t('twoFAHint')}</div>
<input
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => e.key === 'Enter' && submitOtp()}
placeholder="······"
style={{ ...input, fontFamily: 'var(--font-mono)', fontSize: 22, letterSpacing: '0.5em', textAlign: 'center', padding: '14px' }}
/>
{err && <div style={{ marginTop: 14 }}><ErrorLine text={err} /></div>}
<button onClick={submitOtp} disabled={otp.length !== 6 || busy} style={{ ...primaryBtn(otp.length === 6 && !busy), marginTop: 16 }}>
{busy ? t('loading') : t('twoFAConfirm')}
</button>
<div onClick={() => { setStep('cred'); setErr(''); setOtp(''); }} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18, cursor: 'pointer' }}>
{t('twoFABack')}
</div>
</>
)}
</div>
</div>
);
}
function primaryBtn(enabled: boolean): React.CSSProperties {
return {
width: '100%',
border: 'none',
borderRadius: 'var(--radius-full)',
padding: '13px',
fontFamily: 'var(--font-sans)',
fontWeight: 700,
fontSize: 15,
cursor: enabled ? 'pointer' : 'not-allowed',
background: 'var(--accent)',
color: 'var(--fg-on-accent)',
opacity: enabled ? 1 : 0.45,
};
}
export function ErrorLine({ text }: { text: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--danger)', fontSize: 12.5, fontWeight: 600, marginBottom: 12 }}>
<Icon name="alert-triangle" size={14} color="var(--danger)" />
<span>{text}</span>
</div>
);
}