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:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user