feat: account pool per tool with groups and a Cursor pool; update runs in the background

- The update dialog can be closed while an update applies: it keeps running,
  the page still reloads itself when the new server is up, and a failure
  re-opens the dialog with its error.
- Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI,
  Oh My Pi, and every other installed tool, which says it has no pool),
  accounts as cards with 5-hour and weekly quota shown as what is LEFT plus
  their reset times, and the saved profiles of Settings → AI tools →
  Profiles as cards with "Share to pool".
- Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to
  a cursor-agent pane only through tmux -e CURSOR_API_KEY;
  `agenthub accounts add <name> --tool cursor`.
- Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's
  members run only on its accounts of each tool, a user in no group on the
  accounts no group lists, a pin still wins. account_pins loses its tool
  CHECK (rebuilt, pins kept); three new tables.
- Oh My Pi login sharing can be switched from its pool tab too.
This commit is contained in:
2026-10-22 17:02:08 +07:00
parent 44426faf16
commit 685eb748e8
30 changed files with 2891 additions and 299 deletions
+435
View File
@@ -0,0 +1,435 @@
/**
* @file PoolGroups.tsx
* @description The account pool's GROUPS (`/api/accounts/groups`,
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
* members run only on the accounts its group lists, of every tool, while a
* user in no group runs on the shared pool (the accounts no group lists). A
* card shows the group's members and its accounts per tool; an admin creates,
* edits (name, members — one group per user, so ticking one moves them here —
* and accounts) or deletes one, each time with their own password again.
* `groupsOf` tells an account card which groups use it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Pencil, Plus, Trash2, Users } from "lucide-react";
import {
api,
type AccountPool,
type PoolGroup,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
/** Pool tools with their tab labels, in tab order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
];
/** Account names of `tool` in the pool. */
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
return (list ?? []).map((a) => a.name);
}
/** The groups that list account `name` of `tool`. */
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
}
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 PoolGroups({
pool,
isAdmin,
onChanged,
}: {
pool: AccountPool;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation("users");
const groups = pool.groups ?? [];
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
return (
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
<div className="flex flex-wrap items-center gap-2">
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
<div className="flex-1 min-w-[14rem]">
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
</div>
{isAdmin && (
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
</button>
)}
</div>
{groups.length === 0 ? (
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
) : (
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<div
key={g.id}
className="rounded-lg border border-border bg-surface-2 p-3"
data-testid="pool-group"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
<div className="text-[11px] text-fg-muted">
{t("pool.groups.members", { count: g.users.length })}
</div>
</div>
{isAdmin && (
<>
<button
type="button"
onClick={() => setEditing(g)}
title={t("pool.groups.edit", { name: g.name })}
aria-label={t("pool.groups.edit", { name: g.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDeleting(g)}
title={t("pool.groups.delete", { name: g.name })}
aria-label={t("pool.groups.delete", { name: g.name })}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
{g.users.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{g.users.map((u) => (
<span key={u.userId} className="badge py-0 text-[11px]">
{u.username}
</span>
))}
</div>
)}
<div className="mt-2 space-y-1">
{POOL_TOOL_LABELS.map(({ id, label }) => {
const names = g.accounts[id] ?? [];
return (
<div key={id} className="flex items-center gap-1.5 text-[11px]">
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
<span className="text-fg-secondary">{label}:</span>
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{editing && (
<GroupDialog
pool={pool}
group={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onDone={(name) => {
toast.success(t("pool.groups.saved", { name }));
setEditing(null);
onChanged();
}}
/>
)}
{deleting && (
<DeleteGroupDialog
group={deleting}
onClose={() => setDeleting(null)}
onDone={() => {
toast.success(t("pool.groups.deleted", { name: deleting.name }));
setDeleting(null);
onChanged();
}}
/>
)}
</div>
);
}
/** Create a group, or edit one: its name, its members and its accounts. */
function GroupDialog({
pool,
group,
onClose,
onDone,
}: {
pool: AccountPool;
group: PoolGroup | null;
onClose: () => void;
onDone: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [name, setName] = useState(group?.name ?? "");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [members, setMembers] = useState<Set<number>>(
() => new Set(group?.users.map((u) => u.userId) ?? [])
);
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
claude: new Set(group?.accounts.claude ?? []),
codex: new Set(group?.accounts.codex ?? []),
cursor: new Set(group?.accounts.cursor ?? []),
}));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const groupOfUser = (userId: number) =>
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
const next = new Set(set);
if (on) next.add(v);
else next.delete(v);
return next;
};
const ready = name.trim().length > 0 && current.length > 0;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const id = group
? group.id
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
await api.accountPool.updateGroup(
id,
{
...(group ? { name: name.trim() } : {}),
userIds: [...members],
accounts: Object.fromEntries(
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
),
},
current
);
onDone(name.trim());
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
message={t("pool.groups.hint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
<input
className="input mt-1 w-full text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
aria-label={t("pool.groups.name")}
/>
</label>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.memberList")}
</span>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const other = groupOfUser(u.id);
return (
<div key={u.id} className="px-3 py-1.5">
<Checkbox
checked={members.has(u.id)}
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
label={
<span>
{u.username}
{other && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.groups.inGroup", { group: other.name })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<div>
<span className="text-xs font-medium text-fg-secondary">
{t("pool.groups.accountList")}
</span>
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
const names = accountNames(pool, tool);
return (
<div key={tool} className="px-3 py-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
{label}
</div>
{names.length === 0 ? (
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
) : (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{names.map((n) => (
<Checkbox
key={n}
checked={accounts[tool].has(n)}
onChange={(on) =>
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
}
label={n}
labelClassName="text-sm text-fg-primary"
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function DeleteGroupDialog({
group,
onClose,
onDone,
}: {
group: PoolGroup;
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.deleteGroup(group.id, current);
onDone();
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.groups.delete", { name: group.name })}
message={t("pool.groups.deleteHint")}
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}