ui(pool): pick an account's users from its own Edit
Each account row in the Account pool card has a pencil: tick the users whose sessions run on it (one moves off any other account, unticking sends one back to the balancer). PUT /api/accounts/:name/users saves the set in one transaction, audits each change and switches a Claude login into each added user's home at once. The card-level switch button and pinned list are gone.
This commit is contained in:
@@ -8,16 +8,16 @@
|
|||||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
* `.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
|
* 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
|
* password again; an analyst only reads. The secret only goes IN: the server
|
||||||
* never sends it back. An admin can also switch a user to one account
|
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||||
* (`PUT /api/accounts/pins`): that user's sessions skip the balancer, and a
|
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||||
* Claude login goes into their home at once — no restart.
|
* a Claude login goes into their home at once — no restart.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { KeyRound, Pin, PinOff, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AccountPin,
|
type AccountPin,
|
||||||
@@ -32,6 +32,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -78,8 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [removing, setRemoving] = useState<Row | null>(null);
|
const [removing, setRemoving] = useState<Row | null>(null);
|
||||||
/** The pin dialog, opened empty or on one existing pin (to change or unpin it). */
|
const [editing, setEditing] = useState<Row | null>(null);
|
||||||
const [pinning, setPinning] = useState<{ tool: PoolTool; userId?: number } | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(force = false) =>
|
(force = false) =>
|
||||||
@@ -99,11 +99,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const rows = pool ? rowsOf(pool) : [];
|
const rows = pool ? rowsOf(pool) : [];
|
||||||
const pins = pool
|
|
||||||
? (["claude", "codex"] as const).flatMap((tool) =>
|
|
||||||
pinsOf(pool, tool).map((p) => ({ ...p, tool }))
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,15 +120,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{isAdmin && rows.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPinning({ tool: "claude" })}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
<Pin className="w-4 h-4" /> {t("pool.pinUser")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||||
@@ -220,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="px-5 py-2 text-right">
|
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(r)}
|
||||||
|
title={t("pool.edit", { name: r.name })}
|
||||||
|
aria-label={t("pool.edit", { name: r.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRemoving(r)}
|
onClick={() => setRemoving(r)}
|
||||||
@@ -238,34 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pins.length > 0 && (
|
|
||||||
<div className="border-t border-border px-5 py-3" data-testid="account-pins">
|
|
||||||
<h3 className="text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
||||||
{t("pool.pinsTitle")}
|
|
||||||
</h3>
|
|
||||||
<ul className="mt-1.5 flex flex-wrap gap-2">
|
|
||||||
{pins.map((p) => (
|
|
||||||
<li key={`${p.tool}:${p.userId}`} className="badge gap-1.5 text-xs text-fg-secondary">
|
|
||||||
<Pin className="h-3 w-3" />
|
|
||||||
{p.username} → {p.account}
|
|
||||||
<span className="text-fg-muted">({t(`pool.toolName.${p.tool}`)})</span>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPinning({ tool: p.tool, userId: p.userId })}
|
|
||||||
title={t("pool.unpin", { name: p.username })}
|
|
||||||
aria-label={t("pool.unpin", { name: p.username })}
|
|
||||||
className="text-fg-muted hover:text-fg-primary"
|
|
||||||
>
|
|
||||||
<PinOff className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
onClose={() => setAdding(false)}
|
onClose={() => setAdding(false)}
|
||||||
@@ -287,21 +254,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pinning && pool && (
|
{editing && pool && (
|
||||||
<PinUserDialog
|
<EditAccountDialog
|
||||||
pool={pool}
|
pool={pool}
|
||||||
initial={pinning}
|
row={editing}
|
||||||
onClose={() => setPinning(null)}
|
onClose={() => setEditing(null)}
|
||||||
onDone={(user, account, switched) => {
|
onDone={(r) => {
|
||||||
const key = !account
|
const left = r.added.filter(
|
||||||
? "unpinned"
|
(u) => u.switched === "own-login" || u.switched === "expired"
|
||||||
: switched === "switched" || switched === "own-login" || switched === "expired"
|
|
||||||
? switched
|
|
||||||
: "pinned";
|
|
||||||
(key === "own-login" || key === "expired" ? toast.info : toast.success)(
|
|
||||||
t(`pool.pinDone.${key}`, { user, account })
|
|
||||||
);
|
);
|
||||||
setPinning(null);
|
toast.success(t("pool.editDone", { name: editing.name }));
|
||||||
|
for (const u of left) {
|
||||||
|
toast.info(
|
||||||
|
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -311,25 +279,26 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch a user to one account of a tool, or back to automatic. Opened on an
|
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||||
* existing pin it starts on that user with "Automatic", so one click unpins.
|
* on another account of the tool moves here when ticked; unticking one sends
|
||||||
|
* them back to the least-used account. Saved in one request, password again.
|
||||||
*/
|
*/
|
||||||
function PinUserDialog({
|
function EditAccountDialog({
|
||||||
pool,
|
pool,
|
||||||
initial,
|
row,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
pool: AccountPool;
|
pool: AccountPool;
|
||||||
initial: { tool: PoolTool; userId?: number };
|
row: Row;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (user: string, account: string | null, switched: string | null) => void;
|
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const [tool, setTool] = useState<PoolTool>(initial.tool);
|
const pins = pinsOf(pool, row.tool);
|
||||||
|
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
const [userId, setUserId] = useState<number | null>(initial.userId ?? null);
|
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||||
const [account, setAccount] = useState("");
|
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -341,18 +310,22 @@ function PinUserDialog({
|
|||||||
.catch(() => setUsers([]));
|
.catch(() => setUsers([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const accounts = tool === "codex" ? pool.codex : pool.accounts;
|
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||||
const pinOf = (id: number | null, of: PoolTool) =>
|
const ready = changed && current.length > 0;
|
||||||
pinsOf(pool, of).find((p) => p.userId === id)?.account ?? "";
|
const toggle = (id: number, on: boolean) =>
|
||||||
const ready = userId != null && current.length > 0 && account !== pinOf(userId, tool);
|
setChosen((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!ready || busy || userId == null) return;
|
if (!ready || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.accountPool.pin(tool, userId, account || null, current);
|
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||||
onDone(r.pin.username, r.pin.account, r.switched ?? null);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(errorText(t, e));
|
setError(errorText(t, e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -363,9 +336,9 @@ function PinUserDialog({
|
|||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open
|
open
|
||||||
title={t("pool.pinTitle")}
|
title={t("pool.edit", { name: row.name })}
|
||||||
message={t("pool.pinHint")}
|
message={t("pool.editHint")}
|
||||||
confirmLabel={t("pool.pinButton")}
|
confirmLabel={t("pool.save")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -376,66 +349,48 @@ function PinUserDialog({
|
|||||||
<div
|
<div
|
||||||
className="mt-3 space-y-3"
|
className="mt-3 space-y-3"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (isSubmitEnter(e)) {
|
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void submit();
|
void submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label={t("pool.tool")}>
|
<div>
|
||||||
<select
|
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||||
className="input w-full text-sm"
|
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
value={tool}
|
{users === null && (
|
||||||
onChange={(e) => {
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||||
const next = e.target.value as PoolTool;
|
)}
|
||||||
setTool(next);
|
{users?.length === 0 && (
|
||||||
setAccount(pinOf(userId, next));
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||||
}}
|
)}
|
||||||
aria-label={t("pool.tool")}
|
{users?.map((u) => {
|
||||||
>
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||||
<option value="claude">Claude Code</option>
|
return (
|
||||||
<option value="codex">Codex</option>
|
<div key={u.id} className="px-3 py-2">
|
||||||
</select>
|
<Checkbox
|
||||||
</Field>
|
checked={chosen.has(u.id)}
|
||||||
<Field label={t("pool.user")}>
|
onChange={(on) => toggle(u.id, on)}
|
||||||
<select
|
label={
|
||||||
className="input w-full text-sm"
|
<span>
|
||||||
value={userId ?? ""}
|
{u.username}
|
||||||
onChange={(e) => {
|
{u.disabled && (
|
||||||
const id = e.target.value ? Number(e.target.value) : null;
|
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||||
setUserId(id);
|
)}
|
||||||
setAccount(pinOf(id, tool));
|
{elsewhere && (
|
||||||
}}
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
aria-label={t("pool.user")}
|
{t("pool.onAccount", { account: elsewhere.account })}
|
||||||
>
|
</span>
|
||||||
<option value="">
|
)}
|
||||||
{users === null ? t("pool.loadingUsers") : t("pool.chooseUser")}
|
</span>
|
||||||
</option>
|
}
|
||||||
{users?.map((u) => (
|
labelClassName="text-sm text-fg-primary"
|
||||||
<option key={u.id} value={u.id}>
|
/>
|
||||||
{u.username}
|
</div>
|
||||||
{u.disabled ? ` (${t("disabled")})` : ""}
|
);
|
||||||
</option>
|
})}
|
||||||
))}
|
</div>
|
||||||
</select>
|
</div>
|
||||||
</Field>
|
|
||||||
<Field label={t("pool.pinAccount")}>
|
|
||||||
<select
|
|
||||||
className="input w-full text-sm"
|
|
||||||
value={account}
|
|
||||||
onChange={(e) => setAccount(e.target.value)}
|
|
||||||
aria-label={t("pool.pinAccount")}
|
|
||||||
disabled={userId == null}
|
|
||||||
>
|
|
||||||
<option value="">{t("pool.noPin")}</option>
|
|
||||||
{accounts.map((a) => (
|
|
||||||
<option key={a.name} value={a.name}>
|
|
||||||
{a.name}
|
|
||||||
{"email" in a && a.email ? ` — ${a.email}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* admin adds an account only with their own password typed (the token goes
|
* 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
|
* 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
|
* included), or a setup-token — and removes one the same way, and an analyst
|
||||||
* sees no add or remove control. An admin switches a user to one account and
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||||
* back to automatic, each with their own password; it shows on the accounts.
|
* it, saved with the admin's own password; the users show on the account.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
pin: vi.fn(),
|
setUsers: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
@@ -168,53 +168,42 @@ describe("AccountPoolCard", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pins a user to an account and unpins them, with the admin's own password", async () => {
|
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||||
accountPool.list.mockResolvedValue({
|
accountPool.list.mockResolvedValue({
|
||||||
...POOL,
|
...POOL,
|
||||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
pins: {
|
||||||
|
claude: [
|
||||||
|
{ userId: 8, username: "leo", account: "team-1" },
|
||||||
|
{ userId: 7, username: "mia", account: "team-0" },
|
||||||
|
],
|
||||||
|
codex: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
accountPool.pin.mockResolvedValue({
|
accountPool.setUsers.mockResolvedValue({
|
||||||
pin: { userId: 7, username: "mia", account: "team-1" },
|
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
||||||
switched: "switched",
|
removed: [{ userId: 8, username: "leo" }],
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AccountPoolCard isAdmin />);
|
render(<AccountPoolCard isAdmin />);
|
||||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: /switch a user's account/i }));
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument());
|
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
||||||
await user.selectOptions(screen.getByLabelText("User"), "7");
|
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
||||||
await user.selectOptions(screen.getByLabelText("Account"), "team-1");
|
expect(leo).toHaveAttribute("aria-checked", "true");
|
||||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
expect(mia).toHaveAttribute("aria-checked", "false");
|
||||||
(b) => b.textContent === "Switch"
|
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
||||||
|
await user.click(mia);
|
||||||
|
await user.click(leo);
|
||||||
|
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
(b) => b.textContent === "Save"
|
||||||
)!;
|
)!;
|
||||||
expect(confirm).toBeDisabled();
|
expect(save).toBeDisabled();
|
||||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
await user.click(confirm);
|
await user.click(save);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(accountPool.pin).toHaveBeenCalledWith("claude", 7, "team-1", "boss-pass")
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unpins from the pinned list", async () => {
|
|
||||||
accountPool.list.mockResolvedValue({
|
|
||||||
...POOL,
|
|
||||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
|
||||||
});
|
|
||||||
accountPool.pin.mockResolvedValue({
|
|
||||||
pin: { userId: 8, username: "leo", account: null },
|
|
||||||
switched: null,
|
|
||||||
});
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<AccountPoolCard isAdmin />);
|
|
||||||
await user.click(await screen.findByRole("button", { name: "Back to automatic for leo" }));
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
|
||||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
|
||||||
await user.click(
|
|
||||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Switch")!
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass")
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -199,28 +199,18 @@
|
|||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed",
|
"refreshFailed": "refresh failed",
|
||||||
"pinned": "Used by: {{users}}",
|
"pinned": "Used by: {{users}}",
|
||||||
"pinUser": "Switch a user's account",
|
|
||||||
"pinTitle": "Switch a user's account",
|
|
||||||
"pinHint": "That user's sessions run on the chosen account instead of the least-used one — even when it is full. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions.",
|
|
||||||
"pinButton": "Switch",
|
|
||||||
"user": "User",
|
|
||||||
"chooseUser": "Choose a user…",
|
|
||||||
"loadingUsers": "Loading…",
|
"loadingUsers": "Loading…",
|
||||||
"pinAccount": "Account",
|
|
||||||
"noPin": "Automatic — the least-used account",
|
|
||||||
"pinsTitle": "Switched users",
|
|
||||||
"unpin": "Back to automatic for {{name}}",
|
|
||||||
"pinDone": {
|
"pinDone": {
|
||||||
"pinned": "{{user}} now runs on {{account}}",
|
|
||||||
"unpinned": "{{user}} is back on the least-used account",
|
|
||||||
"switched": "{{user}} switched to {{account}} — running sessions follow at their next login check",
|
|
||||||
"own-login": "{{user}} signed in with their own login, which was left alone",
|
"own-login": "{{user}} signed in with their own login, which was left alone",
|
||||||
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
||||||
},
|
},
|
||||||
"toolName": {
|
"edit": "Edit {{name}}",
|
||||||
"claude": "Claude",
|
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
|
||||||
"codex": "Codex"
|
"save": "Save",
|
||||||
}
|
"users": "Users on this account",
|
||||||
|
"noUsers": "No users yet.",
|
||||||
|
"onAccount": "now on {{account}}",
|
||||||
|
"editDone": "{{name}} saved"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
|
|||||||
@@ -199,28 +199,18 @@
|
|||||||
},
|
},
|
||||||
"refreshFailed": "làm mới lỗi",
|
"refreshFailed": "làm mới lỗi",
|
||||||
"pinned": "Đang dùng: {{users}}",
|
"pinned": "Đang dùng: {{users}}",
|
||||||
"pinUser": "Đổi tài khoản cho user",
|
|
||||||
"pinTitle": "Đổi tài khoản cho user",
|
|
||||||
"pinHint": "Phiên của user này chạy trên tài khoản đã chọn thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới.",
|
|
||||||
"pinButton": "Đổi",
|
|
||||||
"user": "User",
|
|
||||||
"chooseUser": "Chọn user…",
|
|
||||||
"loadingUsers": "Đang tải…",
|
"loadingUsers": "Đang tải…",
|
||||||
"pinAccount": "Tài khoản",
|
|
||||||
"noPin": "Tự động — tài khoản ít tải nhất",
|
|
||||||
"pinsTitle": "User đã đổi tài khoản",
|
|
||||||
"unpin": "Trả {{name}} về tự động",
|
|
||||||
"pinDone": {
|
"pinDone": {
|
||||||
"pinned": "{{user}} giờ chạy trên {{account}}",
|
|
||||||
"unpinned": "{{user}} quay lại dùng tài khoản ít tải nhất",
|
|
||||||
"switched": "Đã đổi {{user}} sang {{account}} — phiên đang chạy sẽ theo ở lần kiểm tra đăng nhập kế tiếp",
|
|
||||||
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
||||||
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
||||||
},
|
},
|
||||||
"toolName": {
|
"edit": "Sửa {{name}}",
|
||||||
"claude": "Claude",
|
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
|
||||||
"codex": "Codex"
|
"save": "Lưu",
|
||||||
}
|
"users": "User dùng tài khoản này",
|
||||||
|
"noUsers": "Chưa có user nào.",
|
||||||
|
"onAccount": "đang ở {{account}}",
|
||||||
|
"editDone": "Đã lưu {{name}}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Xoá user",
|
"button": "Xoá user",
|
||||||
|
|||||||
+11
-7
@@ -1061,15 +1061,19 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
}),
|
}),
|
||||||
/** PUT /api/accounts/pins — admin: switch a user to one account (`null` = automatic), password again. */
|
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
|
||||||
pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) =>
|
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
|
||||||
request<{
|
request<{
|
||||||
pin: { userId: number; username: string; account: string | null };
|
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
|
||||||
/** What happened now: a Claude login goes into the user's home at once. */
|
added: {
|
||||||
switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null;
|
userId: number;
|
||||||
}>("/accounts/pins", {
|
username: string;
|
||||||
|
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
|
||||||
|
}[];
|
||||||
|
removed: { userId: number; username: string }[];
|
||||||
|
}>(`/accounts/${encodeURIComponent(name)}/users`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ tool, userId, account, currentPassword }),
|
body: JSON.stringify({ tool, userIds, currentPassword }),
|
||||||
}),
|
}),
|
||||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
|
|||||||
|
|
||||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||||
|
|
||||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). Removing an account drops its pins; deleting a user drops theirs.
|
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
|
||||||
|
|
||||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -425,9 +425,11 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
|||||||
- A token more than a year old is skipped.
|
- A token more than a year old is skipped.
|
||||||
- A resumed session picks an account again, like a new one.
|
- A resumed session picks an account again, like a new one.
|
||||||
- **Switching a user to one account.** An admin can take one user off the
|
- **Switching a user to one account.** An admin can take one user off the
|
||||||
ranking: **Switch a user's account** in the Account pool card (or
|
ranking: the pencil (**Edit**) on that account's row in the Account pool
|
||||||
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>` for
|
card — tick the users who run on it, untick to send one back to automatic (or
|
||||||
automatic again; their own password again, audited). Every session in a
|
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
|
||||||
|
own password again, each change audited). A user ticked on one account
|
||||||
|
moves off any other. Every session in a
|
||||||
workspace that user OWNS then runs on that account, even when it is full;
|
workspace that user OWNS then runs on that account, even when it is full;
|
||||||
other users are not affected, and the account is not reserved for them.
|
other users are not affected, and the account is not reserved for them.
|
||||||
- **A Claude login switches at once, without a restart.** The server first
|
- **A Claude login switches at once, without a restart.** The server first
|
||||||
|
|||||||
@@ -201,6 +201,42 @@ describe("pinning a user to one account", () => {
|
|||||||
assert.equal(pins.pinFor(miaId, "claude"), null);
|
assert.equal(pins.pinFor(miaId, "claude"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets exactly who runs on one account from its Edit", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const id = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
||||||
|
claudePool.addAccount("team-y", `sk-ant-oat01-${"y".repeat(40)}`);
|
||||||
|
pins.setPin(id("ana"), "claude", "team-1");
|
||||||
|
const edit = (account, userIds, extra = {}) =>
|
||||||
|
call("PUT", `/api/accounts/${account}/users`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "claude", userIds, currentPassword: BOSS_PASS, ...extra },
|
||||||
|
});
|
||||||
|
const r = await edit("team-y", [id("mia"), id("ana")]);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body.added.map((u) => [u.username, u.switched]).sort(), [
|
||||||
|
["ana", "next-session"],
|
||||||
|
["mia", "next-session"],
|
||||||
|
]);
|
||||||
|
assert.equal(pins.pinFor(id("ana"), "claude"), "team-y", "moved off team-1");
|
||||||
|
const again = await edit("team-y", [id("mia")]);
|
||||||
|
assert.deepEqual(again.body.added, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
again.body.removed.map((u) => u.username),
|
||||||
|
["ana"]
|
||||||
|
);
|
||||||
|
assert.equal(pins.pinFor(id("ana"), "claude"), null);
|
||||||
|
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y");
|
||||||
|
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.match(audit.path, /unpin ana from claude/);
|
||||||
|
assert.equal((await edit("team-y", [9999])).body.error.code, "ENOUSER");
|
||||||
|
assert.equal((await edit("team-y", "x")).body.error.code, "EBADUSER");
|
||||||
|
assert.equal((await edit("nope", [])).body.error.code, "ENOACCOUNT");
|
||||||
|
assert.equal((await edit("team-y", [], { currentPassword: "x" })).body.error.code, "EREAUTH");
|
||||||
|
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y", "a refused edit changes nothing");
|
||||||
|
await edit("team-y", []);
|
||||||
|
claudePool.removeAccount("team-y");
|
||||||
|
});
|
||||||
|
|
||||||
it("is ignored in local mode and dropped with its user", async () => {
|
it("is ignored in local mode and dropped with its user", async () => {
|
||||||
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||||
pins.setPin(miaId, "claude", "team-1");
|
pins.setPin(miaId, "claude", "team-1");
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* written into the user's home right away (`applyPin`), over the pool login
|
* written into the user's home right away (`applyPin`), over the pool login
|
||||||
* their running panes use, so those move to it without a restart.
|
* their running panes use, so those move to it without a restart.
|
||||||
*
|
*
|
||||||
|
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||||
|
*
|
||||||
* Server mode only: in local mode there are no users and nothing is pinned.
|
* Server mode only: in local mode there are no users and nothing is pinned.
|
||||||
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
||||||
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
||||||
@@ -81,6 +83,41 @@ function setPin(userId, tool, account) {
|
|||||||
return { userId: user.id, username: user.username, account: name };
|
return { userId: user.id, username: user.username, account: name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Users page's Edit of one account: exactly `userIds` run on `account`
|
||||||
|
* afterwards. A user pinned to another account of the tool moves here; one
|
||||||
|
* pinned here but left out goes back to the balancer. One transaction.
|
||||||
|
* Returns `{added, removed}` (each `[{userId, username}]`).
|
||||||
|
*/
|
||||||
|
function setAccountUsers(tool, account, userIds) {
|
||||||
|
validTool(tool);
|
||||||
|
const name = String(account ?? "");
|
||||||
|
if (!poolOf(tool).hasAccount(name)) {
|
||||||
|
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
|
||||||
|
throw err("EBADUSER", "userIds: an array of user ids");
|
||||||
|
}
|
||||||
|
const want = [...new Set(userIds)];
|
||||||
|
const users = want.map((id) => {
|
||||||
|
const u = db().prepare("SELECT id, username FROM users WHERE id = ?").get(id);
|
||||||
|
if (!u) throw err("ENOUSER", `no user ${id}`);
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
const before = listPins(tool);
|
||||||
|
const here = new Set(before.filter((p) => p.account === name).map((p) => p.userId));
|
||||||
|
const added = users.filter((u) => !here.has(u.id));
|
||||||
|
const removed = before.filter((p) => p.account === name && !want.includes(p.userId));
|
||||||
|
db().transaction(() => {
|
||||||
|
for (const u of added) setPin(u.id, tool, name);
|
||||||
|
for (const p of removed) setPin(p.userId, tool, null);
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
added: added.map((u) => ({ userId: u.id, username: u.username })),
|
||||||
|
removed: removed.map((p) => ({ userId: p.userId, username: p.username })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a pin take effect now where it can: a Claude login is written into
|
* Make a pin take effect now where it can: a Claude login is written into
|
||||||
* the user's home at once (claude-pool `switchHome`), so their running panes
|
* the user's home at once (claude-pool `switchHome`), so their running panes
|
||||||
@@ -104,4 +141,4 @@ function dropAccount(tool, account) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TOOLS, pinFor, listPins, setPin, applyPin, dropAccount };
|
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
||||||
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
||||||
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
||||||
|
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
||||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||||
@@ -771,6 +772,41 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/accounts/{name}/users": {
|
||||||
|
put: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "setPoolAccountUsers",
|
||||||
|
summary:
|
||||||
|
"Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user's home at once",
|
||||||
|
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["tool", "userIds", "currentPassword"],
|
||||||
|
properties: {
|
||||||
|
tool: { type: "string", enum: ["claude", "codex"] },
|
||||||
|
userIds: { type: "array", items: { type: "integer" } },
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session",
|
||||||
|
},
|
||||||
|
400: err("EBADTOOL, EBADUSER"),
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
404: err("ENOUSER, ENOACCOUNT"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/accounts/{name}": {
|
"/api/accounts/{name}": {
|
||||||
delete: {
|
delete: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
+45
-11
@@ -19,7 +19,9 @@
|
|||||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||||
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
||||||
* login goes into their home at once, so running panes move without a
|
* login goes into their home at once, so running panes move without a
|
||||||
* restart (`switched`); `GET /` lists the pins under `pins`.
|
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||||
|
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||||
|
* lists the pins under `pins`.
|
||||||
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
||||||
* they need the admin's own password again (`currentPassword`), are audited
|
* they need the admin's own password again (`currentPassword`), are audited
|
||||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||||
@@ -77,6 +79,7 @@ const STATUS = {
|
|||||||
ELOCALMODE: 403,
|
ELOCALMODE: 403,
|
||||||
EREAUTH: 403,
|
EREAUTH: 403,
|
||||||
EBADTOOL: 400,
|
EBADTOOL: 400,
|
||||||
|
EBADUSER: 400,
|
||||||
EBADNAME: 400,
|
EBADNAME: 400,
|
||||||
EBADTOKEN: 400,
|
EBADTOKEN: 400,
|
||||||
ENOACCOUNT: 404,
|
ENOACCOUNT: 404,
|
||||||
@@ -102,7 +105,9 @@ function poolChange(handler) {
|
|||||||
}
|
}
|
||||||
await auth.confirmPassword(req.user, body.currentPassword);
|
await auth.confirmPassword(req.user, body.currentPassword);
|
||||||
const out = await handler(req, body);
|
const out = await handler(req, body);
|
||||||
recordAccountChange(req.user.id, out.target ?? null, req.method, out.action);
|
for (const a of out.audits || [{ target: out.target, action: out.action }]) {
|
||||||
|
recordAccountChange(req.user.id, a.target ?? null, req.method, a.action);
|
||||||
|
}
|
||||||
res.status(out.status || 200).json(out.json);
|
res.status(out.status || 200).json(out.json);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = STATUS[e.code];
|
const status = STATUS[e.code];
|
||||||
@@ -216,15 +221,9 @@ router.put(
|
|||||||
"/pins",
|
"/pins",
|
||||||
poolChange(async (_req, body) => {
|
poolChange(async (_req, body) => {
|
||||||
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
|
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
|
||||||
let switched = null;
|
const switched = pin.account
|
||||||
if (pin.account) {
|
? await applyNow(pin.userId, pin.username, body.tool, pin.account)
|
||||||
try {
|
: null;
|
||||||
switched = await pins.applyPin(pin.userId, body.tool);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[accounts] switch ${pin.username} → ${pin.account}: ${e.message}`);
|
|
||||||
switched = "next-session";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
json: { pin, switched },
|
json: { pin, switched },
|
||||||
target: pin.userId,
|
target: pin.userId,
|
||||||
@@ -235,6 +234,41 @@ router.put(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Switch an added user now; a failed switch still leaves the pin for the next spawn. */
|
||||||
|
async function applyNow(userId, username, tool, account) {
|
||||||
|
try {
|
||||||
|
return await pins.applyPin(userId, tool);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[accounts] switch ${username} → ${account}: ${e.message}`);
|
||||||
|
return "next-session";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:name/users",
|
||||||
|
poolChange(async (req, body) => {
|
||||||
|
const account = req.params.name;
|
||||||
|
const { added, removed } = pins.setAccountUsers(body.tool, account, body.userIds);
|
||||||
|
const switched = [];
|
||||||
|
for (const u of added) {
|
||||||
|
switched.push({ ...u, switched: await applyNow(u.userId, u.username, body.tool, account) });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
json: { added: switched, removed },
|
||||||
|
audits: [
|
||||||
|
...added.map((u) => ({
|
||||||
|
target: u.userId,
|
||||||
|
action: `pool: pin ${u.username} to ${body.tool} account ${account}`,
|
||||||
|
})),
|
||||||
|
...removed.map((u) => ({
|
||||||
|
target: u.userId,
|
||||||
|
action: `pool: unpin ${u.username} from ${body.tool}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:name",
|
"/:name",
|
||||||
poolChange((req, body) => {
|
poolChange((req, body) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user