1
This commit is contained in:
478
src/components/auth/AccountModal.tsx
Normal file
478
src/components/auth/AccountModal.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthCaptchaChallenge,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type AccountModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
riskBlocks: AuthRiskBlockSummary[];
|
||||
sessions: AuthSessionSummary[];
|
||||
auditLogs: AuthAuditLogEntry[];
|
||||
loadingRiskBlocks: boolean;
|
||||
loadingSessions: boolean;
|
||||
loadingAuditLogs: boolean;
|
||||
onClose: () => void;
|
||||
onLogout: () => Promise<void>;
|
||||
onRefreshRiskBlocks: () => Promise<void>;
|
||||
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
|
||||
onRefreshSessions: () => Promise<void>;
|
||||
onLogoutAll: () => Promise<void>;
|
||||
onRefreshAuditLogs: () => Promise<void>;
|
||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendChangePhoneCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onChangePhone: (phone: string, code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'wechat':
|
||||
return '微信登录';
|
||||
case 'phone':
|
||||
return '手机号登录';
|
||||
default:
|
||||
return '账号登录';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function AccountModal({
|
||||
user,
|
||||
isOpen,
|
||||
riskBlocks,
|
||||
sessions,
|
||||
auditLogs,
|
||||
loadingRiskBlocks,
|
||||
loadingSessions,
|
||||
loadingAuditLogs,
|
||||
onClose,
|
||||
onLogout,
|
||||
onRefreshRiskBlocks,
|
||||
onLiftRiskBlock,
|
||||
onRefreshSessions,
|
||||
onLogoutAll,
|
||||
onRefreshAuditLogs,
|
||||
onRevokeSession,
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
}: AccountModalProps) {
|
||||
const [editingPhone, setEditingPhone] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [changePhoneError, setChangePhoneError] = useState('');
|
||||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [changingPhone, setChangingPhone] = useState(false);
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/62 px-4 py-4 sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,_rgba(20,23,31,0.96),_rgba(10,12,18,0.98))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.58)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-amber-200/70">
|
||||
账号信息
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-zinc-200">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
登录方式:{resolveLoginMethodLabel(user.loginMethod)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
手机号:{user.phoneNumberMasked || '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
微信绑定:{user.wechatBound ? '已绑定' : '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
账号状态:
|
||||
{user.bindingStatus === 'pending_bind_phone'
|
||||
? ' 待绑定手机号'
|
||||
: ' 已激活'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
当前安全状态
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshRiskBlocks();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingRiskBlocks ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取安全状态...
|
||||
</div>
|
||||
) : riskBlocks.length > 0 ? (
|
||||
riskBlocks.map((block) => (
|
||||
<div
|
||||
key={`${block.scopeType}:${block.expiresAt}`}
|
||||
className="rounded-2xl border border-amber-300/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{block.title}</span>
|
||||
<span className="text-xs text-amber-100/75">
|
||||
剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-amber-100/85">
|
||||
{block.detail}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-emerald-300/20 px-3 text-xs text-emerald-50 transition hover:border-emerald-300/45 hover:bg-emerald-400/10"
|
||||
onClick={() => {
|
||||
void onLiftRiskBlock(block.scopeType);
|
||||
}}
|
||||
>
|
||||
解除保护
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
当前没有生效中的安全限制。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
登录设备
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshSessions();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingSessions ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<span className="text-xs text-emerald-200/85">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{session.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-rose-400/20 px-3 text-xs text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onRevokeSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
踢下线
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无可展示的登录设备。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
更换手机号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
setEditingPhone((current) => !current);
|
||||
setChangePhoneError('');
|
||||
setChangePhoneHint('');
|
||||
}}
|
||||
>
|
||||
{editingPhone ? '收起' : '修改'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingPhone ? (
|
||||
<div className="mt-3 grid gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4">
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>新手机号</span>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={phone}
|
||||
inputMode="numeric"
|
||||
placeholder="13800000000"
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-11 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={code}
|
||||
inputMode="numeric"
|
||||
placeholder="输入验证码"
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-11 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSendingCode(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
const result = await onSendChangePhoneCode(phone, {
|
||||
challengeId: changePhoneCaptchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setChangePhoneHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
setChangePhoneHint('');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{changePhoneHint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{changePhoneHint}
|
||||
</div>
|
||||
) : null}
|
||||
<CaptchaChallengeField
|
||||
challenge={changePhoneCaptchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
{changePhoneError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{changePhoneError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||||
className="h-11 rounded-2xl border border-sky-300/20 px-4 text-sm font-medium text-sky-100 transition hover:border-sky-300/45 hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPhone(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
await onChangePhone(phone, code);
|
||||
setChangePhoneHint('手机号已更新。');
|
||||
setPhone('');
|
||||
setCode('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '更换手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setChangingPhone(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
最近账号操作
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshAuditLogs();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingAuditLogs ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取账号操作记录...
|
||||
</div>
|
||||
) : auditLogs.length > 0 ? (
|
||||
auditLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{log.title}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{formatSessionTime(log.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
{log.detail}
|
||||
</div>
|
||||
{log.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{log.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无账号操作记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 h-11 w-full rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-11 w-full rounded-2xl border border-rose-400/20 px-4 text-sm font-medium text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user