feat(pool): one board of pools with split/merge by drag; plan names from tier and seat
- The Users page account pool is one board: a column per pool holding its Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its members. Accounts sit in one pool; drag moves them. Drop on New pool to split one off; drag a pool's only account into another pool to merge (members follow, the emptied pool is deleted on Save). One password Save. - Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said 'pro' after an upgrade and 'team' for both seats. Shown on profiles, the status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||
* login (the same switch as Settings → AI tools,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||
* says that its users sign in themselves. An admin adds an account from the
|
||||
* header's Add account, picking its tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
@@ -17,11 +17,11 @@
|
||||
* Cursor API key — 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. An account's Edit (pencil) picks the users who run on it
|
||||
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
|
||||
* or "no group"): their sessions skip the balancer, and a
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -69,6 +69,8 @@ type Row = {
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
|
||||
plan?: string | null;
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
const [tab, setTab] = useState<string>("omp");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
const badge = (id: string): string | null =>
|
||||
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
const rowOf = (tool: PoolTool, name: string) =>
|
||||
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<label className="relative">
|
||||
<span className="btn-primary pointer-events-none text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
|
||||
</span>
|
||||
<select
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
|
||||
}}
|
||||
aria-label={t("pool.addAccount")}
|
||||
data-testid="pool-add-account"
|
||||
>
|
||||
<option value="">{t("pool.addAccount")}</option>
|
||||
{POOL_TOOL_LABELS.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && (
|
||||
<PoolGroups
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => void load()}
|
||||
renderAccount={(tool, name) => {
|
||||
const r = rowOf(tool, name);
|
||||
return r ? (
|
||||
<AccountCard
|
||||
row={r}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => setEditing(r)}
|
||||
onRemove={() => setRemoving(r)}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.otherTools")}
|
||||
</h3>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
aria-label={t("pool.otherTools")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
{current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
@@ -443,39 +399,52 @@ function AccountCard({
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
|
||||
{r.plan && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
|
||||
data-testid="pool-account-plan"
|
||||
>
|
||||
{r.plan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
|
||||
{t(`pool.kindShort.${r.kind}`)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,68 +457,47 @@ function AccountCard({
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{metered && (
|
||||
<div className="space-y-1" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
|
||||
<span className="inline-flex items-center gap-1 text-fg-secondary">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
{metered && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
<span className="basis-full">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
className={`block h-full transition-[width] duration-500 ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
@@ -596,72 +544,6 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
@@ -777,13 +659,8 @@ function EditAccountDialog({
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||
<div className="mt-1 max-h-56 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 elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => toggle(u.id, on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||
)}
|
||||
{elsewhere && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.onAccount", { account: elsewhere.account })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1">
|
||||
<MemberPicker
|
||||
users={users}
|
||||
chosen={chosen}
|
||||
onChange={setChosen}
|
||||
groupOf={groupOf}
|
||||
grouped={(pool.groups ?? []).length > 0}
|
||||
note={(u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
const group = groupOf(u.id);
|
||||
return (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{[
|
||||
group && t("pool.groups.inGroup", { group }),
|
||||
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
|
||||
Reference in New Issue
Block a user