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:
+90
-195
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user