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