feat: add admin user management and disable public registration

This commit is contained in:
陈煜 committed 2026-10-05 14:08:39 +08:00
1 parent cfbba73791
commit 312a5ccb86
32 files changed
+1120 -69

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useState, type FormEvent } from 'react';
import { api, type User } from './api';
import { t as tr } from './i18n';
type Account = {
id: string;
username: string;
role: string;
banned: boolean;
mustChangePassword: boolean;
};
const roleLabels: Record<string, string> = {
admin: '管理员',
user: '普通用户',
readonly: '只读用户',
};
export function AdminPanel({ user, report }: { user: User; report: (e: unknown) => void }) {
const [items, setItems] = useState<Account[]>([]),
[total, setTotal] = useState(0),
[offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false),
[version, setVersion] = useState(0);
useEffect(() => {
let cancelled = false;
setBusy(true);
void api<{ items: Account[]; total: number }>('/admin/users?limit=50&offset=' + offset)
.then((data) => {
if (!cancelled) {
setItems(data.items);
setTotal(data.total);
}
})
.catch((e) => {
if (!cancelled) report(e);
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [offset, version]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget,
data = new FormData(form);
setBusy(true);
try {
await api('/admin/users', 'POST', {
username: data.get('username'),
password: data.get('password'),
role: data.get('role'),
});
form.reset();
setVersion((v) => v + 1);
} catch (e) {
report(e);
} finally {
setBusy(false);
}
}
async function change(account: Account, data: { role?: string; banned?: boolean }) {
setBusy(true);
try {
await api('/admin/users/' + account.id, 'PATCH', data);
setVersion((v) => v + 1);
} catch (e) {
report(e);
} finally {
setBusy(false);
}
}
return (
<div className="admin-panel">
<section className="panel">
<h2>{tr('添加账号')}</h2>
<p className="muted">{tr('新账号首次登录必须修改初始密码。')}</p>
<form onSubmit={create} className="admin-create">
<label className="field">
<span>{tr('账号')}</span>
<input name="username" required minLength={3} maxLength={64} autoComplete="off" />
</label>
<label className="field">
<span>{tr('初始密码')}</span>
<input
name="password"
type="password"
required
minLength={10}
maxLength={72}
autoComplete="new-password"
/>
</label>
<label className="field">
<span>{tr('账号权限')}</span>
<select name="role" defaultValue="user">
{Object.entries(roleLabels).map(([v, label]) => (
<option key={v} value={v}>
{tr(label)}
</option>
))}
</select>
</label>
<button className="primary" disabled={busy}>
{tr('添加账号')}
</button>
</form>
<p className="muted">
{tr('管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。')}
</p>
</section>
<section className="panel">
<h2>
{tr('账号管理')} · {total}
</h2>
<p className="muted">
{tr('修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。')}
</p>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('账号')}</th>
<th>{tr('账号权限')}</th>
<th>{tr('状态')}</th>
<th>{tr('操作')}</th>
</tr>
</thead>
<tbody>
{items.map((account) => (
<tr key={account.id}>
<td>
<strong>{account.username}</strong>
{account.id === user.id && <small> · {tr('当前账号')}</small>}
</td>
<td>
<select
aria-label={account.username + ' ' + tr('账号权限')}
value={account.role}
disabled={busy || account.id === user.id}
onChange={(e) => void change(account, { role: e.target.value })}
>
{Object.entries(roleLabels).map(([v, label]) => (
<option key={v} value={v}>
{tr(label)}
</option>
))}
</select>
</td>
<td>
{account.banned
? tr('已封禁')
: account.mustChangePassword
? tr('待首次改密')
: tr('正常')}
</td>
<td>
<button
className={account.banned ? 'secondary' : 'danger'}
disabled={busy || account.id === user.id}
onClick={() => void change(account, { banned: !account.banned })}
>
{account.banned ? tr('解除封禁') : tr('封禁账号')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="actions">
<button
className="secondary"
disabled={busy || offset === 0}
onClick={() => setOffset((v) => Math.max(0, v - 50))}
>
{tr('上一页')}
</button>
<button
className="secondary"
disabled={busy || offset + 50 >= total}
onClick={() => setOffset((v) => v + 50)}
>
{tr('下一页')}
</button>
</div>
</section>
</div>
);
}
export function FirstPassword({
changed,
logout,
report,
}: {
changed: (u: User) => void;
logout: () => void;
report: (e: unknown) => void;
}) {
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
if (f.get('newPassword') !== f.get('repeatPassword')) {
report(new Error(tr('两次输入的新密码不一致')));
return;
}
setBusy(true);
try {
await api('/auth/credentials', 'PATCH', {
currentPassword: f.get('currentPassword'),
newPassword: f.get('newPassword'),
});
changed(await api<User>('/auth/me'));
} catch (e) {
report(e);
} finally {
setBusy(false);
}
}
return (
<main className="first-password">
<section className="panel">
<h1>{tr('首次登录,请修改密码')}</h1>
<p className="muted">
{tr('设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。')}
</p>
<form onSubmit={submit}>
<label className="field">
<span>{tr('当前密码')}</span>
<input
name="currentPassword"
type="password"
autoComplete="current-password"
required
maxLength={72}
/>
</label>
<label className="field">
<span>{tr('新密码')}</span>
<input
name="newPassword"
type="password"
autoComplete="new-password"
required
minLength={10}
maxLength={72}
/>
</label>
<label className="field">
<span>{tr('确认新密码')}</span>
<input
name="repeatPassword"
type="password"
autoComplete="new-password"
required
minLength={10}
maxLength={72}
/>
</label>
<button className="primary full" disabled={busy}>
{tr('修改密码并进入')}
</button>
</form>
<button className="text" disabled={busy} onClick={logout}>
{tr('退出登录')}
</button>
</section>
</main>
);
}
+35 -33
View File
@@ -58,6 +58,7 @@ import { AgentConnections } from './AgentConnections';
import { AgentAuthorization } from './AgentReview';
import { AgentDrafts } from './AgentDrafts';
import { savedLogin, rememberLogin } from './login-preferences';
import { AdminPanel, FirstPassword } from './AdminPanel';
import { PositionDeletion, deletionLabel } from './PositionDeletion';
import { IconPicker } from './IconPicker';
import { MetalPanel } from './MetalPanel';
@@ -84,6 +85,7 @@ const labels: Record<string, string> = {
calendar: '收支日历',
schedules: '定时计划',
settings: '设置与备份',
admin: '管理员后台',
};
const categories: Record<string, [string, string][]> = {
account: [
@@ -176,7 +178,6 @@ export default function App() {
const authCheck = useRef<Promise<User> | null>(null);
const [user, setUser] = useState<User | null>(null),
[boot, setBoot] = useState(true),
[register, setRegister] = useState(false),
[page, setPage] = useState(
new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview',
),
@@ -498,7 +499,6 @@ export default function App() {
setBackup(null);
setPreview(null);
setPage('overview');
setRegister(false);
setLoading(false);
setSuccess('');
setQuickTransfer(null);
@@ -521,6 +521,10 @@ export default function App() {
try {
const s = refreshUser ? await api<User>('/auth/me') : userRef.current;
if (!active() || !s) return;
if (s.mustChangePassword) {
setUser(s);
return;
}
const visibilityChanged = !!s.revealed !== !!userRef.current?.revealed;
if (visibilityChanged) rangeOnly = false;
if (refreshUser) {
@@ -561,7 +565,7 @@ export default function App() {
const rows = await api<Position[]>('/positions?kind=account');
if (active()) setPositions(rows);
} else if (['account', 'asset', 'debt'].includes(page)) {
if (page === 'account' && !rangeOnly && runDue) {
if (page === 'account' && !rangeOnly && runDue && s.role !== 'readonly') {
const result = await api<{
executed: number;
errors: { message: string }[];
@@ -703,7 +707,7 @@ export default function App() {
}, [modal]);
const previousView = useRef('');
useEffect(() => {
if (!user) return;
if (!user || user.mustChangePassword) return;
const mutated = previousDataVersion.current !== dataVersion;
previousDataVersion.current = dataVersion;
const rangeOnly =
@@ -718,7 +722,7 @@ export default function App() {
return () => {
loadGeneration.current++;
};
}, [viewKey, !!user, dataVersion]);
}, [viewKey, !!user, user?.mustChangePassword, dataVersion]);
useEffect(() => {
const session = sessionGeneration.current;
authCheck.current ||= api<User>('/auth/me');
@@ -829,13 +833,12 @@ export default function App() {
setBusy(true);
setError('');
try {
const u = await api<User>('/auth/' + (register ? 'register' : 'login'), 'POST', {
const u = await api<User>('/auth/login', 'POST', {
username: f.get('username'),
password: f.get('password'),
});
if (session !== sessionGeneration.current) return;
if (!register)
void rememberLogin(String(f.get('username')), String(f.get('password')), remember);
void rememberLogin(String(f.get('username')), String(f.get('password')), remember);
const agentReturn = new URLSearchParams(location.search).has('agent_authorization');
setPage(agentReturn ? 'agent-review' : 'overview');
if (agentReturn) setSettingsSection('agent');
@@ -911,10 +914,8 @@ export default function App() {
<section className="auth-form">
<div>
<p className="eyebrow">{tr('开始你的资产之路')}</p>
<h2>{register ? tr('创建账号') : tr('欢迎回来')}</h2>
<p className="muted">
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
</p>
<h2>{tr('欢迎回来')}</h2>
<p className="muted">{tr('登录以查看你的资产与负债')}</p>
<form onSubmit={auth} autoComplete="on">
<Field label={tr('账号')}>
@@ -932,14 +933,14 @@ export default function App() {
<input
name="password"
type="password"
autoComplete={register ? 'new-password' : 'current-password'}
autoComplete="current-password"
required
minLength={10}
minLength={1}
maxLength={72}
placeholder={tr('至少 10 个字符,最多 72 字节')}
placeholder={tr('输入密码')}
/>
</Field>
{!register && (
{
<>
<label className="check-line">
<input
@@ -951,24 +952,13 @@ export default function App() {
</label>
<p className="muted">{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}</p>
</>
)}
}
<button className="primary full" disabled={busy}>
{busy ? tr('正在处理…') : register ? tr('注册并进入') : tr('登录')}
{busy ? tr('正在处理…') : tr('登录')}
<ArrowUpRight size={18} />
</button>
</form>
<p>
{register ? tr('已有账号?') : tr('还没有账号?')}{' '}
<button
className="text"
onClick={() => {
setRegister(!register);
setError('');
}}
>
{register ? tr('登录') : tr('创建账号')}
</button>
</p>
<p className="muted">{tr('账号由管理员创建,请联系管理员获取账号。')}</p>
<p className="privacy">
<ShieldCheck size={16} />
{tr('个人数据按登录身份隔离')}
@@ -977,6 +967,8 @@ export default function App() {
</section>
</div>
);
if (user.mustChangePassword)
return <FirstPassword changed={setUser} logout={() => void logout()} report={report} />;
async function correctHistory(h: History) {
if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) {
const session = sessionGeneration.current,
@@ -1060,8 +1052,11 @@ export default function App() {
['calendar', CalendarDays],
['schedules', HistoryIcon],
['settings', Settings],
['admin', ShieldCheck],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
const visibleNav = nav.filter(([key]) =>
key === 'admin' ? user.role === 'admin' : !(user.hiddenMenus || []).includes(key),
);
const groups = orderedGroups(
positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''),
user.accountGroupOrder,
@@ -1181,18 +1176,24 @@ export default function App() {
</p>
</div>
<div className="actions">
{page === 'account' && !p && !quickMode && (
{user.role !== 'readonly' && page === 'account' && !p && !quickMode && (
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
{tr('转账')}
</button>
)}
{['overview', 'account', 'asset', 'debt'].includes(page) &&
{user.role !== 'readonly' &&
['overview', 'account', 'asset', 'debt'].includes(page) &&
!p &&
!(page === 'account' && quickMode) &&
newAction}
</div>
</div>
{user.role === 'readonly' && (
<p className="muted" role="status">
{tr('当前为只读账号,无法修改业务数据。')}
</p>
)}
{loading && (
<p role="status" className="muted">
{tr('正在刷新数据…')}
@@ -2128,6 +2129,7 @@ export default function App() {
{page === 'calendar' && (
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
)}
{page === 'admin' && user.role === 'admin' && <AdminPanel user={user} report={report} />}
{page === 'settings' && (
<>
<nav className="settings-tabs" aria-label={tr('设置分类')}>
+3
View File
@@ -18,6 +18,9 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
return body;
}
export type User = {
id?: string;
role?: 'admin' | 'user' | 'readonly';
mustChangePassword?: boolean;
username: string;
baseCurrency: string;
hiddenMenus: string[];
+35 -1
View File
@@ -622,5 +622,39 @@
"密码由浏览器密码管理器保存,下次登录可自动填充。": "Your browser password manager saves the password for autofill next time.",
"快速设置估值": "Quick valuation",
"结束快速估值": "Finish quick valuation",
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes."
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes.",
"管理员后台": "Administration",
"管理员": "Administrator",
"普通用户": "Standard user",
"只读用户": "Read-only user",
"添加账号": "Add user",
"初始密码": "Initial password",
"账号权限": "User role",
"账号管理": "User management",
"状态": "Status",
"当前账号": "Current user",
"已封禁": "Banned",
"待首次改密": "Password change required",
"正常": "Active",
"封禁账号": "Ban user",
"解除封禁": "Unban user",
"输入密码": "Enter password",
"账号由管理员创建,请联系管理员获取账号。": "Contact your administrator to obtain an account.",
"新账号首次登录必须修改初始密码。": "New users must change their initial password on first login.",
"管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。": "Administrators manage users. Standard users manage their own data. Read-only users can view data and change their own login password.",
"修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。": "Changing a role or ban status revokes login sessions and MCP grants. The user must sign in or authorize again.",
"首次登录,请修改密码": "Change your password on first login",
"设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。": "Set a password different from your initial password to continue. Use at least 10 characters and at most 72 bytes.",
"修改密码并进入": "Change password and continue",
"当前为只读账号,无法修改业务数据。": "This account is read-only and cannot change business data.",
"公开注册已关闭,请联系管理员创建账号": "Public registration is closed. Contact your administrator.",
"账号已被封禁": "This account has been banned.",
"首次登录必须修改密码": "You must change your password on first login.",
"只读账号不能修改数据": "Read-only accounts cannot change data.",
"请设置与初始密码不同的新密码(至少 10 个字符)": "Choose a new password different from your initial password (at least 10 characters).",
"需要已完成改密的管理员账号": "An administrator who has changed their initial password is required.",
"不能修改自己的系统权限或封禁自己": "You cannot change your own role or ban yourself.",
"必须保留至少一个可用管理员": "At least one active administrator must remain.",
"管理员权限已变更": "Administrator permissions have changed.",
"账号不存在": "User not found."
}
+38 -4
View File
@@ -618,9 +618,43 @@
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
"当天趋势数据": "當天趨勢數據",
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。",
"记住账号密码": "記住帳號密碼",
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。",
"快速设置估值": "快速設定估值",
"记住账号密码": "記住賬號密碼",
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登錄可自動填充。",
"快速设置估值": "快速設置估值",
"结束快速估值": "結束快速估值",
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。"
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。",
"管理员后台": "管理員後臺",
"管理员": "管理員",
"普通用户": "普通用戶",
"只读用户": "只讀用戶",
"添加账号": "添加賬號",
"初始密码": "初始密碼",
"账号权限": "賬號權限",
"账号管理": "賬號管理",
"状态": "狀態",
"当前账号": "當前賬號",
"已封禁": "已封禁",
"待首次改密": "待首次改密",
"正常": "正常",
"封禁账号": "封禁賬號",
"解除封禁": "解除封禁",
"输入密码": "輸入密碼",
"账号由管理员创建,请联系管理员获取账号。": "賬號由管理員創建,請聯繫管理員獲取賬號。",
"新账号首次登录必须修改初始密码。": "新賬號首次登錄必須修改初始密碼。",
"管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。": "管理員管理賬號;普通用戶可管理自己的數據;只讀用戶可查詢數據並修改自己的登錄密碼。",
"修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。": "修改權限或封禁後,該賬號的登錄會話與 MCP 授權會撤銷,需要重新登錄或授權。",
"首次登录,请修改密码": "首次登錄,請修改密碼",
"设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。": "設置與初始密碼不同的新密碼後,即可進入系統。新密碼至少 10 個字符,最多 72 字節。",
"修改密码并进入": "修改密碼並進入",
"当前为只读账号,无法修改业务数据。": "當前為只讀賬號,無法修改業務數據。",
"公开注册已关闭,请联系管理员创建账号": "公開註冊已關閉,請聯繫管理員創建賬號",
"账号已被封禁": "賬號已被封禁",
"首次登录必须修改密码": "首次登錄必須修改密碼",
"只读账号不能修改数据": "只讀賬號不能修改數據",
"请设置与初始密码不同的新密码(至少 10 个字符)": "請設置與初始密碼不同的新密碼(至少 10 個字符)",
"需要已完成改密的管理员账号": "需要已完成改密的管理員賬號",
"不能修改自己的系统权限或封禁自己": "不能修改自己的系統權限或封禁自己",
"必须保留至少一个可用管理员": "必須保留至少一個可用管理員",
"管理员权限已变更": "管理員權限已變更",
"账号不存在": "賬號不存在"
}
+25
View File
@@ -2567,3 +2567,28 @@ textarea,
grid-template-columns: minmax(0, 1fr);
}
}
/* Administrator accounts and mandatory first-login password change. */
.admin-panel {
display: grid;
gap: 24px;
}
.admin-create {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
gap: 16px;
align-items: end;
}
.first-password {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.first-password > .panel {
width: min(100%, 520px);
}
@media (max-width: 800px) {
.admin-create {
grid-template-columns: 1fr;
}
}