From 1d6b8a64a6df3ec9c073a2e30931029a961729d3 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 11:14:37 +0000 Subject: [PATCH] Add login page, forced password change, and admin user management Why: accounts are admin-issued (user decision 2026-09-30, no self registration); initial and admin-reset passwords must force a change on first login. Evidence: - LoginPage: password (primary) + API key tabs; ChangePasswordGate blocks the app until the forced change completes (mismatch/length validation inline); App auth state machine checking -> login -> mustchange -> ok, session cookie flows through fetch automatically. - Account menu gains change-password and admin-only user management; AdminUsersModal: create account (random initial password generator, admin checkbox), reset password, status table (ADMIN/USER, pending change chips). - Fixed a real bug found in the headless walk: menu toggles passed e.currentTarget into the setState updater where React nulls it by render time, so anchor was null and the menu never rendered; capture the element before the updater. - Headless journey on the real binary with --auth on: admin bootstrap via CLI -> first-login forced change -> app; admin creates bob (bob sees no user management); alice reset via CLI -> forced change on next login; API-key tab login; logout; zero console errors. npm run build + vitest green; make test full suite exit 0. Impact: replaces the old API-key-only LoginGate; embedded no-auth mode unchanged (no login page shown). Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (R2, admin-issued variant) --- src/App.tsx | 57 +++++----- src/api.ts | 15 ++- src/components/AdminUsersModal.tsx | 161 +++++++++++++++++++++++++++++ src/components/LoginPage.tsx | 130 +++++++++++++++++++++++ src/components/TaskSidebar.tsx | 11 +- src/passwords.ts | 11 ++ src/store.ts | 6 +- src/styles.css | 46 +++++++++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 400 insertions(+), 39 deletions(-) create mode 100644 src/components/AdminUsersModal.tsx create mode 100644 src/components/LoginPage.tsx create mode 100644 src/passwords.ts diff --git a/src/App.tsx b/src/App.tsx index ae2babe..fe09a81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from 'react' import { auth } from './auth' +import { api } from './api' +import { LoginPage, ChangePasswordGate } from './components/LoginPage' +import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal' import { store, useStore } from './store' import { TaskSidebar } from './components/TaskSidebar' import { Conversation } from './components/Conversation' @@ -13,33 +16,6 @@ import { DocViewer } from './components/DocViewer' const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40 const RIGHT_MIN = 280, RIGHT_MAX = 560 -// 登录门:服务端开启 API key 认证时(PG 档默认)要求输入密钥;嵌入式档无认证直接放行。 -function LoginGate({ onSuccess }: { onSuccess: () => void }) { - const [key, setKey] = useState('') - const [err, setErr] = useState('') - const submit = async () => { - auth.setKey(key) - try { - const r = await auth.fetch('/api/health') - if (r.ok) { onSuccess(); return } - } catch { /* 网络错误按认证失败处理,提示重试 */ } - auth.clear() - setErr('密钥无效或已撤销,请重试') - } - return ( -
-
-

Agent-System

-

本服务已启用 API key 认证。密钥由服务端创建:agent-system apikey create --owner <name>

