'use client';
// Settings.tsx — 设置:偏好(语言/主题) + 设备管理(列表/移除/二次确认) + TOTP 2FA(绑定/验证/解禁)。
// 设计稿 §5:Web 用户中心「设置(含 2FA/TOTP 开关,用户可自选开启)」。
import React, { useEffect, useState } from 'react';
import { Icon } from './icons';
import { card, input } from './shared';
import { ErrorLine } from './Login';
import { useUI } from '../lib/theme';
import type { TFn, Lang } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { bilingual } from '../lib/api/errors';
import type { Device, TotpSetup } from '../lib/api/types';
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
return (
);
}
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
function Preferences({ t }: { t: TFn }) {
const { lang, setLang, theme, setTheme } = useUI();
return (
{t('prefTitle')}
setLang(v as Lang)}
/>
setTheme(v as 'light' | 'dark')}
/>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
{label}
{children}
);
}
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record }) {
return (
{options.map(([v, l]) => (
))}
);
}
/* ───────── TOTP 2FA ───────── */
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
const api = getClient();
const [mode, setMode] = useState<'idle' | 'setup' | 'disable'>('idle');
const [setup, setSetup] = useState(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [flash, setFlash] = useState('');
function reset() {
setMode('idle');
setCode('');
setErr('');
setSetup(null);
}
async function startSetup() {
setBusy(true);
setErr('');
try {
const s = await api.totpSetup();
setSetup(s);
setMode('setup');
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
async function verify() {
if (code.length !== 6 || busy) return;
setBusy(true);
setErr('');
try {
await api.totpVerify(code);
reset();
setFlash(t('totpEnabledOk'));
onChange();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
async function disable() {
if (code.length !== 6 || busy) return;
setBusy(true);
setErr('');
try {
await api.totpDisable(code);
reset();
setFlash(t('totpDisabledOk'));
onChange();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
return (
{t('totpTitle')}
{enabled ? t('totpOn') : t('totpOff')}
{t('totpDesc')}
{mode === 'idle' && (
enabled ? (
) : (
)
)}
{flash && (
{flash}
)}
{mode === 'setup' && setup && (
{t('totpStep1')}
{t('totpSecretLabel')}
{setup.secret}
{t('totpStep2')}
{ setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && verify()}
placeholder="······"
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
/>
{err &&
}
)}
{mode === 'disable' && (
{t('totpDisableHint')}
{ setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && disable()}
placeholder="······"
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
/>
{err &&
}
)}
);
}
// 二维码占位:与订阅页一致的设计语言(dashed 框 + qr 图标)。mock 阶段不引入 QR 依赖。
function QrPlaceholder({ uri }: { uri: string }) {
return (
otpauth://
);
}
/* ───────── Devices ───────── */
function Devices({ t, lang }: { t: TFn; lang: Lang }) {
const api = getClient();
const [devices, setDevices] = useState(null);
const [confirmId, setConfirmId] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
async function load() {
try {
setDevices(await api.listDevices());
} catch (e) {
setErr(bilingual(e, lang));
}
}
// 仅挂载时拉取一次设备列表
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { load(); }, []);
async function remove(id: string) {
setBusy(true);
setErr('');
try {
await api.removeDevice(id);
setConfirmId(null);
await load();
} catch (e) {
setErr(bilingual(e, lang));
} finally {
setBusy(false);
}
}
return (
{t('devTitle')}
{t('devSub')}
{err &&
}
{devices === null ? (
{t('loading')}
) : devices.length === 0 ? (
{t('devEmpty')}
) : (
devices.map((d, i) => (
{d.name}
{d.current && {t('devCurrent')}}
{d.platform} · {t('devLastActive')} {d.lastActive}
{!d.current && (
)}
))
)}
{confirmId && (
setConfirmId(null)}
onConfirm={() => remove(confirmId)}
/>
)}
);
}
function ConfirmModal({ t, busy, onCancel, onConfirm }: { t: TFn; busy: boolean; onCancel: () => void; onConfirm: () => void }) {
return (
e.stopPropagation()} style={{ ...card, width: 380, maxWidth: '100%', padding: '24px 24px 20px' }}>
{t('devRemoveConfirmTitle')}
{t('devRemoveConfirmSub')}
);
}
const accentBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--accent)', color: 'var(--fg-on-accent)', fontWeight: 700, fontSize: 13, cursor: 'pointer' };
const ghostBtn: React.CSSProperties = { border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'transparent', color: 'var(--fg2)', fontWeight: 600, fontSize: 13, cursor: 'pointer' };
const dangerBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--danger)', color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer' };