feat: add admin user management and disable public registration
This commit is contained in:
1 parent
cfbba73791
commit
312a5ccb86
32 files changed
+1120
-69
No files matched your search
@@ -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
@@ -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('设置分类')}>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -618,9 +618,43 @@
|
||||
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
|
||||
"当天趋势数据": "當天趨勢數據",
|
||||
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。",
|
||||
"记住账号密码": "記住帳號密碼",
|
||||
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。",
|
||||
"快速设置估值": "快速設定估值",
|
||||
"记住账号密码": "記住賬號密碼",
|
||||
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登錄可自動填充。",
|
||||
"快速设置估值": "快速設置估值",
|
||||
"结束快速估值": "結束快速估值",
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。"
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。",
|
||||
"管理员后台": "管理員後臺",
|
||||
"管理员": "管理員",
|
||||
"普通用户": "普通用戶",
|
||||
"只读用户": "只讀用戶",
|
||||
"添加账号": "添加賬號",
|
||||
"初始密码": "初始密碼",
|
||||
"账号权限": "賬號權限",
|
||||
"账号管理": "賬號管理",
|
||||
"状态": "狀態",
|
||||
"当前账号": "當前賬號",
|
||||
"已封禁": "已封禁",
|
||||
"待首次改密": "待首次改密",
|
||||
"正常": "正常",
|
||||
"封禁账号": "封禁賬號",
|
||||
"解除封禁": "解除封禁",
|
||||
"输入密码": "輸入密碼",
|
||||
"账号由管理员创建,请联系管理员获取账号。": "賬號由管理員創建,請聯繫管理員獲取賬號。",
|
||||
"新账号首次登录必须修改初始密码。": "新賬號首次登錄必須修改初始密碼。",
|
||||
"管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。": "管理員管理賬號;普通用戶可管理自己的數據;只讀用戶可查詢數據並修改自己的登錄密碼。",
|
||||
"修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。": "修改權限或封禁後,該賬號的登錄會話與 MCP 授權會撤銷,需要重新登錄或授權。",
|
||||
"首次登录,请修改密码": "首次登錄,請修改密碼",
|
||||
"设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。": "設置與初始密碼不同的新密碼後,即可進入系統。新密碼至少 10 個字符,最多 72 字節。",
|
||||
"修改密码并进入": "修改密碼並進入",
|
||||
"当前为只读账号,无法修改业务数据。": "當前為只讀賬號,無法修改業務數據。",
|
||||
"公开注册已关闭,请联系管理员创建账号": "公開註冊已關閉,請聯繫管理員創建賬號",
|
||||
"账号已被封禁": "賬號已被封禁",
|
||||
"首次登录必须修改密码": "首次登錄必須修改密碼",
|
||||
"只读账号不能修改数据": "只讀賬號不能修改數據",
|
||||
"请设置与初始密码不同的新密码(至少 10 个字符)": "請設置與初始密碼不同的新密碼(至少 10 個字符)",
|
||||
"需要已完成改密的管理员账号": "需要已完成改密的管理員賬號",
|
||||
"不能修改自己的系统权限或封禁自己": "不能修改自己的系統權限或封禁自己",
|
||||
"必须保留至少一个可用管理员": "必須保留至少一個可用管理員",
|
||||
"管理员权限已变更": "管理員權限已變更",
|
||||
"账号不存在": "賬號不存在"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user