feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
@@ -1,10 +1,34 @@
|
||||
import api from './client';
|
||||
import type { CurrentUser, LoginResult, MessageResponse } from './types';
|
||||
import type {
|
||||
CurrentUser,
|
||||
DeviceChallengeStatus,
|
||||
DeviceCompletePayload,
|
||||
DevicePreparePayload,
|
||||
DevicePrepareResult,
|
||||
LoginResult,
|
||||
MessageResponse,
|
||||
SmsCodeResult,
|
||||
} from './types';
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<LoginResult, LoginResult>('/auth/login', { username, password }),
|
||||
|
||||
sendSmsCode: (username: string, password: string) =>
|
||||
api.post<SmsCodeResult, SmsCodeResult>('/auth/sms-code', { username, password }),
|
||||
|
||||
prepareDeviceLogin: (data: DevicePreparePayload) =>
|
||||
api.post<DevicePrepareResult, DevicePrepareResult>('/auth/prepare', data),
|
||||
|
||||
completeDeviceLogin: (data: DeviceCompletePayload) =>
|
||||
api.post<LoginResult, LoginResult>('/auth/complete', data),
|
||||
|
||||
deviceChallengeStatus: (challengeId: string, pollToken: string) =>
|
||||
api.get<DeviceChallengeStatus, DeviceChallengeStatus>(
|
||||
`/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
|
||||
{ params: { pollToken } },
|
||||
),
|
||||
|
||||
me: () => api.get<CurrentUser, CurrentUser>('/auth/me'),
|
||||
|
||||
logout: () => api.post<MessageResponse, MessageResponse>('/auth/logout'),
|
||||
|
||||
@@ -10,9 +10,13 @@ const api = axios.create({
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const requestUrl: string = error.config?.url ?? '';
|
||||
// 登录链路(含 DCAuth 轮询)里的 401 是业务错误,不能当成“登录态失效”跳转。
|
||||
const isAuthFlow =
|
||||
['/auth/login', '/auth/sms-code', '/auth/prepare', '/auth/complete'].includes(requestUrl) ||
|
||||
requestUrl.startsWith('/dcauth/');
|
||||
// 仅在“已登录后 token 失效”时清除登录态并跳转登录页。
|
||||
// 登录接口本身返回 401(用户名/密码错误)时不应跳转,否则会吞掉错误提示。
|
||||
if (error.response?.status === 401 && error.config?.url !== '/auth/login') {
|
||||
if (error.response?.status === 401 && !isAuthFlow) {
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
@@ -25,7 +29,10 @@ api.interceptors.response.use(
|
||||
} else if (detail && typeof detail === 'object') {
|
||||
msg = JSON.stringify(detail);
|
||||
}
|
||||
return Promise.reject(new Error(msg));
|
||||
const wrapped = new Error(msg) as Error & { errorCode?: string; status?: number };
|
||||
wrapped.errorCode = error.response?.data?.errorCode;
|
||||
wrapped.status = error.response?.status;
|
||||
return Promise.reject(wrapped);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -272,6 +272,43 @@ export interface CurrentUser {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface SmsCodeResult {
|
||||
sent: boolean;
|
||||
phoneMasked: string;
|
||||
expiresInSeconds: number;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface DevicePreparePayload {
|
||||
username: string;
|
||||
password: string;
|
||||
smsCode: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export interface DevicePrepareResult {
|
||||
challengeId: string;
|
||||
challenge: string;
|
||||
expiresAt: string;
|
||||
pollToken: string;
|
||||
launchUrl: string;
|
||||
phoneMasked: string;
|
||||
app: string;
|
||||
}
|
||||
|
||||
export interface DeviceChallengeStatus {
|
||||
status: string;
|
||||
deviceTicket?: string;
|
||||
deviceId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeviceCompletePayload {
|
||||
challengeId: string;
|
||||
pollToken: string;
|
||||
deviceTicket: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -280,6 +317,13 @@ export interface UserInfo {
|
||||
remark: string;
|
||||
permissions: string[];
|
||||
custom_permissions?: string[] | null;
|
||||
phone: string;
|
||||
phone_masked: string;
|
||||
phone_bound_at?: string | null;
|
||||
device_id: string;
|
||||
device_id_masked: string;
|
||||
device_bound_at?: string | null;
|
||||
device_last_used_at?: string | null;
|
||||
}
|
||||
|
||||
// ==================== Account ====================
|
||||
|
||||
@@ -10,5 +10,12 @@ export const userApi = {
|
||||
rename: (id: number, username: string) =>
|
||||
api.patch<UserInfo, UserInfo>(`/users/${id}/username`, { username }),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
|
||||
bindPhone: (id: number, phone: string) =>
|
||||
api.post<UserInfo, UserInfo>(`/admin/users/${id}/phone`, { phone }),
|
||||
resetDevice: (id: number) =>
|
||||
api.post<
|
||||
{ success: boolean; reset: boolean; revoked: number; message: string },
|
||||
{ success: boolean; reset: boolean; revoked: number; message: string }
|
||||
>(`/admin/users/${id}/device/reset`),
|
||||
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Card, Divider, Form, Input, Space, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LockOutlined,
|
||||
MobileOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '../api/modules';
|
||||
import type { DevicePrepareResult, LoginResult } from '../api/modules';
|
||||
import { setAuth, type AuthUser } from '../store/auth';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { useTheme } from '../store/useTheme';
|
||||
@@ -12,78 +18,370 @@ import { useAppInfo } from '../hooks/useAppInfo';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type LoginMode = 'secure' | 'legacy';
|
||||
type LoginValues = { username: string; password: string; smsCode?: string };
|
||||
|
||||
function errorCodeOf(error: unknown): string {
|
||||
return (error as { errorCode?: string } | null)?.errorCode || '';
|
||||
}
|
||||
|
||||
function launchProtocol(url: string) {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.setTimeout(() => anchor.remove(), 1000);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
|
||||
const [mode, setMode] = useState<LoginMode>('secure');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [phoneMasked, setPhoneMasked] = useState('');
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [launchUrl, setLaunchUrl] = useState('');
|
||||
const [form] = Form.useForm<LoginValues>();
|
||||
const passwordRef = useRef<InputRef>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const appInfo = useAppInfo();
|
||||
const versionText = appInfo?.version ? `v${appInfo.version}` : 'v--';
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
setCountdown(seconds);
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setCountdown((current) => {
|
||||
if (current <= 1) {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return current - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function switchMode(next: LoginMode) {
|
||||
if (loading || waiting) return;
|
||||
setMode(next);
|
||||
form.setFieldValue('smsCode', '');
|
||||
setWaiting(false);
|
||||
setLaunchUrl('');
|
||||
setPhoneMasked('');
|
||||
}
|
||||
|
||||
function applyLoginResult(result: LoginResult) {
|
||||
const user: AuthUser = {
|
||||
id: 0,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
permissions: result.permissions,
|
||||
};
|
||||
setAuth(user);
|
||||
message.success('登录成功');
|
||||
onLogin?.();
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
let values: Pick<LoginValues, 'username' | 'password'>;
|
||||
try {
|
||||
const result = await authApi.login(values.username, values.password);
|
||||
const user: AuthUser = {
|
||||
id: 0,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
permissions: result.permissions,
|
||||
};
|
||||
setAuth(user);
|
||||
message.success('登录成功');
|
||||
onLogin?.(); // 触发 App 重渲染
|
||||
navigate('/', { replace: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = getErrorMessage(e) || '登录失败';
|
||||
message.error(msg);
|
||||
// 密码错误(401)时清空密码并自动聚焦,方便快速重试
|
||||
if (msg.includes('密码') || msg.includes('用户名')) {
|
||||
form.setFieldValue('password', '');
|
||||
passwordRef.current?.focus();
|
||||
values = await form.validateFields(['username', 'password']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const result = await authApi.sendSmsCode(values.username.trim(), values.password);
|
||||
setPhoneMasked(result.phoneMasked);
|
||||
startCountdown(60);
|
||||
message.success(`验证码已发送至 ${result.phoneMasked}`);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'phone_unbound') {
|
||||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||||
} else {
|
||||
message.error(getErrorMessage(error) || '验证码发送失败');
|
||||
}
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSecureLogin(values: LoginValues) {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setLoading(true);
|
||||
setWaiting(false);
|
||||
try {
|
||||
const prepared = await authApi.prepareDeviceLogin({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
smsCode: (values.smsCode || '').trim(),
|
||||
apiBase: window.location.origin,
|
||||
});
|
||||
setPhoneMasked(prepared.phoneMasked);
|
||||
setLaunchUrl(prepared.launchUrl);
|
||||
setWaiting(true);
|
||||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认');
|
||||
launchProtocol(prepared.launchUrl);
|
||||
|
||||
const completed = await waitForDevice(prepared);
|
||||
applyLoginResult(completed);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'phone_unbound') {
|
||||
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
|
||||
} else if (code === 'device_mismatch') {
|
||||
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备');
|
||||
} else if (code === 'challenge_expired' || code === 'challenge_rejected') {
|
||||
message.error('本机验证已过期,请重新发起登录');
|
||||
} else if (code === 'device_timeout') {
|
||||
message.error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起');
|
||||
} else {
|
||||
const text = getErrorMessage(error) || '登录失败';
|
||||
message.error(text);
|
||||
if (text.includes('密码') || text.includes('用户名')) {
|
||||
form.setFieldValue('password', '');
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setLoading(false);
|
||||
setWaiting(false);
|
||||
setLaunchUrl('');
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDevice(prepared: DevicePrepareResult): Promise<LoginResult> {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
await sleep(1000);
|
||||
const status = await authApi.deviceChallengeStatus(
|
||||
prepared.challengeId,
|
||||
prepared.pollToken,
|
||||
);
|
||||
if (status.status === 'authorized' && status.deviceTicket) {
|
||||
return authApi.completeDeviceLogin({
|
||||
challengeId: prepared.challengeId,
|
||||
pollToken: prepared.pollToken,
|
||||
deviceTicket: status.deviceTicket,
|
||||
});
|
||||
}
|
||||
if (status.status === 'expired' || status.status === 'rejected') {
|
||||
const error = new Error(status.message || '本机验证已过期,请重新登录') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
error.errorCode = status.status === 'expired' ? 'challenge_expired' : 'challenge_rejected';
|
||||
throw error;
|
||||
}
|
||||
if (status.status === 'ticket_issued' || status.status === 'consumed') {
|
||||
const error = new Error('本机验证会话已被使用,请重新发起登录') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
error.errorCode = 'challenge_used';
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth');
|
||||
}
|
||||
}
|
||||
const timeout = new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') as Error & {
|
||||
errorCode?: string;
|
||||
};
|
||||
timeout.errorCode = 'device_timeout';
|
||||
throw timeout;
|
||||
}
|
||||
|
||||
async function handleLegacyLogin(values: LoginValues) {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authApi.login(values.username.trim(), values.password);
|
||||
applyLoginResult(result);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'secure_login_required') {
|
||||
message.warning('该账号已启用安全登录,请使用短信验证码 + 本机验证登录');
|
||||
} else {
|
||||
message.error(getErrorMessage(error) || '登录失败');
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Card style={{ width: 380, boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)' }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: isDark
|
||||
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: 420,
|
||||
boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
直播运营后台
|
||||
</Title>
|
||||
<Text type="secondary" style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: -20,
|
||||
marginBottom: 24,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
marginTop: -20,
|
||||
marginBottom: 24,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
当前版本 {versionText}
|
||||
</Text>
|
||||
<Form form={form} onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" ref={passwordRef} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
|
||||
{mode === 'secure' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="安全登录"
|
||||
description="用户名密码验证后,还需短信验证码和本机 DCAuth 设备签名。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{waiting ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="等待本机确认"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<span>请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth。</span>
|
||||
{launchUrl ? (
|
||||
<Text code copyable={{ text: launchUrl }} style={{ wordBreak: 'break-all' }}>
|
||||
{launchUrl}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} onFinish={handleSecureLogin} size="large" requiredMark={false}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名(中文 / 英文 / 数字)"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="smsCode"
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
placeholder={phoneMasked ? `6 位验证码(${phoneMasked})` : '6 位短信验证码'}
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
htmlType="button"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0 || loading}
|
||||
onClick={() => void sendCode()}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
安全登录 · 拉起本机验证
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider plain style={{ margin: '4px 0 12px' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未绑定手机号的旧账号
|
||||
</Text>
|
||||
</Divider>
|
||||
<Button type="link" block onClick={() => switchMode('legacy')} disabled={loading}>
|
||||
旧账号仅密码登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="旧账号仅密码登录"
|
||||
description="仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form form={form} onFinish={handleLegacyLogin} size="large" requiredMark={false}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
密码登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Button type="link" block onClick={() => switchMode('secure')} disabled={loading}>
|
||||
<MobileOutlined /> 返回安全登录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { userApi, type UserInfo } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
@@ -59,6 +59,13 @@ export default function UsersPage() {
|
||||
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [permLoading, setPermLoading] = useState(false);
|
||||
|
||||
// 手机号绑定 / 设备重置
|
||||
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
|
||||
const [phoneUser, setPhoneUser] = useState<UserInfo | null>(null);
|
||||
const [phoneForm] = Form.useForm<{ phone: string }>();
|
||||
const [phoneSaving, setPhoneSaving] = useState(false);
|
||||
const [resettingId, setResettingId] = useState<number | null>(null);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canAssignPerm = can('user:assign_permissions');
|
||||
@@ -136,6 +143,42 @@ export default function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 手机号 / 设备 ----
|
||||
const handlePhoneClick = (user: UserInfo) => {
|
||||
setPhoneUser(user);
|
||||
phoneForm.setFieldsValue({ phone: user.phone || '' });
|
||||
setPhoneModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePhoneSave = async () => {
|
||||
if (!phoneUser) return;
|
||||
try {
|
||||
const values = await phoneForm.validateFields();
|
||||
setPhoneSaving(true);
|
||||
await userApi.bindPhone(phoneUser.id, values.phone.trim());
|
||||
message.success('手机号已更新');
|
||||
setPhoneModalOpen(false);
|
||||
loadUsers();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setPhoneSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetDevice = async (id: number) => {
|
||||
setResettingId(id);
|
||||
try {
|
||||
const result = await userApi.resetDevice(id);
|
||||
message.success(result.message || (result.reset ? '已重置设备' : '当前没有已绑定设备'));
|
||||
loadUsers();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setResettingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 权限管理 ----
|
||||
const handlePermClick = async (user: UserInfo) => {
|
||||
setPermUser(user);
|
||||
@@ -209,6 +252,55 @@ export default function UsersPage() {
|
||||
width: 80,
|
||||
render: (active: boolean) => active ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 170,
|
||||
render: (_: unknown, record: UserInfo) => (
|
||||
<Space size={4}>
|
||||
{record.phone ? (
|
||||
<span>{record.phone_masked || record.phone}</span>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button size="small" type="link" onClick={() => handlePhoneClick(record)}>
|
||||
{record.phone ? '修改' : '绑定'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定设备',
|
||||
dataIndex: 'device_id',
|
||||
width: 230,
|
||||
render: (_: unknown, record: UserInfo) =>
|
||||
record.device_id ? (
|
||||
<Space size={4} wrap>
|
||||
<span style={{ fontFamily: 'monospace' }}>{record.device_id_masked || record.device_id}</span>
|
||||
{canEdit && (
|
||||
<Popconfirm
|
||||
title="确认重置设备?"
|
||||
description="重置后该账号下次登录会重新绑定当前设备。"
|
||||
onConfirm={() => handleResetDevice(record.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
icon={<ReloadOutlined />}
|
||||
loading={resettingId === record.id}
|
||||
>
|
||||
重置设备
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -250,7 +342,7 @@ export default function UsersPage() {
|
||||
loading={loading}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 844 }}
|
||||
scroll={{ x: 1240 }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
@@ -378,6 +470,35 @@ export default function UsersPage() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 绑定 / 修改手机号 Modal */}
|
||||
<Modal
|
||||
title={phoneUser ? `绑定手机号 - ${phoneUser.username}` : '绑定手机号'}
|
||||
open={phoneModalOpen}
|
||||
onCancel={() => setPhoneModalOpen(false)}
|
||||
onOk={handlePhoneSave}
|
||||
okText="保存"
|
||||
confirmLoading={phoneSaving}
|
||||
width={420}
|
||||
>
|
||||
<Form form={phoneForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="11 位手机号" maxLength={11} autoComplete="tel" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="绑定后该账号必须使用「密码 + 短信验证码 + 本机 DCAuth 设备」登录。"
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user