- setKey(e.target.value)} - placeholder="ask_…" onKeyDown={e => e.key === 'Enter' && submit()} autoFocus /> - - {err &&
{err}
} -
-
- ) -} - export function App() { useEffect(() => { const h = (e: KeyboardEvent) => workspaceShortcuts(e) @@ -52,14 +28,19 @@ export function App() { const trackerRunId = useStore(s => s.ui.trackerRunId) const viewingArtifact = useStore(s => s.ui.viewingArtifact) const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length) - // authState:checking → ok(放行)| required(登录门)。 - const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking') + // authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。 + const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking') + const [mustChangeOwner, setMustChangeOwner] = useState('') useEffect(() => { ;(async () => { try { const r = await auth.fetch('/api/health') - setAuthState(r.status === 401 ? 'required' : 'ok') + if (r.status === 401) { setAuthState('login'); return } + // 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。 + const me = await api.me().catch(() => null) + if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return } + setAuthState('ok') } catch { setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错 } @@ -103,9 +84,19 @@ export function App() { window.addEventListener('mouseup', up) } + if (authState === 'login') { + return setAuthState('ok')} + onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} /> + } + if (authState === 'mustchange') { + return { await store.refreshMe().catch(() => undefined); setAuthState('ok') }} + onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} /> + } if (authState !== 'ok') { - return authState === 'required' ? setAuthState('ok')} /> - :

连接服务中…

+ return

连接服务中…

} return ( @@ -154,6 +145,8 @@ export function App() { {trackerRunId && } {ui.settingsOpen && } + {ui.adminUsersOpen && store.setUI({ adminUsersOpen: false })} />} + {ui.changePwOpen && store.setUI({ changePwOpen: false })} />} {viewingArtifact && } {toast &&
{toast}
} diff --git a/src/api.ts b/src/api.ts index 2ac22d5..07b9988 100644 --- a/src/api.ts +++ b/src/api.ts @@ -27,7 +27,20 @@ export const api = { // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json(r)), setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), - me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), + // ---- 认证与会话(管理员发放制,R2)---- + login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { + if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + }), + logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), + changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { + if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + }), + listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), + createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), + resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), diff --git a/src/components/AdminUsersModal.tsx b/src/components/AdminUsersModal.tsx new file mode 100644 index 0000000..a176a48 --- /dev/null +++ b/src/components/AdminUsersModal.tsx @@ -0,0 +1,161 @@ +// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。 +// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。 +// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。 +import { useEffect, useState } from 'react' +import { api } from '../api' +import { store, useStore } from '../store' +import { randomPassword } from '../passwords' + +interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } + +function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+ {title} + + +
+
{children}
+
+
+ ) +} + +export function AdminUsersModal({ onClose }: { onClose: () => void }) { + const me = useStore(s => s.me) + const [users, setUsers] = useState(null) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [owner, setOwner] = useState('') + const [name, setName] = useState('') + const [pw, setPw] = useState('') + const [asAdmin, setAsAdmin] = useState(false) + const [busy, setBusy] = useState(false) + const [resetFor, setResetFor] = useState(null) + const [resetPw, setResetPw] = useState('') + + const refresh = async () => { + try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } + } + useEffect(() => { refresh() }, []) + + const create = async () => { + setErr(''); setNotice('') + if (pw.length < 8) { setErr('初始密码至少 8 位'); return } + setBusy(true) + try { + await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin) + setNotice(`已创建 ${owner.trim()}:首次登录须改密`) + setOwner(''); setName(''); setPw('') + await refresh() + } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } + } + + const reset = async () => { + if (!resetFor) return + setErr('') + if (resetPw.length < 8) { setErr('新密码至少 8 位'); return } + setBusy(true) + try { + await api.resetAdminUserPassword(resetFor, resetPw) + setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`) + setResetFor(null); setResetPw('') + await refresh() + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + return ( + + {me?.isAdmin && ( +
+
+ setOwner(e.target.value)} /> + setName(e.target.value)} /> +
+
+ setPw(e.target.value)} /> + + + +
+

创建后该用户首次登录必须修改此初始密码。

+
+ )} + {notice &&
{notice}
} + {err &&
{err}
} + {resetFor ? ( +
+ 重置 {resetFor} 的密码: + setResetPw(e.target.value)} /> + + + +
+ ) : ( + + + + {(users ?? []).map(u => ( + + + + + + + + + ))} + {users !== null && users.length === 0 && } + +
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && ( + + )}
暂无账号
+ )} +
+ ) +} + +/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */ +export function ChangePasswordModal({ onClose }: { onClose: () => void }) { + const me = useStore(s => s.me) + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [err, setErr] = useState('') + const [ok, setOk] = useState(false) + const [busy, setBusy] = useState(false) + + const submit = async () => { + setErr('') + if (next !== confirm) { setErr('两次输入的新密码不一致'); return } + if (next.length < 8) { setErr('新密码至少 8 位'); return } + setBusy(true) + try { + await api.changePassword(current, next) + setOk(true) + store.toast('密码已更新') + setTimeout(onClose, 900) + } catch (e) { + setErr(String(e).includes('401') ? '当前密码不正确' : String(e)) + } finally { setBusy(false) } + } + + return ( + + {ok ?
密码已更新,其他设备的会话已注销。
: ( +
+ + setCurrent(e.target.value)} autoFocus /> + + setNext(e.target.value)} /> + + setConfirm(e.target.value)} /> + {err &&
{err}
} + +
+ )} +
+ ) +} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx new file mode 100644 index 0000000..d60902b --- /dev/null +++ b/src/components/LoginPage.tsx @@ -0,0 +1,130 @@ +// 登录与改密(管理员发放制,R2): +// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口; +// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。 +import { useState } from 'react' +import { auth } from '../auth' +import { api } from '../api' + +function Err({ msg }: { msg: string }) { + if (!msg) return null + return
{msg}
+} + +/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */ +export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) { + const [tab, setTab] = useState<'password' | 'apikey'>('password') + const [owner, setOwner] = useState('') + const [password, setPassword] = useState('') + const [key, setKey] = useState('') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + + const submitPassword = async () => { + setErr(''); setBusy(true) + try { + const me = await api.login(owner.trim(), password) + if (me.mustChangePassword) { + // 初始/重置密码:先完成强制改密再放行。 + onMustChange(me.owner) + return + } + onAuthed() + } catch (e) { + setErr(humanLoginError(String(e))) + } finally { setBusy(false) } + } + + const submitKey = async () => { + setErr(''); setBusy(true) + auth.setKey(key) + try { + const r = await auth.fetch('/api/health') + if (r.ok) { onAuthed(); return } + throw new Error(String(r.status)) + } catch { + auth.clear() + setErr('密钥无效或已撤销,请重试') + } finally { setBusy(false) } + } + + return ( +
+
{ e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}> +
AAgent-System
+

登录

+

账号由管理员发放;首次登录需修改初始密码

+
+ + +
+ {tab === 'password' ? ( + <> + + setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" /> + + setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" /> + + + + ) : ( + <> + + setKey(e.target.value)} autoFocus placeholder="ask_…" /> + + +

密钥由服务端创建:agent-system apikey create --owner <name>。程序化客户端也可直接带 Authorization 头。

+ + )} + +
+ ) +} + +/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */ +export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) { + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async () => { + setErr('') + if (next !== confirm) { setErr('两次输入的新密码不一致'); return } + if (next.length < 8) { setErr('新密码至少 8 位'); return } + setBusy(true) + try { + await api.changePassword(current, next) + onDone() + } catch (e) { + setErr(humanLoginError(String(e)).replace('401: ', '')) + } finally { setBusy(false) } + } + + return ( +
+
{ e.preventDefault(); submit() }}> +
AAgent-System
+

修改密码

+

账号 {owner} 使用初始/重置密码首次登录,须设置新密码后继续

+ + setCurrent(e.target.value)} autoFocus autoComplete="current-password" /> + + setNext(e.target.value)} autoComplete="new-password" /> + + setConfirm(e.target.value)} autoComplete="new-password" /> + + + {onCancel && } + +
+ ) +} + +/** 错误文案:把 HTTP 状态码翻成人话。 */ +export function humanLoginError(raw: string): string { + if (raw.includes('401')) return '用户名或密码不正确' + if (raw.includes('429')) return '失败次数过多,请稍后再试' + if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)' + return raw.replace(/^\d{3}:\s*/, '') +} diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 961364e..df74292 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import { store, useStore } from '../store' import { auth } from '../auth' +import { api } from '../api' import type { Conversation } from '../contracts' import { workspaceEntries } from './Workspaces' import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist' @@ -119,7 +120,7 @@ export function TaskSidebar() { + 新建任务{currentProject ? {currentProject.name} : null} + onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}>▾ setQ(e.target.value)} /> @@ -190,11 +191,11 @@ export function TaskSidebar() { {/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
- @@ -232,9 +233,11 @@ export function TaskSidebar() {
{ store.openSettings(true); setMenu(null) }}>偏好设置 { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''} + { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码… + {me?.isAdmin && { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…} {me?.auth && <>
- { auth.logout() }}>退出登录 + { api.logout().catch(() => undefined); auth.logout() }}>退出登录 } diff --git a/src/passwords.ts b/src/passwords.ts new file mode 100644 index 0000000..b0ad3c3 --- /dev/null +++ b/src/passwords.ts @@ -0,0 +1,11 @@ +// 浏览器端随机初始密码(管理员未显式给定密码时): +// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。 +const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789' + +export function randomPassword(length = 12): string { + const buf = new Uint32Array(length) + crypto.getRandomValues(buf) + let out = '' + for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length] + return out +} diff --git a/src/store.ts b/src/store.ts index 4aa31d1..0006b8f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -38,9 +38,11 @@ export interface UIState { taskOrg: 'project' | 'timeline' showArchived: boolean lastProjectId: string | null + adminUsersOpen: boolean + changePwOpen: boolean } -export interface Me { owner: string; name: string; auth: boolean } +export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean } interface AppState { contextObs: Record @@ -82,6 +84,8 @@ const DEFAULT_UI: UIState = { taskOrg: 'project', showArchived: false, lastProjectId: null, + adminUsersOpen: false, + changePwOpen: false, } function loadUI(): UIState { diff --git a/src/styles.css b/src/styles.css index 9b91458..b465556 100644 --- a/src/styles.css +++ b/src/styles.css @@ -275,3 +275,49 @@ button { font-family: inherit; } .tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; } .tsb-acc-name { font-size: 13.5px; font-weight: 600; } .tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); } + +/* ---- 登录页与改密门(R2 管理员发放制) ---- */ +.lg-wrap { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--bg); z-index: 500; } +.lg-card { width: 380px; max-width: calc(100vw - 32px); background: var(--bg-panel); border: 1px solid var(--border); border-radius: 12px; padding: 28px 28px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.25); } +.lg-brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; margin-bottom: 18px; } +.lg-logo { width: 26px; height: 26px; border-radius: 8px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; } +.lg-title { margin: 0 0 4px; font-size: 19px; } +.lg-sub { margin: 0 0 16px; font-size: 12.5px; color: var(--text-faint); line-height: 1.6; } +.lg-tabs { display: flex; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; } +.lg-tab { flex: 1; height: 28px; border: none; border-radius: 6px; background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; } +.lg-tab.on { background: var(--bg-active); color: var(--text); font-weight: 500; } +.lg-label { display: block; font-size: 12px; font-weight: 500; color: var(--text-dim); margin: 10px 0 5px; } +.lg-input { width: 100%; height: 36px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 11px; font-size: 13.5px; } +.lg-input:focus { border-color: var(--accent); outline: none; } +.lg-mono { font-family: var(--mono); font-size: 12.5px; } +.lg-err { margin-top: 10px; font-size: 12.5px; color: var(--err); background: rgba(224,86,107,.08); border: 1px solid rgba(224,86,107,.3); border-radius: 8px; padding: 7px 10px; } +.lg-submit { width: 100%; height: 36px; margin-top: 14px; border: none; border-radius: 8px; background: var(--accent); color: #fff; font-weight: 600; font-size: 13.5px; cursor: pointer; } +.lg-submit:disabled { opacity: .55; cursor: default; } +.lg-submit:not(:disabled):hover { filter: brightness(1.08); } +.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; } +.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; } +.lg-hint { margin: 12px 0 0; font-size: 11.5px; color: var(--text-faint); line-height: 1.7; } +.lg-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; } + +/* ---- 用户管理 / 改密弹层 ---- */ +.ad-body { padding: 14px 18px 18px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; } +.ad-create { border: 1px solid var(--border); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-raised); } +.ad-row { display: flex; gap: 8px; align-items: center; } +.ad-row .lg-input { flex: 1; min-width: 0; height: 32px; } +.ad-check { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text-dim); white-space: nowrap; } +.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 32px; flex: none; } +.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: 6px; padding: 4px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; } +.ad-mini:hover { color: var(--text); border-color: var(--accent); } +.ad-hint { margin: 0; font-size: 11.5px; color: var(--text-faint); } +.ad-notice { font-size: 12.5px; color: var(--ok); background: rgba(65,178,110,.09); border: 1px solid rgba(65,178,110,.3); border-radius: 8px; padding: 8px 11px; } +.ad-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.ad-table th { text-align: left; font-weight: 500; color: var(--text-faint); font-size: 11.5px; padding: 6px 8px; border-bottom: 1px solid var(--border); } +.ad-table td { padding: 7px 8px; border-bottom: 1px solid var(--border); } +.ad-mono { font-family: var(--mono); font-size: 12px; } +.ad-dim { color: var(--text-faint); } +.ad-chip { font-size: 10px; border-radius: 4px; padding: 1px 6px; font-weight: 700; letter-spacing: .4px; background: var(--bg-hover); color: var(--text-faint); } +.ad-chip-admin { background: var(--accent-soft); color: var(--accent); } +.ad-chip-warn { background: rgba(217,155,63,.15); color: var(--warn); } +.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid var(--warn); border-radius: 10px; padding: 12px; font-size: 12.5px; } +.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; } +.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; } diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 1bda813..e4fba48 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/tasklist.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file