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:
@@ -6,7 +6,8 @@
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
|
||||
* `updateUser` (Settings → Account's saved display name shows at once);
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
@@ -16,6 +17,7 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
type AuthValue = {
|
||||
state: AuthState;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (patch: Partial<AuthUser>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||
const AuthContext = createContext<AuthValue>({
|
||||
state: { mode: "local" },
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
});
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -134,6 +144,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
// Stable, so a page may list it as an effect dependency.
|
||||
const updateUser = useCallback(
|
||||
(patch: Partial<AuthUser>) =>
|
||||
setState((s) =>
|
||||
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
@@ -142,7 +161,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type Ref } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -23,15 +23,12 @@ export function PathPickButton({
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
buttonRef,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
/** Lets a field beside it open the same picker (a click on an empty path). */
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -55,7 +52,6 @@ export function PathPickButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
|
||||
@@ -552,7 +552,7 @@ export function Sidebar({
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${t("common:auth.signedInAs", {
|
||||
name: auth.state.user.username,
|
||||
name: auth.state.user.displayName || auth.state.user.username,
|
||||
role: auth.state.user.role,
|
||||
})} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
|
||||
* else `owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
@@ -60,6 +61,9 @@ const DestructiveLaneModal = lazy(() =>
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
/** An owner as the tree shows it: their display name, else their username. */
|
||||
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
|
||||
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -150,7 +154,7 @@ export function WorkspaceTree() {
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -338,8 +342,8 @@ export function WorkspaceTree() {
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
@@ -366,7 +370,7 @@ export function WorkspaceTree() {
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{lane.owner_name?.[0] ?? "?"}
|
||||
{ownerShown(lane)[0] ?? "?"}
|
||||
</span>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("PathPickButton", () => {
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false);
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
|
||||
@@ -30,7 +30,7 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
lane(1, 3, "bob", "Bob app"),
|
||||
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
@@ -113,6 +113,14 @@ describe("WorkspaceTree for an analyst", () => {
|
||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||
});
|
||||
|
||||
it("names an owner by their display name when they set one", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
||||
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
||||
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("folds a group and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
|
||||
@@ -951,9 +951,16 @@ function RunningStep({
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
/** An option's label carries the model's " (Recommended)" marker into the
|
||||
* answer the CLI records; the user's pick reads without it (every picked
|
||||
* label of a multi-select answer too). */
|
||||
function stripRecommended(answer: string | undefined): string | undefined {
|
||||
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
|
||||
}
|
||||
|
||||
/** An AskUserQuestion: each question with the answer the user picked or
|
||||
* typed (the result's `answers`), "declined" when they refused it, no answer
|
||||
* yet while the CLI still asks. */
|
||||
* typed (the result's `answers`, minus the " (Recommended)" marker),
|
||||
* "declined" when they refused it, no answer yet while the CLI still asks. */
|
||||
function AskCard({
|
||||
block,
|
||||
result,
|
||||
@@ -972,7 +979,7 @@ function AskCard({
|
||||
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
{questions.map((q, i) => {
|
||||
const answer = result?.answers?.[q];
|
||||
const answer = stripRecommended(result?.answers?.[q]);
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file MessageList.ask.test.tsx
|
||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||
* line: the chat shows it as its own card — each question with the answer the
|
||||
* user picked or typed, "declined" when they refused it, nothing yet while the
|
||||
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
|
||||
* CLI still waits.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -67,6 +67,25 @@ describe("an AskUserQuestion card", () => {
|
||||
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the (Recommended) marker from a picked answer", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Tools?"]),
|
||||
result("q1", {
|
||||
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("Lint, Test")).toBeTruthy();
|
||||
expect(within(card).queryByText(/Recommended/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says declined when refused, and shows no answer while still asked", () => {
|
||||
const { rerender } = render(
|
||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||
|
||||
@@ -328,7 +328,6 @@ export function AddLaneModal({
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const browseRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
@@ -461,14 +460,9 @@ export function AddLaneModal({
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path opens the picker, as Browse does (the
|
||||
OS's own where it can be); typing still works from the keyboard. */}
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{/* A click into the path only focuses it — the picker opens from
|
||||
Browse alone (#77). */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
@@ -476,12 +470,7 @@ export function AddLaneModal({
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton
|
||||
start={sourceRepo}
|
||||
onPick={setSourceRepo}
|
||||
showLabel
|
||||
buttonRef={browseRef}
|
||||
/>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||
* Backspace goes up and typing jumps to a name.
|
||||
* An admin with a Linux account of their own (S3; the listing carries
|
||||
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
|
||||
* browser to the host account's home, where a workspace runs as the shared
|
||||
* host account. It is never remembered: every open starts at the admin's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Server,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -127,22 +132,26 @@ function BrowseDialog({
|
||||
// parent) — breadcrumbs above it are not links.
|
||||
const [floor, setFloor] = useState<string | null>(null);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
// An admin's "Server folder" tick (S3): browse the host account's home.
|
||||
const [hostHome, setHostHome] = useState<string | null>(null);
|
||||
const [onHost, setOnHost] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const crumbRef = useRef<HTMLDivElement>(null);
|
||||
const typeAhead = useRef({ text: "", at: 0 });
|
||||
|
||||
const load = async (
|
||||
target?: string,
|
||||
{ push = true, hidden = showHidden, select = null as string | null } = {}
|
||||
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden);
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||
setListing(next);
|
||||
setSelected(select);
|
||||
setFilter("");
|
||||
setEditing(false);
|
||||
if (next.home) setHome(next.home);
|
||||
setHostHome(next.hostHome ?? null);
|
||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||
if (push) {
|
||||
setHistory((h) => {
|
||||
@@ -162,7 +171,8 @@ function BrowseDialog({
|
||||
if (!open) return;
|
||||
setHistory({ stack: [], at: -1 });
|
||||
setCreating(false);
|
||||
void load(initialPath);
|
||||
setOnHost(false);
|
||||
void load(initialPath, { host: false });
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -315,6 +325,13 @@ function BrowseDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Switch between the admin's own home and the host account's, and open it.
|
||||
const toggleHost = () => {
|
||||
const next = !onHost;
|
||||
setOnHost(next);
|
||||
void load(undefined, { host: next });
|
||||
};
|
||||
|
||||
const toggleHidden = () => {
|
||||
const next = !showHidden;
|
||||
setShowHidden(next);
|
||||
@@ -659,6 +676,21 @@ function BrowseDialog({
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t("browse.showHidden")}
|
||||
</label>
|
||||
{hostHome && (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||||
title={t("browse.hostFolderHint", { path: hostHome })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onHost}
|
||||
onChange={toggleHost}
|
||||
data-testid="browse-host-folder"
|
||||
/>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{t("browse.hostFolder")}
|
||||
</label>
|
||||
)}
|
||||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||
{t("browse.hint")}
|
||||
</span>
|
||||
|
||||
@@ -584,17 +584,15 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the picker on every click into the path field", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
const field = document.getElementById("add-lane-repo")!;
|
||||
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
||||
await user.click(field);
|
||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
||||
expect(field).toHaveFocus();
|
||||
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
@@ -630,7 +628,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false);
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
|
||||
@@ -72,6 +72,32 @@ const option = (dialog: HTMLElement, name: string) =>
|
||||
within(dialog).findByRole("option", { name: new RegExp(name) });
|
||||
|
||||
describe("FolderBrowseModal", () => {
|
||||
it("opens an admin in their own home; the Server folder tick opens the host's", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.browse).mockImplementation(async (p, _f, _h, host) => ({
|
||||
...listing(p || (host ? "/home/u" : "/srv/ws"), false),
|
||||
home: host ? "/home/u" : "/srv/ws",
|
||||
hostHome: "/home/u",
|
||||
}));
|
||||
const dialog = await open();
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, false)
|
||||
);
|
||||
const tick = within(dialog).getByTestId("browse-host-folder");
|
||||
expect(tick).not.toBeChecked();
|
||||
await user.click(tick);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, true)
|
||||
);
|
||||
expect(tick).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows no Server folder tick without a host home in the listing", async () => {
|
||||
const dialog = await open();
|
||||
expect((await within(dialog).findAllByRole("option")).length).toBeGreaterThan(0);
|
||||
expect(within(dialog).queryByTestId("browse-host-folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
@@ -105,11 +131,11 @@ describe("FolderBrowseModal", () => {
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false)
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false)
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false, false)
|
||||
);
|
||||
// Above the user's own folder (it answered no parent) nothing is a link.
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
@@ -129,7 +155,9 @@ describe("FolderBrowseModal", () => {
|
||||
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj");
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
|
||||
await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
await userEvent.keyboard("{Control>}l{/Control}");
|
||||
@@ -137,7 +165,7 @@ describe("FolderBrowseModal", () => {
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false)
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false, false)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
|
||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
@@ -106,6 +107,14 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||
// only because this session's own Read named it.
|
||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||
} else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
|
||||
// A text file it read or wrote elsewhere (a report under ~/Documents):
|
||||
// served only because this session's own Read/Write/Edit named it.
|
||||
show(
|
||||
api.sessions
|
||||
.readFile(scope.sessionId, path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
|
||||
);
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s
|
||||
* textarea fallback. While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
@@ -59,6 +60,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
@@ -85,9 +87,11 @@ const clipboardProvider: IClipboardProvider = {
|
||||
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||
// clipboard. An empty write (a "clear") is ignored.
|
||||
writeText: (_selection, text) => {
|
||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
// Silent and best effort: no toast per copy. On a non-secure origin
|
||||
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
|
||||
// textarea copy, which the browser allows while the drag that made the
|
||||
// selection still counts as the user's gesture.
|
||||
if (text) void writeClipboard(text);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||
* drop route, an image read outside the folder through the session's image
|
||||
* route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* route, a text file read or written outside it through the session's file route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||
* each skill's SKILL.md by name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -31,7 +31,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
p.endsWith(".md")
|
||||
? Promise.resolve({ text: "# Audit report", truncated: false })
|
||||
: Promise.reject(new Error("404"))
|
||||
),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({
|
||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||
@@ -110,9 +117,16 @@ describe("FilePreview", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
it("reads a text file it wrote outside the folder through the session", async () => {
|
||||
const report = "/home/a/Documents/audit/REPORT_ssh_ec2.md";
|
||||
render(<FilePreview path={report} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Audit report")).toBeTruthy();
|
||||
expect(vi.mocked(api.sessions.readFile)).toHaveBeenCalledWith("s1", report);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", async () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
expect(await screen.findByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}),
|
||||
readFile: r({ text: "", truncated: false }),
|
||||
},
|
||||
lanes: {
|
||||
gitFile: r({
|
||||
@@ -355,7 +356,9 @@ describe("PaneChat — the activity panel", () => {
|
||||
);
|
||||
const first = render(pane());
|
||||
fireEvent.click(
|
||||
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
|
||||
within(await screen.findByTestId("chat-outline")).getByRole("button", {
|
||||
name: "Hide side panel",
|
||||
})
|
||||
);
|
||||
first.unmount();
|
||||
render(pane());
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back.
|
||||
* @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 type { TFunction } from "i18next";
|
||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
} from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
refreshError: string | null;
|
||||
addedAt: string;
|
||||
expired: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`pool.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.accountPool
|
||||
.list(force)
|
||||
.then((r) => {
|
||||
setPool(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(() => load(true));
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("pool.refresh")}
|
||||
aria-label={t("pool.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{removing && (
|
||||
<RemoveAccountDialog
|
||||
row={removing}
|
||||
onClose={() => setRemoving(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.done.removed", { name: removing.name }));
|
||||
setRemoving(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
|
||||
function AddAccountDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>("claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// The secret box is a textarea for pasted JSON: Enter there is a newline.
|
||||
if ((e.target as HTMLElement).tagName !== "TEXTAREA" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
aria-label={t("pool.profile")}
|
||||
>
|
||||
<option value="">
|
||||
{profiles === null
|
||||
? t("pool.loadingProfiles")
|
||||
: profiles.length === 0
|
||||
? t("pool.noProfiles")
|
||||
: t("pool.chooseProfile")}
|
||||
</option>
|
||||
{profiles?.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.label}
|
||||
{p.email ? ` — ${p.email}` : ""}
|
||||
{p.active ? ` (${t("pool.activeHere")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t("pool.name")}
|
||||
hint={fromProfile ? t("pool.nameFromProfile") : t("pool.nameHint")}
|
||||
>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.name")}
|
||||
/>
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="input w-full h-24 font-mono text-xs"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAccountDialog({
|
||||
row,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
row: Row;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.remove(row.tool, row.name, current);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.remove", { name: row.name })}
|
||||
message={t("pool.removeHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
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,512 @@
|
||||
/**
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
* refusal is shown inside the dialog, which stays open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export 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 random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
|
||||
export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
const [displayName, setDisplayName] = useState(user.displayName ?? "");
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [removed, setRemoved] = useState<Set<number>>(new Set());
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.get(user.id)
|
||||
.then((r) => setOwned(r.workspaces))
|
||||
.catch(() => setOwned([]));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [user.id]);
|
||||
|
||||
const ownerName = (id: number) => {
|
||||
const u = users.find((x) => x.id === id);
|
||||
return u ? u.displayName || u.username : `#${id}`;
|
||||
};
|
||||
const ownedIds = useMemo(() => new Set((owned ?? []).map((w) => w.id)), [owned]);
|
||||
const addable = (lanes ?? []).filter((l) => !ownedIds.has(l.id) && !added.includes(l.id));
|
||||
const laneLabel = (id: number) => {
|
||||
const l = (lanes ?? []).find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd : `#${id}`;
|
||||
};
|
||||
|
||||
const patch: UserPatch = {};
|
||||
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
|
||||
if (norm(displayName) !== (user.displayName ?? null)) patch.displayName = norm(displayName);
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.update(user.id, patch, current);
|
||||
onDone(t("done.updated", { name: user.displayName || user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
const shown = user.displayName || user.username;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-edit-title"
|
||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||
{t("edit.title", { name: user.displayName || user.username })}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
|
||||
{t(`roles.${user.role}`)}
|
||||
</span>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{user.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common:close")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
||||
<dl
|
||||
className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-surface-2 p-3 text-xs sm:grid-cols-3"
|
||||
data-testid="user-edit-info"
|
||||
>
|
||||
<Info label={t("edit.created")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
</Info>
|
||||
<Info label={t("edit.lastLogin")}>
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")}
|
||||
</Info>
|
||||
<Info label={t("edit.logins")}>{user.logins ?? 0}</Info>
|
||||
<Info label={t("workspaces")}>{user.workspaces}</Info>
|
||||
<Info label={t("sessions")}>{user.sessions}</Info>
|
||||
<Info label={t("cost30d")}>{fmtCost(user.last30d.cost)}</Info>
|
||||
</dl>
|
||||
|
||||
<Section title={t("edit.profile")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("displayName")} hint={t("displayNameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={100}
|
||||
aria-label={t("displayName")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("email")}>
|
||||
<input
|
||||
type="email"
|
||||
className="input w-full text-sm"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={254}
|
||||
autoComplete="off"
|
||||
aria-label={t("email")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.access")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("role")} hint={self ? t("edit.selfRole") : t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
disabled={self}
|
||||
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>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("edit.status")}</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={active}
|
||||
onChange={setActive}
|
||||
disabled={self}
|
||||
label={t("edit.active")}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{active ? t("edit.active") : t("disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
||||
{self ? t("edit.selfDisable") : t("edit.statusHint")}
|
||||
</span>
|
||||
{!active && !user.disabled && (
|
||||
<p className="mt-1 text-[11px] text-status-danger">
|
||||
{t("dialog.disableBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="input w-full font-mono text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="new-password"
|
||||
aria-label={t("newPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
title={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
disabled={!password}
|
||||
onClick={() => void copyText(password)}
|
||||
aria-label={t("edit.copy")}
|
||||
title={t("edit.copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> {t("edit.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
{owned === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("edit.loading")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="user-edit-workspaces">
|
||||
{owned.length === 0 && added.length === 0 && (
|
||||
<li className="text-xs text-fg-muted">{t("edit.noOwned")}</li>
|
||||
)}
|
||||
{owned.map((w) => {
|
||||
const gone = removed.has(w.id);
|
||||
return (
|
||||
<li
|
||||
key={w.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${gone ? "text-fg-muted line-through" : "text-fg-primary"}`}
|
||||
title={w.cwd}
|
||||
>
|
||||
{w.title || w.cwd}
|
||||
</span>
|
||||
{gone ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
onClick={() =>
|
||||
setRemoved((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(w.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("edit.undo")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.removeWorkspace", { name: w.title || w.cwd })}
|
||||
title={t("edit.removeWorkspaceHint")}
|
||||
onClick={() => setRemoved((s) => new Set(s).add(w.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{added.map((id) => (
|
||||
<li
|
||||
key={`add-${id}`}
|
||||
className="flex items-center gap-2 rounded-md border border-accent/30 bg-accent/5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{laneLabel(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.undo")}
|
||||
onClick={() => setAdded((a) => a.filter((x) => x !== id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{addable.length > 0 && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={pick}
|
||||
onChange={(e) => setPick(e.target.value)}
|
||||
aria-label={t("edit.addWorkspace")}
|
||||
>
|
||||
<option value="">{t("edit.chooseWorkspace")}</option>
|
||||
{addable.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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
disabled={!pick}
|
||||
onClick={() => {
|
||||
setAdded((a) => [...a, Number(pick)]);
|
||||
setPick("");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t("edit.addWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(added.length > 0 || removed.size > 0) && (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">
|
||||
{t("dialog.workspaceBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
onChange={setSignOut}
|
||||
tone="warning"
|
||||
label={t("edit.signOut")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">
|
||||
{t("edit.signOutHint", { n: user.logins ?? 0 })}
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!changed && (
|
||||
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t("common:cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={!ready}
|
||||
aria-busy={busy}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ 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,155 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: lists the Claude and Codex
|
||||
* accounts with their live panes and quota, says what an empty pool means, an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control.
|
||||
* @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";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
name: "team-1",
|
||||
kind: "login",
|
||||
email: "team1@example.com",
|
||||
refreshError: null,
|
||||
addedAt: "2026-10-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
expired: false,
|
||||
livePanes: 2,
|
||||
spend5h: 1.5,
|
||||
spend7d: 12,
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
name: "cx-1",
|
||||
kind: "apikey",
|
||||
addedAt: "2026-10-02T00:00:00Z",
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
blockPercent: 90,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "work",
|
||||
label: "Work",
|
||||
email: "w@example.com",
|
||||
subscriptionType: "max",
|
||||
active: false,
|
||||
},
|
||||
{ name: "me", label: "Me", email: "me@example.com", subscriptionType: "pro", active: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
expect(screen.getByRole("option", { name: /Me/ })).toBeEnabled();
|
||||
await user.selectOptions(select, "work");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file UserEditDialog.test.tsx
|
||||
* @description The admin's account editor: Save stays off until something
|
||||
* changed AND the admin typed their own password; one PATCH carries only the
|
||||
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
||||
* admin editing their own row cannot touch role or state; Generate fills a
|
||||
* 16-character password.
|
||||
* @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";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { UserEditDialog, generatePassword } from "../UserEditDialog";
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
const mia: ServerUserUsage = {
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
displayName: null,
|
||||
email: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
workspaces: 1,
|
||||
sessions: 3,
|
||||
last7d: usage,
|
||||
last30d: usage,
|
||||
logins: 2,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
|
||||
function open(user = mia, self = false) {
|
||||
const onDone = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UserEditDialog user={user} self={self} users={[mia]} onClose={onClose} onDone={onDone} />
|
||||
);
|
||||
return { onDone, onClose };
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Save" });
|
||||
|
||||
beforeEach(() => {
|
||||
users.get.mockReset().mockResolvedValue({
|
||||
user: mia,
|
||||
workspaces: [{ id: 5, title: "api", cwd: "/w/api" }],
|
||||
});
|
||||
users.update.mockReset().mockResolvedValue({ user: mia });
|
||||
lanes.list.mockReset().mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 5, title: "api", cwd: "/w/api", owner_id: 2 },
|
||||
{ id: 7, title: "web", cwd: "/w/web", owner_id: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog", () => {
|
||||
it("needs a change and the admin's own password before Save", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
expect(save().hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
||||
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends one PATCH with only what changed", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
||||
await ue.selectOptions(screen.getByLabelText("Role"), "analyst");
|
||||
await ue.click(screen.getByRole("switch", { name: "Active" }));
|
||||
await ue.click(screen.getByRole("button", { name: "Remove api" }));
|
||||
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
||||
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(save());
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
2,
|
||||
{
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(onDone).toHaveBeenCalledWith("mia updated");
|
||||
});
|
||||
|
||||
it("never lets an admin change their own role or disable themselves", async () => {
|
||||
open({ ...mia, role: "admin" }, true);
|
||||
await screen.findByText("api");
|
||||
expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a short new password: there is no minimum length", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("New password"), "abc");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("generates a 16-character password", async () => {
|
||||
expect(generatePassword()).toHaveLength(16);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.click(screen.getByRole("button", { name: /Generate/ }));
|
||||
expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@
|
||||
"browse.recent": "Recent",
|
||||
"browse.selectFile": "Select file",
|
||||
"browse.showHidden": "Show hidden",
|
||||
"browse.hostFolder": "Server folder",
|
||||
"browse.hostFolderHint": "Browse the server account's own folder ({{path}}). A workspace there runs as the shared server account, which every admin uses — not as your own.",
|
||||
"browse.up": "Up one level (Backspace)",
|
||||
"browse.workspaces": "Workspaces",
|
||||
"browse.title": "Browse for a folder",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"title": "AI Proxy",
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"config9router": "Config 9router",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
@@ -820,6 +821,10 @@
|
||||
"data": "Data"
|
||||
},
|
||||
"section": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Your profile, password and sign-ins on this server."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Language, colour mode and theme, and the corner pet."
|
||||
@@ -1044,5 +1049,33 @@
|
||||
"chooseFile": "Choose file",
|
||||
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
|
||||
"appliesTo": "Applies to"
|
||||
},
|
||||
"account": {
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"logins": "Active sign-ins",
|
||||
"lastLogin": "Last sign-in",
|
||||
"roleNote": "Your role and access are set by an admin on the Users page.",
|
||||
"profile": "Profile",
|
||||
"profileDescription": "How your name appears to others on this server.",
|
||||
"save": "Save",
|
||||
"profileSaved": "Profile saved",
|
||||
"password": "Password",
|
||||
"passwordDescription": "Changing it keeps this browser signed in and signs you out everywhere else.",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ.",
|
||||
"changePassword": "Change password",
|
||||
"passwordChanged": "Password changed",
|
||||
"sessionsTitle": "Sign-ins",
|
||||
"sessionsDescription": "You are signed in on {{n}} browser(s), this one included.",
|
||||
"signOutOthers": "Sign out other devices",
|
||||
"signOutOthersConfirm": "Sign out every other device?",
|
||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||
"signedOut_one": "Signed out {{count}} other device",
|
||||
"signedOut_other": "Signed out {{count}} other devices"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"disabled": "{{name}} disabled",
|
||||
"enabled": "{{name}} enabled",
|
||||
"password": "{{name}}'s password reset",
|
||||
"workspace": "Workspace given to {{name}}"
|
||||
"workspace": "Workspace given to {{name}}",
|
||||
"updated": "{{name}} updated"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
@@ -64,12 +65,127 @@
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
"EEXISTS": "That username is taken.",
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password cannot be empty."
|
||||
"EBADPASSWORD": "Password cannot be empty.",
|
||||
"EBADEMAIL": "That email address does not look valid.",
|
||||
"EBADFIELD": "One of the values is not valid.",
|
||||
"ENOLANE": "That workspace no longer exists.",
|
||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||
"EBADROLE": "Unknown role.",
|
||||
"ELOCALMODE": "Accounts are managed in server mode only."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||
"auditEmpty": "Nothing recorded yet.",
|
||||
"activeCount": "{{n}} active",
|
||||
"cost30d": "Cost · 30 days"
|
||||
"cost30d": "Cost · 30 days",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "Shown beside the username. Optional.",
|
||||
"email": "Email",
|
||||
"edit": {
|
||||
"button": "Edit user",
|
||||
"title": "Edit {{name}}",
|
||||
"created": "Created",
|
||||
"lastLogin": "Last sign-in",
|
||||
"never": "Never",
|
||||
"logins": "Active sign-ins",
|
||||
"profile": "Profile",
|
||||
"access": "Access",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"copy": "Copy password",
|
||||
"generate": "Generate",
|
||||
"loading": "Loading…",
|
||||
"noOwned": "Owns no workspace.",
|
||||
"undo": "Undo",
|
||||
"removeWorkspace": "Remove {{name}}",
|
||||
"removeWorkspaceHint": "The workspace is left with no owner",
|
||||
"addWorkspace": "Add workspace",
|
||||
"chooseWorkspace": "Choose a workspace…",
|
||||
"sessions": "Sign-ins",
|
||||
"signOut": "Sign out everywhere",
|
||||
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||
"noChanges": "No changes yet."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||
"account": "Account",
|
||||
"tool": "Tool",
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
"spend7d": "{{cost}} in 7 days",
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Name",
|
||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||
"remove": "Remove {{name}}",
|
||||
"removeHint": "New sessions stop using this account. Sessions already running on it keep running.",
|
||||
"removeButton": "Remove",
|
||||
"done": {
|
||||
"added": "{{name}} added to the pool",
|
||||
"removed": "{{name}} removed from the pool"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"EBADNAME": "Name: 1–64 letters, digits, '.', '_' or '-'.",
|
||||
"EBADTOKEN": "That is not a valid token for this tool.",
|
||||
"EEXISTS": "An account with that name is already in the pool.",
|
||||
"ENOACCOUNT": "That account is no longer in the pool.",
|
||||
"ELOCALMODE": "In local mode the pool is changed from the host's shell (agenthub accounts).",
|
||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||
"ENOTFOUND": "That saved login no longer exists.",
|
||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
"profile": "A saved login (Switch account profiles)",
|
||||
"credentials": "Paste .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||
"chooseProfile": "Choose a saved login…",
|
||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||
"loadingProfiles": "Loading…",
|
||||
"activeHere": "in use on this server — keeps working",
|
||||
"nameFromProfile": "Leave blank to use the profile's name",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key or auth.json"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"refreshFailed": "refresh failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"browse.recent": "Gần đây",
|
||||
"browse.selectFile": "Chọn tệp",
|
||||
"browse.showHidden": "Hiện mục ẩn",
|
||||
"browse.hostFolder": "Thư mục máy chủ",
|
||||
"browse.hostFolderHint": "Duyệt thư mục của tài khoản máy chủ ({{path}}). Workspace ở đó chạy bằng tài khoản máy chủ dùng chung cho mọi admin — không phải tài khoản riêng của bạn.",
|
||||
"browse.up": "Lên một cấp (Backspace)",
|
||||
"browse.workspaces": "Workspace",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
|
||||
@@ -626,6 +626,7 @@
|
||||
"title": "AI Proxy",
|
||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||
"add": "Thêm profile",
|
||||
"config9router": "Config 9router",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
@@ -820,6 +821,10 @@
|
||||
"data": "Dữ liệu"
|
||||
},
|
||||
"section": {
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"description": "Hồ sơ, mật khẩu và các phiên đăng nhập của bạn trên server này."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||
@@ -1044,5 +1049,33 @@
|
||||
"chooseFile": "Chọn file",
|
||||
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
|
||||
"appliesTo": "Áp dụng cho"
|
||||
},
|
||||
"account": {
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Session",
|
||||
"logins": "Phiên đăng nhập",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"roleNote": "Vai trò và quyền truy cập của bạn do admin đặt ở trang Người dùng.",
|
||||
"profile": "Hồ sơ",
|
||||
"profileDescription": "Tên của bạn hiển thị với người khác trên server này.",
|
||||
"save": "Lưu",
|
||||
"profileSaved": "Đã lưu hồ sơ",
|
||||
"password": "Mật khẩu",
|
||||
"passwordDescription": "Đổi mật khẩu giữ trình duyệt này đăng nhập và đăng xuất bạn ở mọi nơi khác.",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||
"mismatch": "Hai mật khẩu mới không khớp.",
|
||||
"changePassword": "Đổi mật khẩu",
|
||||
"passwordChanged": "Đã đổi mật khẩu",
|
||||
"sessionsTitle": "Phiên đăng nhập",
|
||||
"sessionsDescription": "Bạn đang đăng nhập trên {{n}} trình duyệt, tính cả trình duyệt này.",
|
||||
"signOutOthers": "Đăng xuất các thiết bị khác",
|
||||
"signOutOthersConfirm": "Đăng xuất mọi thiết bị khác?",
|
||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"disabled": "Đã khoá {{name}}",
|
||||
"enabled": "Đã mở khoá {{name}}",
|
||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||
"workspace": "Đã giao workspace cho {{name}}"
|
||||
"workspace": "Đã giao workspace cho {{name}}",
|
||||
"updated": "Đã cập nhật {{name}}"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
@@ -64,12 +65,127 @@
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống."
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||
"EBADROLE": "Vai trò không hợp lệ.",
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||
"auditEmpty": "Chưa ghi nhận gì.",
|
||||
"activeCount": "{{n}} đang hoạt động",
|
||||
"cost30d": "Chi phí · 30 ngày"
|
||||
"cost30d": "Chi phí · 30 ngày",
|
||||
"displayName": "Tên hiển thị",
|
||||
"displayNameHint": "Hiện cạnh tên đăng nhập. Không bắt buộc.",
|
||||
"email": "Email",
|
||||
"edit": {
|
||||
"button": "Sửa người dùng",
|
||||
"title": "Sửa {{name}}",
|
||||
"created": "Ngày tạo",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"never": "Chưa bao giờ",
|
||||
"logins": "Phiên đăng nhập",
|
||||
"profile": "Hồ sơ",
|
||||
"access": "Quyền truy cập",
|
||||
"status": "Trạng thái",
|
||||
"active": "Đang hoạt động",
|
||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||
"show": "Hiện mật khẩu",
|
||||
"hide": "Ẩn mật khẩu",
|
||||
"copy": "Sao chép mật khẩu",
|
||||
"generate": "Tạo ngẫu nhiên",
|
||||
"loading": "Đang tải…",
|
||||
"noOwned": "Chưa sở hữu workspace nào.",
|
||||
"undo": "Hoàn tác",
|
||||
"removeWorkspace": "Bỏ {{name}}",
|
||||
"removeWorkspaceHint": "Workspace sẽ không còn chủ sở hữu",
|
||||
"addWorkspace": "Thêm workspace",
|
||||
"chooseWorkspace": "Chọn một workspace…",
|
||||
"sessions": "Phiên đăng nhập",
|
||||
"signOut": "Đăng xuất ở mọi nơi",
|
||||
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||
"noChanges": "Chưa có thay đổi."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||
"account": "Tài khoản",
|
||||
"tool": "Công cụ",
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
"spend7d": "{{cost}} trong 7 ngày",
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Tên",
|
||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||
"remove": "Xoá {{name}}",
|
||||
"removeHint": "Phiên mới sẽ không dùng tài khoản này nữa. Các phiên đang chạy trên nó vẫn chạy tiếp.",
|
||||
"removeButton": "Xoá",
|
||||
"done": {
|
||||
"added": "Đã thêm {{name}} vào pool",
|
||||
"removed": "Đã xoá {{name}} khỏi pool"
|
||||
},
|
||||
"errors": {
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"EBADNAME": "Tên: 1–64 ký tự chữ, số, '.', '_' hoặc '-'.",
|
||||
"EBADTOKEN": "Token không hợp lệ cho công cụ này.",
|
||||
"EEXISTS": "Đã có tài khoản trùng tên trong pool.",
|
||||
"ENOACCOUNT": "Tài khoản đó không còn trong pool.",
|
||||
"ELOCALMODE": "Ở chế độ local, pool được thay đổi từ shell của máy (agenthub accounts).",
|
||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
"profile": "Login đã lưu (profile Switch account)",
|
||||
"credentials": "Dán .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||
"chooseProfile": "Chọn login đã lưu…",
|
||||
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||
"loadingProfiles": "Đang tải…",
|
||||
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||
"nameFromProfile": "Để trống để dùng tên profile",
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key hoặc auth.json"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file clipboard.test.ts
|
||||
* @description `writeClipboard` / `copyText`: the async Clipboard API when the
|
||||
* origin has one, else the hidden-textarea `execCommand("copy")` — the only
|
||||
* path on `http://<lan-ip>`, where a terminal selection must still reach the
|
||||
* user's clipboard — and the focus goes back to where it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyText, writeClipboard } from "../clipboard";
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
const setClipboard = (value: unknown) =>
|
||||
Object.defineProperty(navigator, "clipboard", { value, configurable: true });
|
||||
|
||||
afterEach(() => {
|
||||
if (original) Object.defineProperty(navigator, "clipboard", original);
|
||||
else delete (navigator as { clipboard?: unknown }).clipboard;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("writeClipboard", () => {
|
||||
it("uses the Clipboard API when the origin has one", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
expect(await writeClipboard("hello")).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back to execCommand on a non-secure origin, and gives the focus back", async () => {
|
||||
setClipboard(undefined);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
let copied = "";
|
||||
const exec = vi.fn((cmd: string) => {
|
||||
copied = document.querySelector("textarea")?.value ?? "";
|
||||
return cmd === "copy";
|
||||
});
|
||||
Object.defineProperty(document, "execCommand", { value: exec, configurable: true });
|
||||
expect(await writeClipboard("from tmux")).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("copy");
|
||||
expect(copied).toBe("from tmux");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("copyText reports a failed copy", async () => {
|
||||
setClipboard(undefined);
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: vi.fn(() => false),
|
||||
configurable: true,
|
||||
});
|
||||
expect(await copyText("x", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
+144
-8
@@ -540,7 +540,88 @@ export type ServerUserUsage = AuthUser & {
|
||||
sessions: number;
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
/** GET /api/users/:id — one account plus the workspaces it owns. */
|
||||
export type UserDetail = {
|
||||
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
|
||||
workspaces: { id: number; title: string; cwd: string }[];
|
||||
};
|
||||
/** PATCH /api/users/:id — every field optional; only what changed is sent. */
|
||||
export type UserPatch = {
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
role?: AuthUser["role"];
|
||||
disabled?: boolean;
|
||||
password?: string;
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
};
|
||||
/** GET /api/me/account — the signed-in account's own information. */
|
||||
export type MyAccount =
|
||||
| { mode: "local" }
|
||||
| {
|
||||
mode: "server";
|
||||
user: AuthUser & { disabled: boolean };
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
logins: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
/** One Claude pool account as `GET /api/accounts` reports it — never its token. */
|
||||
export type PoolAccount = {
|
||||
name: string;
|
||||
/** `token` = a setup-token; `login` = a real login placed in members' homes. */
|
||||
kind: "token" | "login";
|
||||
email: string | null;
|
||||
/** A login the server could not refresh (`ESTALE`: log in again). */
|
||||
refreshError: string | null;
|
||||
addedAt: string;
|
||||
expiresAt: string | null;
|
||||
expired: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||
export type CodexPoolAccount = {
|
||||
name: string;
|
||||
kind: "apikey" | "chatgpt";
|
||||
addedAt: string;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
label: string;
|
||||
email: string | null;
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||
export type PoolAccountInput = {
|
||||
tool: PoolTool;
|
||||
name: string;
|
||||
profile?: string;
|
||||
credentials?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/** A staff read of a member's session (`GET /api/users/audit`). */
|
||||
export type AuditEntry = {
|
||||
id: number;
|
||||
@@ -550,10 +631,21 @@ export type AuditEntry = {
|
||||
session_id: string | null;
|
||||
reader: string | null;
|
||||
owner: string | null;
|
||||
/** Their display names, when set — shown instead of the usernames. */
|
||||
reader_display?: string | null;
|
||||
owner_display?: string | null;
|
||||
};
|
||||
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "analyst" | "developer";
|
||||
/** Optional profile fields — absent from an older server's answers. */
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
@@ -702,6 +794,19 @@ export const api = {
|
||||
}),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
/** GET /api/me/account — this account's own information (server mode). */
|
||||
account: () => request<MyAccount>("/me/account"),
|
||||
/** PATCH /api/me/account — this account's display name and email. */
|
||||
updateAccount: (body: { displayName?: string | null; email?: string | null }) =>
|
||||
request<MyAccount>("/me/account", { method: "PATCH", body: JSON.stringify(body) }),
|
||||
/** POST /api/me/password — this login stays, every other one ends. */
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ ok: true }>("/me/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
/** POST /api/me/sign-out-others — end every login of this account but this one. */
|
||||
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -721,12 +826,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/users/:id — role and/or disabled (never your own). */
|
||||
update: (
|
||||
id: number,
|
||||
body: { role?: AuthUser["role"]; disabled?: boolean },
|
||||
currentPassword: string
|
||||
) =>
|
||||
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
||||
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||
* sign-out in ONE change (never your own role or disabling yourself). */
|
||||
update: (id: number, body: UserPatch, currentPassword: string) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
@@ -744,6 +848,26 @@ export const api = {
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
/** The Claude/Codex account pool every member's new panes run on
|
||||
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||
accountPool: {
|
||||
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
||||
@@ -934,6 +1058,12 @@ export const api = {
|
||||
* wherever it lies (local mode only), for an `<img>`. */
|
||||
readImageUrl: (id: string, path: string) =>
|
||||
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
||||
/** GET /api/sessions/:id/file — a text file the session itself read or
|
||||
* wrote, wherever it lies (an output outside the workspace). */
|
||||
readFile: (id: string, path: string) =>
|
||||
request<{ text: string; truncated: boolean }>(
|
||||
`/sessions/${encodeURIComponent(id)}/file?${new URLSearchParams({ path })}`
|
||||
),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
@@ -2415,6 +2545,8 @@ export const api = {
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
@@ -2975,16 +3107,20 @@ export const api = {
|
||||
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
||||
* root, `home` is the folder the browser's Home place opens.
|
||||
*/
|
||||
browse: (path?: string, files?: boolean, hidden?: boolean) => {
|
||||
browse: (path?: string, files?: boolean, hidden?: boolean, host?: boolean) => {
|
||||
const q = new URLSearchParams();
|
||||
if (path) q.set("path", path);
|
||||
if (files) q.set("files", "1");
|
||||
if (hidden) q.set("hidden", "1");
|
||||
if (host) q.set("host", "1");
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
path: string;
|
||||
parent: string | null;
|
||||
home?: string;
|
||||
/** An admin with their own Linux account (S3): the host account's
|
||||
* home, opened by `host=1` instead of theirs. */
|
||||
hostHome?: string;
|
||||
entries: {
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
+20
-11
@@ -4,7 +4,9 @@
|
||||
* Uses the async Clipboard API when it exists and falls back to a hidden
|
||||
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
|
||||
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
|
||||
* a toast, so a copy is never silent.
|
||||
* a toast, so a copy is never silent. `writeClipboard(text)` is the same two
|
||||
* paths with no toast, for a copy the user did not click (a terminal
|
||||
* selection); it hands the focus back to wherever it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +14,8 @@ import i18n from "../i18n";
|
||||
import { toast } from "./toast";
|
||||
|
||||
function fallbackCopy(text: string): boolean {
|
||||
// Selecting the textarea moves the focus: give it back (a terminal keeps typing).
|
||||
const was = document.activeElement as HTMLElement | null;
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
@@ -25,25 +29,30 @@ function fallbackCopy(text: string): boolean {
|
||||
return false;
|
||||
} finally {
|
||||
ta.remove();
|
||||
was?.focus?.({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy `text` silently: the Clipboard API, else the textarea path. */
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
return fallbackCopy(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard and toast the result.
|
||||
* @param quiet Skip the success toast (the caller shows its own feedback).
|
||||
* @returns Whether the copy succeeded.
|
||||
*/
|
||||
export async function copyText(text: string, quiet = false): Promise<boolean> {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
if (!ok) ok = fallbackCopy(text);
|
||||
const ok = await writeClipboard(text);
|
||||
if (!ok) toast.error(i18n.t("common:copyFailed"));
|
||||
else if (!quiet) toast.success(i18n.t("common:copied"));
|
||||
return ok;
|
||||
|
||||
@@ -35,6 +35,26 @@ export const SETTINGS_INDEX: {
|
||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "account",
|
||||
titleKey: "account.profile",
|
||||
descKey: "account.profileDescription",
|
||||
keywords: ["profile", "email", "display name", "hồ sơ", "tên hiển thị", "tài khoản"],
|
||||
to: "#account-profile",
|
||||
},
|
||||
{
|
||||
section: "account",
|
||||
titleKey: "account.password",
|
||||
descKey: "account.passwordDescription",
|
||||
keywords: ["password", "mật khẩu", "đổi mật khẩu"],
|
||||
to: "#account-password",
|
||||
},
|
||||
{
|
||||
section: "account",
|
||||
titleKey: "account.sessionsTitle",
|
||||
keywords: ["sign out", "logout", "devices", "đăng xuất", "thiết bị"],
|
||||
to: "#account-sessions",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "language.title",
|
||||
|
||||
@@ -2666,6 +2666,8 @@ export interface Lane {
|
||||
owner_id?: number | null;
|
||||
/** The owner's username (`users.username`); null with no owner. */
|
||||
owner_name?: string | null;
|
||||
/** The owner's display name (`users.display_name`); null when none is set. */
|
||||
owner_display_name?: string | null;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
* their own and every profile they add is personal; an admin chooses when
|
||||
* adding; an analyst only reads.
|
||||
*
|
||||
* **Config 9router** (beside Add profile) opens 9router's own UI in a new tab
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
@@ -36,7 +41,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Lock, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AiProxy,
|
||||
@@ -50,6 +55,14 @@ import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
const NINE_ROUTER_PORT = 20128;
|
||||
|
||||
/** 9router's UI on the machine serving this page (it listens on every interface). */
|
||||
export const nineRouterUrl = (loc: Pick<Location, "hostname"> = window.location): string => {
|
||||
const host = loc.hostname.includes(":") ? `[${loc.hostname}]` : loc.hostname;
|
||||
return `http://${host}:${NINE_ROUTER_PORT}`;
|
||||
};
|
||||
|
||||
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
||||
@@ -125,6 +138,14 @@ export function AiProxyConfig() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const [nineRouter, setNineRouter] = useState(false);
|
||||
useEffect(() => {
|
||||
api.aiProxies
|
||||
.nineRouter()
|
||||
.then((r) => setNineRouter(r.installed))
|
||||
.catch(() => setNineRouter(false));
|
||||
}, []);
|
||||
|
||||
// Any edit to the form invalidates the last verdict — see `testResult`.
|
||||
useEffect(() => setTestResult(null), [form]);
|
||||
|
||||
@@ -238,14 +259,27 @@ export function AiProxyConfig() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* The page header above already names the section. */}
|
||||
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiProxy.add")}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{nineRouter && (
|
||||
<a
|
||||
href={nineRouterUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("aiProxy.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* clears), and one page per section. A `#anchor` in the URL scrolls to the
|
||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* In server mode a signed-in account gets **Account** first (its own
|
||||
* information, display name/email, password and sign-ins —
|
||||
* `AccountSettings`); local mode has no account and no such section.
|
||||
* An unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). Small pages share one: System is the old
|
||||
* About (`about` redirects), Git holds git logins and worktree suggestions
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
Router,
|
||||
Search,
|
||||
Server,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
@@ -57,6 +61,7 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiProxyConfig } from "./AiProxyConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AccountSettings } from "../components/settings/AccountSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
@@ -73,7 +78,7 @@ import {
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { useAuth, usePermissions } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
@@ -237,12 +242,25 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const allSections: Section[] = [
|
||||
...(signedIn
|
||||
? [
|
||||
{
|
||||
id: "account",
|
||||
group: "general" as const,
|
||||
Icon: UserRound,
|
||||
...s("account"),
|
||||
render: () => <AccountSettings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "appearance",
|
||||
group: "general",
|
||||
|
||||
+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"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AiProxyConfig } from "../AiProxyConfig";
|
||||
import { AiProxyConfig, nineRouterUrl } from "../AiProxyConfig";
|
||||
import { api } from "../../lib/api";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
import type { AuthState } from "../../lib/api";
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
test: vi.fn(),
|
||||
nineRouter: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
|
||||
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
|
||||
vi.mocked(api.aiProxies.nineRouter).mockReset().mockResolvedValue({ installed: false });
|
||||
vi.mocked(api.aiProxies.test)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -83,6 +85,27 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AiProxyConfig", () => {
|
||||
it("shows Config 9router beside Add profile only when 9router is installed", async () => {
|
||||
vi.mocked(api.aiProxies.nineRouter).mockResolvedValue({ installed: true });
|
||||
render(<AiProxyConfig />);
|
||||
const link = await screen.findByRole("link", { name: /aiProxy\.config9router/ });
|
||||
// The dashboard's own host (jsdom's is localhost), not a fixed localhost.
|
||||
expect(link).toHaveAttribute("href", `http://${window.location.hostname}:20128`);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("points Config 9router at the host the dashboard was opened on", () => {
|
||||
expect(nineRouterUrl({ hostname: "10.1.13.7" })).toBe("http://10.1.13.7:20128");
|
||||
expect(nineRouterUrl({ hostname: "::1" })).toBe("http://[::1]:20128");
|
||||
});
|
||||
|
||||
it("hides Config 9router when 9router is not installed", async () => {
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Native (Claude / Codex)");
|
||||
await waitFor(() => expect(api.aiProxies.nineRouter).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("link", { name: /aiProxy\.config9router/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||
render(<AiProxyConfig />);
|
||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description The Users page: local mode and a member are sent home; an
|
||||
* analyst reads the table with no account controls; an admin adds a user only
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the dialog, and cannot change their own role or
|
||||
* disable themselves.
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -37,6 +38,7 @@ const users = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
@@ -46,6 +48,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
users,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -69,6 +74,10 @@ beforeEach(() => {
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
users.get.mockImplementation(async (id: number) => ({
|
||||
user: { id, username: "x", role: "developer", disabled: false },
|
||||
workspaces: [],
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Users page", () => {
|
||||
@@ -119,15 +128,15 @@ describe("Users page", () => {
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the dialog and keeps it open", async () => {
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.setPassword.mockRejectedValue(
|
||||
users.update.mockRejectedValue(
|
||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||
);
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
|
||||
await ue.click(screen.getAllByRole("button", { name: "Edit user" })[1]!);
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
@@ -135,18 +144,26 @@ describe("Users page", () => {
|
||||
"Your password is wrong."
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
|
||||
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" }, "wrong");
|
||||
});
|
||||
|
||||
it("never offers an admin a role change or disable on their own row", async () => {
|
||||
it("offers one Edit per row and no separate account buttons", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
|
||||
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
|
||||
expect(ownRole!.hasAttribute("disabled")).toBe(true);
|
||||
expect(ownLock!.hasAttribute("disabled")).toBe(true);
|
||||
expect(miaRole!.hasAttribute("disabled")).toBe(false);
|
||||
expect(miaLock!.hasAttribute("disabled")).toBe(false);
|
||||
expect(screen.getAllByRole("button", { name: "Edit user" })).toHaveLength(2);
|
||||
for (const name of ["Change role", "Reset password", "Give a workspace", "Disable"])
|
||||
expect(screen.queryByRole("button", { name })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the display name first, with @username beside it, and the email", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.list.mockResolvedValue({
|
||||
users: [{ ...row(2, "mia", "developer"), displayName: "Mia Tran", email: "mia@x.vn" }],
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText("Mia Tran")).toBeTruthy();
|
||||
expect(screen.getByText("@mia")).toBeTruthy();
|
||||
expect(screen.getByText("mia@x.vn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.5 (rebuild client to match)
|
||||
UI build v2.7.6 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user