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
@@ -0,0 +1,352 @@
/**
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LogOut, RefreshCw } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unknown) => {
const err = e as Error & { code?: string };
return err.code && t(`users:errors.${err.code}`, { defaultValue: "" })
? t(`users:errors.${err.code}`)
: err.message;
};
export function AccountSettings() {
const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { updateUser } = useAuth();
const apply = useCallback(
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the display name: let it follow a save at once.
updateUser({ displayName: r.user.displayName, email: r.user.email });
},
[updateUser]
);
const load = useCallback(() => {
setLoadError(null);
api.me
.account()
.then(apply)
.catch((e: Error) => setLoadError(e.message));
}, [apply]);
useEffect(load, [load]);
if (loadError) {
return (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
</button>
</div>
);
}
if (!account) {
return <div className="card h-40 animate-pulse" data-testid="account-loading" />;
}
return (
<div className="space-y-6">
<ProfileCard account={account} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
</div>
);
}
function ProfileCard({ account }: { account: ServerAccount }) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<span
aria-hidden
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@{user.username}
{user.email ? ` · ${user.email}` : ""}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
{t(`users:roles.${user.role}`)}
</span>
<span className="text-[11px] text-fg-muted">{t(`users:roleHint.${user.role}`)}</span>
</div>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
<Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
</Stat>
<Stat label={t("account.workspaces")}>{account.workspaces}</Stat>
<Stat label={t("account.sessions")}>{account.sessions}</Stat>
<Stat label={t("account.logins")}>{account.logins}</Stat>
<Stat label={t("account.lastLogin")}>
{account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}
</Stat>
</dl>
<p className="mt-3 text-[11px] text-fg-muted">{t("account.roleNote")}</p>
</section>
);
}
function ProfileForm({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const [displayName, setDisplayName] = useState(account.user.displayName ?? "");
const [email, setEmail] = useState(account.user.email ?? "");
const [busy, setBusy] = useState(false);
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
const body: { displayName?: string | null; email?: string | null } = {};
if (norm(displayName) !== (account.user.displayName ?? null))
body.displayName = norm(displayName);
if (norm(email) !== (account.user.email ?? null)) body.email = norm(email);
const changed = Object.keys(body).length > 0;
const save = async () => {
if (!changed || busy) return;
setBusy(true);
try {
onSaved(await api.me.updateAccount(body));
toast.success(t("account.profileSaved"));
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void save();
}
};
return (
<SettingsSection
id="account-profile"
title={t("account.profile")}
description={t("account.profileDescription")}
>
<div className="grid gap-3 sm:grid-cols-2" onKeyDown={onKeyDown}>
<Field label={t("users:displayName")}>
<input
className="input w-full text-sm"
value={displayName}
maxLength={100}
onChange={(e) => setDisplayName(e.target.value)}
aria-label={t("users:displayName")}
/>
</Field>
<Field label={t("users:email")}>
<input
type="email"
className="input w-full text-sm"
value={email}
maxLength={254}
onChange={(e) => setEmail(e.target.value)}
aria-label={t("users:email")}
/>
</Field>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!changed || busy}
onClick={() => void save()}
>
{t("account.save")}
</button>
</div>
</SettingsSection>
);
}
function PasswordForm() {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
setCurrent("");
setNext("");
setAgain("");
toast.success(t("account.passwordChanged"));
} catch (e) {
setError(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<SettingsSection
id="account-password"
title={t("account.password")}
description={t("account.passwordDescription")}
>
<div className="grid gap-3 sm:grid-cols-3" onKeyDown={onKeyDown}>
<Field label={t("account.currentPassword")}>
<input
type="password"
className="input w-full text-sm"
value={current}
autoComplete="current-password"
onChange={(e) => setCurrent(e.target.value)}
aria-label={t("account.currentPassword")}
/>
</Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={next}
autoComplete="new-password"
onChange={(e) => setNext(e.target.value)}
aria-label={t("account.newPassword")}
/>
</Field>
<Field label={t("account.confirmPassword")}>
<input
type="password"
className="input w-full text-sm"
value={again}
autoComplete="new-password"
onChange={(e) => setAgain(e.target.value)}
aria-label={t("account.confirmPassword")}
/>
</Field>
</div>
{(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")}
</p>
)}
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!ready}
onClick={() => void submit()}
>
{t("account.changePassword")}
</button>
</div>
</SettingsSection>
);
}
function SignOutOthers({ logins, onDone }: { logins: number; onDone: () => void }) {
const { t } = useTranslation("settings");
const [busy, setBusy] = useState(false);
const run = async () => {
const ok = await confirmDialog({
title: t("account.signOutOthersConfirm"),
message: t("account.signOutOthersBody"),
confirmLabel: t("account.signOutOthers"),
cancelLabel: t("common:cancel"),
destructive: true,
});
if (!ok) return;
setBusy(true);
try {
const r = await api.me.signOutOthers();
toast.success(t("account.signedOut", { count: r.ended }));
onDone();
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
return (
<SettingsSection
id="account-sessions"
title={t("account.sessionsTitle")}
description={t("account.sessionsDescription", { n: logins })}
>
<button
type="button"
className="btn-ghost text-status-danger disabled:opacity-50"
disabled={busy || logins <= 1}
onClick={() => void run()}
>
<LogOut className="h-4 w-4" /> {t("account.signOutOthers")}
</button>
</SettingsSection>
);
}
function Stat({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-[11px] text-fg-muted">{label}</dt>
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -0,0 +1,108 @@
/**
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const me = vi.hoisted(() => ({
account: vi.fn(),
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { me } };
});
const confirm = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
import { AccountSettings } from "../AccountSettings";
const account = {
mode: "server" as const,
user: {
id: 2,
username: "mia",
role: "developer" as const,
disabled: false,
displayName: "Mia Tran",
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
workspaces: 3,
sessions: 12,
logins: 2,
lastLoginAt: null,
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
me.signOutOthers.mockResolvedValue({ ended: 1 });
confirm.mockReset().mockResolvedValue(true);
});
describe("AccountSettings", () => {
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
expect(card.textContent).toContain("Mia Tran");
expect(card.textContent).toContain("@mia");
expect(card.textContent).toContain("Developer");
expect(card.textContent).toContain("12");
});
it("saves only a changed field", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
});
it("refuses two different new passwords", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
const button = screen.getByRole("button", { name: "Change password" });
expect(button.hasAttribute("disabled")).toBe(true);
expect(me.changePassword).not.toHaveBeenCalled();
});
it("changes the password with the current one", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
await ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
);
});
it("asks before signing the other devices out", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
});
});