feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP

- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
+90 -195
View File
@@ -4,9 +4,12 @@
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,20 +22,15 @@ import {
AlertCircle,
DollarSign,
Eye,
FolderInput,
FolderOpen,
KeyRound,
Lock,
LockOpen,
MessagesSquare,
Pencil,
RefreshCw,
ScrollText,
Shield,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
@@ -40,14 +38,13 @@ import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
export function Users() {
const { t } = useTranslation("users");
const auth = useAuth();
@@ -57,7 +54,8 @@ export function Users() {
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const load = useCallback(
() =>
@@ -114,11 +112,7 @@ export function Users() {
{t("common:refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => setDialog({ kind: "add" })}
className="btn-primary"
>
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
<UserPlus className="w-4 h-4" /> {t("add")}
</button>
)}
@@ -209,11 +203,19 @@ export function Users() {
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{u.username.slice(0, 2)}
</span>
<span className="truncate text-sm font-medium text-fg-primary">
{u.username}
{(u.displayName || u.username).slice(0, 2)}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
)}
</div>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
)}
</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
@@ -244,37 +246,8 @@ export function Users() {
<UsageCell usage={u.last30d} />
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
@@ -285,6 +258,8 @@ export function Users() {
</table>
</div>
<AccountPoolCard isAdmin={isAdmin} />
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
@@ -310,9 +285,11 @@ export function Users() {
{formatDateTime(a.at)}
</span>
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
<span className="font-medium text-fg-primary">{a.reader}</span>
<span className="font-medium text-fg-primary">
{a.reader_display || a.reader}
</span>
<span className="mx-1.5 text-fg-muted">→</span>
{a.owner}
{a.owner_display || a.owner}
</span>
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
{a.method}
@@ -326,14 +303,25 @@ export function Users() {
)}
</section>
{dialog && (
<AccountDialog
dialog={dialog}
users={users ?? []}
onClose={() => setDialog(null)}
{adding && (
<AddUserDialog
onClose={() => setAdding(false)}
onDone={(message) => {
toast.success(message);
setDialog(null);
setAdding(false);
void load();
}}
/>
)}
{editing && (
<UserEditDialog
user={editing}
self={editing.id === me.id}
users={users ?? []}
onClose={() => setEditing(null)}
onDone={(message) => {
toast.success(message);
setEditing(null);
void load();
}}
/>
@@ -342,12 +330,6 @@ export function Users() {
);
}
const ROLE_BADGE: Record<Role, string> = {
admin: "bg-accent/15 text-accent border-accent/20",
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
developer: "bg-surface-4 text-fg-secondary border-border",
};
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
@@ -384,71 +366,31 @@ function RowAction({
);
}
/** One account change: its own fields, then the admin's password to confirm it. */
function AccountDialog({
dialog,
users,
/** A new account: username, password, role, then the admin's own password. */
function AddUserDialog({
onClose,
onDone,
}: {
dialog: Dialog;
users: ServerUserUsage[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const { kind, user } = dialog;
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "developer");
const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [role, setRole] = useState<Role>("developer");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (kind !== "workspace") return;
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, [kind]);
const ready =
current.length > 0 &&
(kind === "add"
? username.trim().length > 0 && password.length > 0
: kind === "password"
? password.length > 0
: kind === "workspace"
? laneId !== null
: kind === "role"
? role !== user?.role
: true);
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
if (kind === "add") {
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.username }));
} else if (kind === "role" && user) {
await api.users.update(user.id, { role }, current);
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
} else if ((kind === "disable" || kind === "enable") && user) {
await api.users.update(user.id, { disabled: kind === "disable" }, current);
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
} else if (kind === "password" && user) {
await api.users.setPassword(user.id, password, current);
onDone(t("done.password", { name }));
} else if (kind === "workspace" && user && laneId !== null) {
await api.users.assignWorkspace(user.id, laneId, current);
onDone(t("done.workspace", { name }));
}
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.displayName || r.user.username }));
} catch (e) {
const err = e as Error & { code?: string };
setError(
@@ -461,20 +403,6 @@ function AccountDialog({
}
};
const titles: Record<DialogKind, string> = {
add: t("dialog.add"),
role: t("dialog.role", { name }),
disable: t("dialog.disable", { name }),
enable: t("dialog.enable", { name }),
password: t("dialog.password", { name }),
workspace: t("dialog.workspace", { name }),
};
const messages: Partial<Record<DialogKind, string>> = {
disable: t("dialog.disableBody", { name }),
password: t("dialog.passwordBody", { name }),
workspace: t("dialog.workspaceBody", { name }),
};
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
@@ -486,81 +414,48 @@ function AccountDialog({
return (
<ConfirmModal
open
title={titles[kind]}
message={messages[kind]}
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
title={t("dialog.add")}
confirmLabel={t("add")}
cancelLabel={t("common:cancel")}
destructive={kind === "disable"}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
{kind === "add" && (
<Field label={t("username")} hint={t("usernameHint")}>
<input
className="input w-full text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
aria-label={t("username")}
/>
</Field>
)}
{(kind === "add" || kind === "password") && (
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-label={kind === "add" ? t("password") : t("newPassword")}
/>
</Field>
)}
{(kind === "add" || kind === "role") && (
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
aria-label={t("role")}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
)}
{kind === "workspace" && (
<Field label={t("workspace")}>
{lanes && lanes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
) : (
<select
className="input w-full text-sm"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("workspace")}
>
<option value="" />
{(lanes ?? []).map((l) => (
<option key={l.id} value={l.id}>
{(l.title || l.cwd) +
" — " +
(l.owner_id == null
? t("noOwner")
: t("ownedBy", { name: ownerName(l.owner_id) }))}
</option>
))}
</select>
)}
</Field>
)}
<Field label={t("username")} hint={t("usernameHint")}>
<input
className="input w-full text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
aria-label={t("username")}
/>
</Field>
<Field label={t("password")} hint={t("passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-label={t("password")}
/>
</Field>
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
aria-label={t("role")}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"