Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa74cce3fc | |||
| 3113479cd2 | |||
| 4eb7734ab9 | |||
| 750d74a2af |
+14
-3
@@ -4166,14 +4166,25 @@ async function cmdUsers(sub, positional, flags) {
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
auth
|
||||
.listUsers()
|
||||
.map((u) => [
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active",
|
||||
])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
|
||||
mustChange: true,
|
||||
});
|
||||
console.log(
|
||||
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file AuthCard.tsx
|
||||
* @description The card the signed-out screens share (the login page, the
|
||||
* first-login password change in `FirstPasswordPage.tsx`): a centred form
|
||||
* with the theme's accent glow and grid behind the brand logo, a title and a
|
||||
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
|
||||
* since no account's theme has been chosen for it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">{title}</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||
* password an admin gave (`mustChangePassword`) sees only
|
||||
* `FirstPasswordPage` until it picks its own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,15 +22,14 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
@@ -165,6 +166,17 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
// A password an admin gave: nothing else until the user picks their own
|
||||
// (the server refuses the rest anyway — EPWCHANGE).
|
||||
if (state.mode === "server" && state.user?.mustChangePassword) {
|
||||
return (
|
||||
<FirstPasswordPage
|
||||
user={state.user}
|
||||
onDone={() => updateUser({ mustChangePassword: false })}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
@@ -178,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -201,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
<AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file FirstPasswordPage.tsx
|
||||
* @description The screen an account signed in with a password an admin GAVE
|
||||
* (created or reset on the Users page — `mustChangePassword`) sees instead of
|
||||
* the app: the current password, a new one that must pass every rule (the
|
||||
* `PasswordRules` checklist ticks them as they are typed) and the same again,
|
||||
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
|
||||
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { passwordProblems } from "../lib/passwordRules";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { PasswordRules } from "./users/PasswordRules";
|
||||
|
||||
export function FirstPasswordPage({
|
||||
user,
|
||||
onDone,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onDone: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const strong = passwordProblems(next, user.username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.me.changePassword(current, next);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(
|
||||
code && t(`users:errors.${code}`, { defaultValue: "" })
|
||||
? t(`users:errors.${code}`)
|
||||
: (err as Error).message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
autoComplete: string,
|
||||
autoFocus = false
|
||||
) => (
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
required
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={t("auth.mustChangeTitle")}
|
||||
subtitle={t("auth.mustChangeSubtitle")}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
|
||||
{field(t("auth.newPassword"), next, setNext, "new-password")}
|
||||
<PasswordRules password={next} username={user.username} />
|
||||
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error ?? t("auth.mismatch")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!ready}
|
||||
>
|
||||
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance. A
|
||||
* password an admin gave shows only the "choose your own password" page,
|
||||
* whose button waits for every rule, until the change succeeds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +20,7 @@ import "../../i18n";
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const changePassword = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
@@ -27,7 +30,11 @@ vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||
me: {
|
||||
prefs: () => prefs(),
|
||||
savePrefs: () => Promise.resolve({ prefs: {} }),
|
||||
changePassword: (c: string, n: string) => changePassword(c, n),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,10 +63,30 @@ beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
changePassword.mockReset().mockResolvedValue({ ok: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
it("makes an account with an admin-given password choose its own first", async () => {
|
||||
const ue = userEvent.setup();
|
||||
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const save = await screen.findByRole("button", { name: "Save and continue" });
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
await ue.type(screen.getByLabelText("Current password"), "123");
|
||||
await ue.type(screen.getByLabelText("New password"), "weakpass");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
|
||||
expect(save).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
|
||||
await ue.click(save);
|
||||
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app in local mode", async () => {
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
it("opens a markdown file rendered, and switches it to raw and back", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens it as source when Settings → Appearance says Raw", () => {
|
||||
window.localStorage.setItem("agenthub.markdown.view", "raw");
|
||||
try {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
} finally {
|
||||
window.localStorage.removeItem("agenthub.markdown.view");
|
||||
}
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -374,7 +374,6 @@ export function SkillAddPanel({
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
@@ -382,14 +381,14 @@ export function SkillAddPanel({
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
{t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
{t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -569,7 +568,6 @@ export function SkillAddPanel({
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
@@ -746,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-fg-primary">{info.name}</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
@@ -949,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
{t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* (`MarkdownPreview`), opening as Settings → Appearance says
|
||||
* (`lib/markdownView.ts`: Preview by default).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -24,7 +26,7 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [preview, setPreview] = useState(readMarkdownPreview);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -873,9 +874,10 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -2014,6 +2016,8 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -3027,7 +3031,7 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : !mdPreview &&
|
||||
) : !mdShown &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
@@ -3083,7 +3087,7 @@ export function LaneChangesPanel({
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
text && mdShown ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
|
||||
@@ -514,7 +514,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -534,12 +534,13 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
@@ -227,6 +228,13 @@ function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string
|
||||
|
||||
type View = "preview" | "code";
|
||||
|
||||
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
|
||||
function defaultView(path: string): View {
|
||||
const kind = artifactKind(path);
|
||||
if (!RENDERED.has(kind)) return "code";
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
@@ -320,7 +328,7 @@ export function FilePreview({
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
@@ -332,7 +340,7 @@ export function FilePreview({
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||
setView(defaultView(path));
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
* @description Settings → Account (server mode, any signed-in role): the
|
||||
* account's own information (`GET /api/me/account` — username, role, member
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
|
||||
* sidebar shows it at once), its display name
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
||||
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
|
||||
* shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one (`POST /api/me/password` — this browser stays signed in, every
|
||||
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
||||
* typed (`POST /api/me/password` — this browser stays signed in, every
|
||||
* other sign-in ends) and "Sign out other devices" (`POST
|
||||
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
|
||||
* admin's to change, on the Users page — never here.
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -32,6 +34,8 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { PasswordRules } from "../users/PasswordRules";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
@@ -110,18 +114,27 @@ function ProfileCard({
|
||||
const shown = user.displayName || user.username;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// null = the link field is closed.
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
const run = async (op: () => Promise<MyAccount>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await op());
|
||||
toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const applyLink = async () => {
|
||||
const url = link?.trim();
|
||||
if (!url) return;
|
||||
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again
|
||||
@@ -150,6 +163,18 @@ function ProfileCard({
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
|
||||
onClick={() => setLink((v) => (v === null ? "" : null))}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.fromUrl")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
aria-pressed={link !== null}
|
||||
data-testid="account-avatar-url-toggle"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{user.avatarAt && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -187,6 +212,36 @@ function ProfileCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{link !== null && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full text-sm"
|
||||
value={link}
|
||||
placeholder={t("account.avatar.urlPlaceholder")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void applyLink();
|
||||
} else if (e.key === "Escape") setLink(null);
|
||||
}}
|
||||
data-testid="account-avatar-url-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex-shrink-0 text-sm"
|
||||
onClick={() => void applyLink()}
|
||||
disabled={busy || !link.trim()}
|
||||
data-testid="account-avatar-url-apply"
|
||||
>
|
||||
{t("account.avatar.urlApply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
|
||||
<Stat label={t("account.memberSince")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
@@ -283,13 +338,17 @@ function ProfileForm({
|
||||
|
||||
function PasswordForm() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { state } = useAuth();
|
||||
const username = state.mode === "server" ? state.user?.username : undefined;
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
|
||||
// The account's own password: every rule (server/lib/auth.js decides too).
|
||||
const strong = passwordProblems(next, username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -331,7 +390,7 @@ function PasswordForm() {
|
||||
aria-label={t("account.currentPassword")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
|
||||
<Field label={t("account.newPassword")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
@@ -352,6 +411,9 @@ function PasswordForm() {
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<PasswordRules password={next} username={username} />
|
||||
</div>
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error ?? t("account.mismatch")}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||
* default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
@@ -97,6 +99,7 @@ export function AppearanceSettings() {
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -224,6 +227,25 @@ export function AppearanceSettings() {
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="markdown-view"
|
||||
label={t("markdownView.label")}
|
||||
description={t("markdownView.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("markdownView.label")}
|
||||
value={mdPreview ? "preview" : "raw"}
|
||||
onChange={(v) => {
|
||||
writeMarkdownPreview(v === "preview");
|
||||
setMdPreview(v === "preview");
|
||||
}}
|
||||
options={[
|
||||
{ value: "preview" as const, label: t("markdownView.preview") },
|
||||
{ value: "raw" as const, label: t("markdownView.raw") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file OmpSharingSettings — Settings → AI tools → Oh My Pi login sharing: the
|
||||
* admin's server-wide switch that lets every member's omp pane run on the
|
||||
* HOST account's omp login through omp's own auth broker + gateway, without
|
||||
* any member holding a credential (`GET/PUT /api/ai-tools/omp-sharing`,
|
||||
* `server/lib/omp-share.js`). Renders nothing outside server mode with
|
||||
* per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type OmpSharingState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function OmpSharingSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<OmpSharingState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? null
|
||||
: !state.installed
|
||||
? t("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? t("ompSharing.running")
|
||||
: error
|
||||
? t("ompSharing.failed", { error })
|
||||
: t("ompSharing.starting");
|
||||
return (
|
||||
<SettingsSection id="omp-sharing" title={t("ompSharing.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("ompSharing.switch")}
|
||||
description={t("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{status && (
|
||||
<p data-testid="omp-sharing-status" className="text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's
|
||||
* login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles
|
||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type CcAccountIdentity,
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
/** Tools the account pool takes (server/routes/accounts.js). */
|
||||
const POOL_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||
const btnCls =
|
||||
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||
|
||||
export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const shown = TOOLS.filter((x) => {
|
||||
const tool = aiTools.byId(x.id);
|
||||
if (x.id === "claude") return tool?.enabled !== false;
|
||||
return !!tool && tool.installed && tool.enabled !== false;
|
||||
});
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
|
||||
const loadOthers = useCallback(
|
||||
() =>
|
||||
api.toolProfiles
|
||||
.list()
|
||||
.then((r) => setOthers(r.tools))
|
||||
.catch(() => setOthers([])),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
.list()
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="tool-profiles"
|
||||
title={t("toolProfiles.title")}
|
||||
description={t("toolProfiles.description")}
|
||||
>
|
||||
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
|
||||
{shown.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={x.id === current.id}
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
|
||||
x.id === current.id
|
||||
? "border-accent/40 bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
|
||||
{x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
login={current.login}
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** "The pool holds N accounts" + the way to it (Administration → Users). */
|
||||
function PoolLine({ count }: { count: number }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
|
||||
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
|
||||
<Link to="/admin#account-pool" className="text-accent hover:underline">
|
||||
{t("toolProfiles.openPool")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link that opens the pool's Add dialog on one saved profile (admins only). */
|
||||
function MoveToPool({ tool, name }: { tool: string; name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<Link
|
||||
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
|
||||
title={t("toolProfiles.moveToPoolHint")}
|
||||
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
{t("toolProfiles.moveToPool")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginHint({ text, command }: { text: string; command: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await copyText(command, true)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||
title={t("snippet.copy")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */
|
||||
function LabelEdit({
|
||||
label,
|
||||
busy,
|
||||
editing,
|
||||
setEditing,
|
||||
onRename,
|
||||
}: {
|
||||
label: string;
|
||||
busy: boolean;
|
||||
editing: boolean;
|
||||
setEditing: (on: boolean) => void;
|
||||
onRename: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [draft, setDraft] = useState(label);
|
||||
useEffect(() => {
|
||||
if (editing) setDraft(label);
|
||||
}, [editing, label]);
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setEditing(false);
|
||||
if (next && next !== label) onRename(next);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setEditing(true)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One mutation with a busy key, a toast either way, then a reload. */
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load]
|
||||
);
|
||||
return { busy, run };
|
||||
}
|
||||
|
||||
// ── Codex, Oh My Pi, … ─────────────────────────────────────────────────
|
||||
|
||||
function OtherProfiles({
|
||||
tool,
|
||||
label,
|
||||
login,
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
label: string;
|
||||
login: string;
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const { busy, run } = useRunner(onChanged);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
if (loading) return <SkeletonRows n={2} />;
|
||||
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
|
||||
if (!state.supported) {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
|
||||
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("toolProfiles.deleteConfirmBody", { tool: label }),
|
||||
confirmLabel: tc("profiles.deleteButton"),
|
||||
});
|
||||
if (ok) {
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.toolProfiles.remove(tool, p.name),
|
||||
tc("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
|
||||
</p>
|
||||
{state.error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{tc("profiles.activeLabel")}
|
||||
</div>
|
||||
{state.active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">{who(state.active)}</div>
|
||||
<OtherIdentityLine identity={state.active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
|
||||
)}
|
||||
{state.active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{tc("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((p) => (
|
||||
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.toolProfiles.rename(tool, p.name, next),
|
||||
tc("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
|
||||
{!p.active && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? tc("profiles.switching")
|
||||
: tc("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool={tool} name={p.name} />}
|
||||
{!p.active && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={tc("profiles.deleteButton")}
|
||||
aria-label={tc("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<OtherIdentityLine identity={p} />
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind;
|
||||
|
||||
function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const parts = [
|
||||
t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }),
|
||||
identity.email ? identity.detail : null,
|
||||
].filter(Boolean);
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||
// they are. The dashboard cannot log a new account in (only `claude auth login`
|
||||
// can mint tokens), so adding a profile is always "log in, then snapshot what
|
||||
// the CLI wrote" — a save button on the live account, never a name field: the
|
||||
// server derives the identifier, and the user edits the label in place. The
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives: the live account is read from the CLI and never refreshed,
|
||||
* any other profile only when its token has actually expired. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
}
|
||||
}, []);
|
||||
const { busy, run } = useRunner(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, once per profile per mount: a retry is the row's
|
||||
// own button, so a failed account is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
/** Deletable unless it is the ONLY saved copy of the live account. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
const remove = async (p: CcProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("profiles.deleteConfirmBody"),
|
||||
confirmLabel: t("profiles.deleteButton"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
};
|
||||
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||
const p = ctx.menu!.item;
|
||||
ctx.close();
|
||||
fn(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
||||
{!ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||
onSelect={pick((p) => void checkUsage(p.name))}
|
||||
>
|
||||
{t("profiles.usageCheck")}
|
||||
</MenuItem>
|
||||
{deletable(ctx.menu.item) && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy !== null}
|
||||
testId="profile-menu-delete"
|
||||
onSelect={pick((p) => void remove(p))}
|
||||
>
|
||||
{t("profiles.deleteButton")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one. Reading a saved
|
||||
* profile's usage may refresh its OAuth token (rotating it on disk), so it
|
||||
* happens once per mount or click, never on a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file AccountSettings.test.tsx
|
||||
* @description Settings → Account: shows the account's own information, saves
|
||||
* only a changed display name/email, refuses a password change whose two new
|
||||
* passwords differ, sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar.
|
||||
* passwords differ, keeps a password that misses a rule from being sent,
|
||||
* sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar or sets it from a link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -17,6 +18,7 @@ const me = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
signOutOthers: vi.fn(),
|
||||
setAvatar: vi.fn(),
|
||||
setAvatarFromUrl: vi.fn(),
|
||||
removeAvatar: vi.fn(),
|
||||
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
||||
}));
|
||||
@@ -76,6 +78,22 @@ describe("AccountSettings", () => {
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets the avatar from a link and closes the field", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
||||
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
|
||||
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
|
||||
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
||||
);
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
@@ -114,9 +132,21 @@ describe("AccountSettings", () => {
|
||||
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
||||
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
|
||||
// No upper-case letter: the checklist says so and the button stays off.
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("password-rules")
|
||||
.querySelector('[data-rule="upper"]')
|
||||
?.getAttribute("data-ok")
|
||||
).toBe("false");
|
||||
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "New-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123");
|
||||
await ue.click(screen.getByRole("button", { name: "Change password" }));
|
||||
await waitFor(() =>
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for OmpSharingSettings — the admin's Oh My Pi login-sharing
|
||||
* switch in Settings → AI tools: hidden in local mode, for a non-admin and
|
||||
* outside S3, and a toggle saves through PUT /api/ai-tools/omp-sharing and
|
||||
* shows the daemons' state.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { OmpSharingSettings } from "../OmpSharingSettings";
|
||||
import { api, type OmpSharingState } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { ompSharing: vi.fn(), setOmpSharing: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.ompSharing);
|
||||
const set = vi.mocked(api.aiTools.setOmpSharing);
|
||||
const off: OmpSharingState = {
|
||||
available: true,
|
||||
enabled: false,
|
||||
installed: true,
|
||||
broker: { running: false, error: null },
|
||||
gateway: { running: false, error: null },
|
||||
};
|
||||
|
||||
describe("OmpSharingSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin turn sharing on and shows it running", async () => {
|
||||
get.mockResolvedValue(off);
|
||||
set.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
broker: { running: true },
|
||||
gateway: { running: true, port: 4827 },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Share this server's Oh My Pi login" });
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("Running");
|
||||
});
|
||||
|
||||
it("names a daemon's failure", async () => {
|
||||
get.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
gateway: { running: false, error: "port 4827 is taken" },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("port 4827 is taken");
|
||||
});
|
||||
|
||||
it("renders nothing outside S3, for a non-admin or in local mode", async () => {
|
||||
get.mockResolvedValue({ ...off, available: false });
|
||||
const r = render(<OmpSharingSettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
r.unmount();
|
||||
perms.isAdmin = false;
|
||||
const r2 = render(<OmpSharingSettings />);
|
||||
expect(r2.container).toBeEmptyDOMElement();
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
|
||||
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
|
||||
* failing account costs the others nothing), and its right-click delete asks
|
||||
* first and never offers to delete the only copy of the live account; Codex's
|
||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||
* switched; and in server mode a profile links into the account pool.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { ToolProfilesSettings } from "../ToolProfilesSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({
|
||||
useAiTools: () => ({
|
||||
byId: (id: string) => ({ id, installed: true, enabled: true }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const m = vi.hoisted(() => {
|
||||
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
|
||||
return {
|
||||
window,
|
||||
cc: {
|
||||
profiles: vi.fn(),
|
||||
profileUsage: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
activateProfile: vi.fn(),
|
||||
saveProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function show() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ToolProfilesSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
});
|
||||
m.cc.profileUsage.mockImplementation(async (name: string) => {
|
||||
if (name === "spare") throw new Error("this profile's saved login has been superseded");
|
||||
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
|
||||
});
|
||||
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
|
||||
m.tp.list.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "codex",
|
||||
supported: true,
|
||||
items: [
|
||||
{
|
||||
name: "a",
|
||||
label: "Alice",
|
||||
active: true,
|
||||
kind: "chatgpt",
|
||||
email: "a@x",
|
||||
detail: "plus",
|
||||
},
|
||||
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
|
||||
],
|
||||
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
|
||||
},
|
||||
{ tool: "omp", supported: true, items: [], active: null },
|
||||
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
|
||||
],
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
|
||||
await show();
|
||||
await settle();
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes from the right-click menu only after confirming", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
it("switches a Codex login through /api/tool-profiles", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /codex/i }));
|
||||
const bob = screen.getByTestId("profile-row-codex-b");
|
||||
expect(bob).toHaveTextContent("…1234");
|
||||
await user.click(screen.getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
|
||||
expect(m.tp.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("says why Antigravity cannot be switched", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
|
||||
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — account pool", () => {
|
||||
it("is not shown in local mode", async () => {
|
||||
await show();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Move to pool/)).toBeNull();
|
||||
});
|
||||
|
||||
it("in server mode shows the pool's size and links a profile into it", async () => {
|
||||
perms.local = false;
|
||||
await show();
|
||||
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
|
||||
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
@@ -4,19 +4,23 @@
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* 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 (`?tool=codex`), an OpenAI API key or a pasted
|
||||
* `auth.json` — or removes one, each time with their own
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||
* a Claude login goes into their home at once — no restart.
|
||||
* 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 Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
@@ -77,7 +81,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [params, setParams] = useSearchParams();
|
||||
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||
const preset = (() => {
|
||||
const tool = params.get("poolAdd");
|
||||
const profile = params.get("poolProfile");
|
||||
return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null;
|
||||
})() as { tool: PoolTool; profile: string } | null;
|
||||
const [adding, setAdding] = useState(() => isAdmin && preset !== null);
|
||||
const closeAdd = () => {
|
||||
setAdding(false);
|
||||
if (!preset) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("poolAdd");
|
||||
next.delete("poolProfile");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
|
||||
@@ -102,13 +121,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -235,10 +257,11 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
preset={preset}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
closeAdd();
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
@@ -412,19 +435,27 @@ function EditAccountDialog({
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
/** What each tool's pool account can come from (server/routes/accounts.js). */
|
||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||
claude: ["profile", "credentials", "token"],
|
||||
codex: ["profile", "token"],
|
||||
};
|
||||
|
||||
function AddAccountDialog({
|
||||
preset,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** A saved profile to move in, from its "Move to pool" link. */
|
||||
preset?: { tool: PoolTool; profile: string } | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>("claude");
|
||||
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [profile, setProfile] = useState(preset?.profile ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -432,13 +463,14 @@ function AddAccountDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles(null);
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
}, [tool]);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
@@ -449,10 +481,10 @@ function AddAccountDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
@@ -491,29 +523,36 @@ function AddAccountDialog({
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value as PoolTool);
|
||||
setProfile("");
|
||||
setSource("profile");
|
||||
}}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
|
||||
>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{SOURCES[tool].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tool === "codex" && s === "token"
|
||||
? t("pool.secret.codex")
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file PasswordRules.tsx
|
||||
* @description The checklist under a "new password" field the account picks
|
||||
* for itself (Settings → Account, the first-login password change, an admin's
|
||||
* own password on the Users page): each rule of `lib/passwordRules.ts`, ticked
|
||||
* as the password typed passes it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_RULES, passwordProblems } from "../../lib/passwordRules";
|
||||
|
||||
export function PasswordRules({ password, username }: { password: string; username?: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
const failed = new Set(passwordProblems(password, username));
|
||||
return (
|
||||
<ul className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2" data-testid="password-rules">
|
||||
{PASSWORD_RULES.map((rule) => {
|
||||
const ok = password.length > 0 && !failed.has(rule);
|
||||
return (
|
||||
<li
|
||||
key={rule}
|
||||
className={`flex items-center gap-1.5 ${ok ? "text-status-success" : "text-fg-muted"}`}
|
||||
data-rule={rule}
|
||||
data-ok={ok}
|
||||
>
|
||||
{ok ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
{t(`passwordRules.${rule}`, { min: PASSWORD_MIN_LENGTH })}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
@@ -20,6 +22,8 @@ import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { PasswordRules } from "./PasswordRules";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
@@ -34,11 +38,15 @@ export const ROLE_BADGE: Record<Role, string> = {
|
||||
};
|
||||
|
||||
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
export function generatePassword(n = 16, username?: string): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
// Drawn again until it passes every rule, so it also fits an own password.
|
||||
for (;;) {
|
||||
crypto.getRandomValues(bytes);
|
||||
const out = Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
if (passwordProblems(out, username).length === 0) return out;
|
||||
}
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
@@ -110,8 +118,11 @@ export function UserEditDialog({
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -309,7 +320,10 @@ export function UserEditDialog({
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<Field
|
||||
label={t("newPassword")}
|
||||
hint={self ? t("edit.ownPasswordHint") : t("edit.passwordHint")}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
@@ -343,7 +357,7 @@ export function UserEditDialog({
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setPassword(generatePassword(16, user.username));
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
@@ -351,6 +365,11 @@ export function UserEditDialog({
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{self && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<PasswordRules password={password} username={user.username} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
@@ -30,6 +31,13 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
||||
const card = (isAdmin: boolean, url = "/admin") => (
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
@@ -91,7 +99,7 @@ beforeEach(() => {
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
const { unmount } = render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
@@ -102,14 +110,14 @@ describe("AccountPoolCard", () => {
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
@@ -134,7 +142,7 @@ describe("AccountPoolCard", () => {
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
@@ -159,7 +167,7 @@ describe("AccountPoolCard", () => {
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -184,7 +192,7 @@ describe("AccountPoolCard", () => {
|
||||
removed: [{ userId: 8, username: "leo" }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||
@@ -206,4 +214,40 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
||||
accountPool.profiles.mockImplementation(async (tool: string) =>
|
||||
tool === "codex"
|
||||
? {
|
||||
profiles: [
|
||||
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
||||
],
|
||||
}
|
||||
: { profiles: [] }
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "codex", name: "cx", profile: "cx" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("links back to the profiles in Settings", async () => {
|
||||
render(card(false));
|
||||
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
||||
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"edit.description": "Description",
|
||||
"previewFailed": "Could not load this file.",
|
||||
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||
"section.hub.own": "Shared by me",
|
||||
"section.hub.ownHint": "Yours, visible to every user",
|
||||
"section.hub.others": "Shared by others",
|
||||
"section.hub.othersHint": "What other users shared — install what you need",
|
||||
"section.mine.own": "Mine",
|
||||
"section.mine.ownHint": "What you added",
|
||||
"section.mine.others": "Installed from others",
|
||||
"section.mine.othersHint": "Shared by other users, installed in your account",
|
||||
"section.all.others": "Shared by others",
|
||||
"section.all.othersHint": "What other users shared",
|
||||
"section.all.own": "Mine",
|
||||
"section.all.ownHint": "What you added, shared or not",
|
||||
"pages": "Pages",
|
||||
"section.empty": "Nothing here yet.",
|
||||
"tab.all": "All",
|
||||
"installedBadge": "Installed",
|
||||
"installsTitle": "Installs",
|
||||
@@ -126,12 +140,7 @@
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
"reinstall": "Reinstall",
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallHint": "Remove it from your account",
|
||||
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||
@@ -145,7 +154,7 @@
|
||||
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you uninstall it and install it again.",
|
||||
"preview.instructions": "Instructions",
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||
"logout": "Sign out",
|
||||
"noAccount": "No account yet? Ask your administrator to create one.",
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Choose your own password",
|
||||
"mustChangeSubtitle": "Your administrator gave you a starting password. Choose your own before you continue.",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ",
|
||||
"mustChangeSubmit": "Save and continue",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Settings → AI tools → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -1140,6 +1140,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
"hint": "Every user's omp sessions run on the logins the host account made with `omp login`, through omp's own auth broker and gateway on this machine. A session gets only a key that works while its pane runs, never the login itself. Only omp's built-in providers are shared.",
|
||||
"running": "Running: broker and gateway are up.",
|
||||
"starting": "Starting…",
|
||||
"notInstalled": "omp is not installed on this server.",
|
||||
"failed": "Not running: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Open Markdown as",
|
||||
"description": "How a .md file opens in Files, Changes, the config viewer and a chat's file panel. Its Preview/Raw button still switches that one file. Saved to your account.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Workspace tabs",
|
||||
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
|
||||
@@ -1339,7 +1354,10 @@
|
||||
"remove": "Remove picture",
|
||||
"saved": "Picture updated",
|
||||
"removed": "Picture removed",
|
||||
"tooBig": "The picture must be at most 1 MB"
|
||||
"tooBig": "The picture must be at most 1 MB",
|
||||
"fromUrl": "Use a picture from a link",
|
||||
"urlPlaceholder": "https://… image link",
|
||||
"urlApply": "Use"
|
||||
},
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
@@ -1367,5 +1385,32 @@
|
||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||
"signedOut_one": "Signed out {{count}} other device",
|
||||
"signedOut_other": "Signed out {{count}} other devices"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Profiles",
|
||||
"description": "Every AI tool's saved logins in one place. Switching swaps only the login; config, skills, sessions and history stay as they are.",
|
||||
"poolCount_one": "The account pool holds {{count}} account for this tool.",
|
||||
"poolCount_other": "The account pool holds {{count}} accounts for this tool.",
|
||||
"openPool": "Open the account pool",
|
||||
"moveToPool": "Move to pool",
|
||||
"moveToPoolHint": "Administration → Users → Account pool, with this profile chosen. The pool becomes the login's only holder and the profile is removed.",
|
||||
"saveButton": "Save this login as a profile",
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
"codex": "A profile is one Codex login (a ChatGPT account or an API key): the whole auth.json. config.toml, sessions, skills and history stay where they are.",
|
||||
"omp": "A profile is the full set of logins Oh My Pi stores (every provider), read with `omp auth export` and put back with `omp auth import`. Settings, models and sessions stay where they are. Running omp sessions pick up the switch on their own."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity keeps its login in the system keyring, which AgentHub does not read or write, so it cannot be switched from here. Sign in or out inside agy itself."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "ChatGPT login",
|
||||
"apikey": "API key",
|
||||
"credentials": "Logins"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "No minimum length",
|
||||
"passwordHint": "Any starting password — they choose their own at first sign-in",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
|
||||
"ETOOBIG": "The picture must be at most 1 MB",
|
||||
"EBADURL": "Enter an https:// link to a picture.",
|
||||
"EPRIVATEURL": "That link points to a private or local address.",
|
||||
"EFETCH": "Could not download the picture from that link.",
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||
"EBADROLE": "Unknown role.",
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted."
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere, and they choose their own at their next sign-in.",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"copy": "Copy password",
|
||||
@@ -122,7 +127,8 @@
|
||||
"sessions": "Sign-ins",
|
||||
"signOut": "Sign out everywhere",
|
||||
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||
"noChanges": "No changes yet."
|
||||
"noChanges": "No changes yet.",
|
||||
"ownPasswordHint": "Leave blank to keep the current one. Your other sign-ins end."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
@@ -171,19 +177,20 @@
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
"profile": "A saved login (Switch account profiles)",
|
||||
"profile": "A saved profile (Settings → AI tools → Profiles)",
|
||||
"credentials": "Paste .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||
"chooseProfile": "Choose a saved login…",
|
||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||
"noProfiles": "No saved profiles — save one in Settings → AI tools → Profiles",
|
||||
"loadingProfiles": "Loading…",
|
||||
"activeHere": "in use on this server — keeps working",
|
||||
"nameFromProfile": "Leave blank to use the profile's name",
|
||||
@@ -210,7 +217,8 @@
|
||||
"users": "Users on this account",
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved"
|
||||
"editDone": "{{name}} saved",
|
||||
"profilesLink": "Profiles in Settings"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
@@ -253,5 +261,15 @@
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
},
|
||||
"mustChange": "New password due",
|
||||
"mustChangeHint": "Signed in with a password an admin gave; must choose their own before using AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "At least {{min}} characters",
|
||||
"lower": "A lower-case letter",
|
||||
"upper": "An upper-case letter",
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"username": "Does not contain the username"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,20 @@
|
||||
"edit.description": "Mô tả",
|
||||
"previewFailed": "Không tải được file này.",
|
||||
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||
"section.hub.own": "Tôi đã share",
|
||||
"section.hub.ownHint": "Của bạn, mọi người dùng đều thấy",
|
||||
"section.hub.others": "Người khác share",
|
||||
"section.hub.othersHint": "Những gì người dùng khác đã share — cài cái bạn cần",
|
||||
"section.mine.own": "Của tôi",
|
||||
"section.mine.ownHint": "Những gì bạn đã thêm",
|
||||
"section.mine.others": "Đã cài từ người khác",
|
||||
"section.mine.othersHint": "Người dùng khác share, đã cài vào tài khoản của bạn",
|
||||
"section.all.others": "Người khác share",
|
||||
"section.all.othersHint": "Những gì người dùng khác đã share",
|
||||
"section.all.own": "Của tôi",
|
||||
"section.all.ownHint": "Những gì bạn đã thêm, đã share hoặc chưa",
|
||||
"pages": "Trang",
|
||||
"section.empty": "Chưa có gì.",
|
||||
"tab.all": "Tất cả",
|
||||
"installedBadge": "Đã cài",
|
||||
"installsTitle": "Lượt cài",
|
||||
@@ -126,12 +140,7 @@
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
"reinstall": "Cài lại",
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
|
||||
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||
@@ -145,7 +154,7 @@
|
||||
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bạn gỡ cài đặt rồi cài lại.",
|
||||
"preview.instructions": "Hướng dẫn",
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
||||
"logout": "Đăng xuất",
|
||||
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Đặt mật khẩu của bạn",
|
||||
"mustChangeSubtitle": "Quản trị viên đã cấp cho bạn mật khẩu ban đầu. Hãy đặt mật khẩu riêng trước khi tiếp tục.",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||
"mismatch": "Hai mật khẩu mới không khớp",
|
||||
"mustChangeSubmit": "Lưu và tiếp tục",
|
||||
"saving": "Đang lưu…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Cài đặt → AI tools → Hồ sơ đăng nhập để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -1140,6 +1140,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||
"hint": "Mọi session omp của người dùng chạy bằng các đăng nhập mà tài khoản máy chủ đã tạo bằng `omp login`, qua auth broker và gateway của chính omp trên máy này. Session chỉ nhận một key dùng được khi pane còn chạy, không bao giờ nhận chính đăng nhập. Chỉ các provider có sẵn của omp được dùng chung.",
|
||||
"running": "Đang chạy: broker và gateway đã sẵn sàng.",
|
||||
"starting": "Đang khởi động…",
|
||||
"notInstalled": "Máy chủ chưa cài omp.",
|
||||
"failed": "Không chạy: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Mở file Markdown dạng",
|
||||
"description": "Cách file .md mở trong Files, Changes, trình xem cấu hình và bảng file của chat. Nút Xem trước/Mã nguồn vẫn đổi riêng file đó. Lưu theo tài khoản.",
|
||||
"preview": "Xem trước",
|
||||
"raw": "Mã nguồn"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Tab của workspace",
|
||||
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
|
||||
@@ -1339,7 +1354,10 @@
|
||||
"remove": "Xoá ảnh đại diện",
|
||||
"saved": "Đã cập nhật ảnh đại diện",
|
||||
"removed": "Đã xoá ảnh đại diện",
|
||||
"tooBig": "Ảnh tối đa 1 MB"
|
||||
"tooBig": "Ảnh tối đa 1 MB",
|
||||
"fromUrl": "Dùng ảnh từ đường dẫn",
|
||||
"urlPlaceholder": "https://… đường dẫn ảnh",
|
||||
"urlApply": "Dùng"
|
||||
},
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
@@ -1367,5 +1385,32 @@
|
||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Hồ sơ đăng nhập",
|
||||
"description": "Mọi đăng nhập đã lưu của các AI tool ở một nơi. Khi đổi chỉ phần đăng nhập được hoán đổi; config, skills, phiên làm việc và lịch sử vẫn giữ nguyên.",
|
||||
"poolCount_one": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"poolCount_other": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"openPool": "Mở account pool",
|
||||
"moveToPool": "Đưa vào pool",
|
||||
"moveToPoolHint": "Quản trị → Người dùng → Account pool, chọn sẵn hồ sơ này. Pool sẽ là nơi duy nhất giữ đăng nhập này và hồ sơ được xoá.",
|
||||
"saveButton": "Lưu đăng nhập này thành hồ sơ",
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
"codex": "Mỗi hồ sơ là một đăng nhập Codex (tài khoản ChatGPT hoặc API key): toàn bộ auth.json. config.toml, phiên, skills và lịch sử vẫn giữ nguyên.",
|
||||
"omp": "Mỗi hồ sơ là toàn bộ các đăng nhập Oh My Pi đang lưu (mọi provider), đọc bằng `omp auth export` và ghi lại bằng `omp auth import`. Settings, model và phiên vẫn giữ nguyên. Các phiên omp đang chạy tự nhận đăng nhập mới."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity lưu đăng nhập trong keyring của hệ điều hành, AgentHub không đọc hay ghi vào đó nên không đổi được từ đây. Hãy đăng nhập/đăng xuất ngay trong agy."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "Đăng nhập ChatGPT",
|
||||
"apikey": "API key",
|
||||
"credentials": "Đăng nhập"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||
"passwordHint": "Mật khẩu ban đầu tuỳ ý — người dùng sẽ tự đặt mật khẩu riêng ở lần đăng nhập đầu",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
|
||||
"ETOOBIG": "Ảnh tối đa 1 MB",
|
||||
"EBADURL": "Hãy nhập đường dẫn https:// tới một ảnh.",
|
||||
"EPRIVATEURL": "Đường dẫn trỏ tới địa chỉ nội bộ hoặc cục bộ.",
|
||||
"EFETCH": "Không tải được ảnh từ đường dẫn này.",
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||
"EBADROLE": "Vai trò không hợp lệ.",
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá admin cuối cùng."
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi và họ phải tự đặt mật khẩu riêng ở lần đăng nhập sau.",
|
||||
"show": "Hiện mật khẩu",
|
||||
"hide": "Ẩn mật khẩu",
|
||||
"copy": "Sao chép mật khẩu",
|
||||
@@ -122,7 +127,8 @@
|
||||
"sessions": "Phiên đăng nhập",
|
||||
"signOut": "Đăng xuất ở mọi nơi",
|
||||
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||
"noChanges": "Chưa có thay đổi."
|
||||
"noChanges": "Chưa có thay đổi.",
|
||||
"ownPasswordHint": "Để trống để giữ mật khẩu hiện tại. Các phiên đăng nhập khác của bạn sẽ kết thúc."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
@@ -171,19 +177,20 @@
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
"profile": "Login đã lưu (profile Switch account)",
|
||||
"profile": "Hồ sơ đã lưu (Cài đặt → AI tools → Hồ sơ đăng nhập)",
|
||||
"credentials": "Dán .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
||||
"chooseProfile": "Chọn login đã lưu…",
|
||||
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||
"noProfiles": "Chưa có hồ sơ nào — lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập",
|
||||
"loadingProfiles": "Đang tải…",
|
||||
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||
"nameFromProfile": "Để trống để dùng tên profile",
|
||||
@@ -210,7 +217,8 @@
|
||||
"users": "User dùng tài khoản này",
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}"
|
||||
"editDone": "Đã lưu {{name}}",
|
||||
"profilesLink": "Hồ sơ trong Cài đặt"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
@@ -253,5 +261,15 @@
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
},
|
||||
"mustChange": "Chờ đổi mật khẩu",
|
||||
"mustChangeHint": "Đang dùng mật khẩu do admin cấp; phải tự đặt mật khẩu riêng trước khi dùng AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "Ít nhất {{min}} ký tự",
|
||||
"lower": "Có chữ thường",
|
||||
"upper": "Có chữ hoa",
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
}
|
||||
}
|
||||
|
||||
+77
-1
@@ -699,6 +699,29 @@ export type PoolProfile = {
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
||||
export type ToolProfileIdentity = {
|
||||
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
||||
kind: string;
|
||||
email: string | null;
|
||||
/** codex: the ChatGPT plan or `…1234` of a key; omp: its providers. */
|
||||
detail: string | null;
|
||||
};
|
||||
export type ToolProfile = ToolProfileIdentity & {
|
||||
name: string;
|
||||
label: string;
|
||||
savedAt: number | null;
|
||||
active: boolean;
|
||||
};
|
||||
export type ToolProfilesTool = {
|
||||
tool: string;
|
||||
supported: boolean;
|
||||
/** Why a tool cannot be switched (`supported: false`). */
|
||||
reason?: string;
|
||||
items: ToolProfile[];
|
||||
active: ToolProfileIdentity | null;
|
||||
error?: { code?: string; message: string } | null;
|
||||
};
|
||||
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||
export type PoolAccountInput = {
|
||||
tool: PoolTool;
|
||||
@@ -733,6 +756,8 @@ export type AuthUser = {
|
||||
/** When the account's avatar was last set (null = none) — versions its URL. */
|
||||
avatarAt?: string | null;
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
@@ -1007,6 +1032,9 @@ export const api = {
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/me/avatar/url — the server downloads the picture from an https link. */
|
||||
setAvatarFromUrl: (url: string) =>
|
||||
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
@@ -1091,6 +1119,33 @@ export const api = {
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
|
||||
toolProfiles: {
|
||||
/** GET /api/tool-profiles — every covered tool, switchable or not. */
|
||||
list: () => request<{ tools: ToolProfilesTool[] }>("/tool-profiles"),
|
||||
/** POST /api/tool-profiles/:tool — save the login the tool holds now. */
|
||||
save: (tool: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
),
|
||||
rename: (tool: string, name: string, label: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ name, label }) }
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
remove: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string }>(`/tool-profiles/${encodeURIComponent(tool)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
},
|
||||
/** The Claude/Codex account pool every member's new panes run on
|
||||
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||
accountPool: {
|
||||
@@ -1098,7 +1153,10 @@ export const api = {
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||
profiles: (tool: PoolTool = "claude") =>
|
||||
request<{ profiles: PoolProfile[] }>(
|
||||
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
|
||||
),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
@@ -3051,6 +3109,14 @@ export const api = {
|
||||
aiTools: {
|
||||
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
|
||||
list: () => request<AiToolsView>("/ai-tools"),
|
||||
/** GET /api/ai-tools/omp-sharing - the host's Oh My Pi login shared with members (S3). */
|
||||
ompSharing: () => request<OmpSharingState>("/ai-tools/omp-sharing"),
|
||||
/** PUT /api/ai-tools/omp-sharing - an admin's switch. */
|
||||
setOmpSharing: (enabled: boolean) =>
|
||||
request<OmpSharingState>("/ai-tools/omp-sharing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||
@@ -4307,6 +4373,16 @@ export interface ArtifactCandidate {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||
export interface OmpSharingState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
broker: { running: boolean; error?: string | null };
|
||||
gateway: { running: boolean; port?: number; error?: string | null };
|
||||
}
|
||||
|
||||
export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file markdownView — Settings → Appearance → "Open Markdown as": a viewer
|
||||
* preference in `localStorage` (`agenthub.markdown.view`, synced to the
|
||||
* account through `lib/userPrefs.ts`). Preview (the default) opens a `.md`
|
||||
* file rendered; Raw opens its source. Read each time a viewer opens a file
|
||||
* (the Files/Changes panel, the config file viewer, a chat artifact); the
|
||||
* viewer's own Preview/Raw toggle still flips that one file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const MARKDOWN_VIEW_KEY = "agenthub.markdown.view";
|
||||
|
||||
/** True when a Markdown file opens rendered (the default). */
|
||||
export function readMarkdownPreview(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(MARKDOWN_VIEW_KEY) !== "raw";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMarkdownPreview(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.removeItem(MARKDOWN_VIEW_KEY);
|
||||
else window.localStorage.setItem(MARKDOWN_VIEW_KEY, "raw");
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file passwordRules.ts
|
||||
* @description The rules a password an account picks for ITSELF must pass —
|
||||
* a copy of `passwordProblems` in server/lib/auth.js, so the checklist can
|
||||
* tick each one as it is typed. The server's copy is the one that decides
|
||||
* (EWEAKPASSWORD); a password an admin gives someone else has no rules.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export const PASSWORD_RULES = ["length", "lower", "upper", "digit", "symbol", "username"] as const;
|
||||
export type PasswordRule = (typeof PASSWORD_RULES)[number];
|
||||
|
||||
/** The rules `password` fails (empty = it passes every one). */
|
||||
export function passwordProblems(password: string, username?: string): PasswordRule[] {
|
||||
const out: PasswordRule[] = [];
|
||||
if ([...password].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(password)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(password)) out.push("upper");
|
||||
if (!/\p{N}/u.test(password)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(password)) out.push("symbol");
|
||||
const name = (username || "").toLowerCase();
|
||||
if (name && password.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
@@ -313,9 +313,21 @@ export const SETTINGS_INDEX: {
|
||||
to: "?tab=settings",
|
||||
},
|
||||
{
|
||||
section: "agents/claude-code",
|
||||
titleKey: "ccConfig:profiles.title",
|
||||
keywords: ["account", "login", "quota", "switch"],
|
||||
section: "agents",
|
||||
titleKey: "toolProfiles.title",
|
||||
keywords: [
|
||||
"profile",
|
||||
"account",
|
||||
"login",
|
||||
"quota",
|
||||
"switch",
|
||||
"codex",
|
||||
"omp",
|
||||
"pool",
|
||||
"hồ sơ",
|
||||
"tài khoản",
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
|
||||
@@ -43,6 +43,7 @@ export const PREF_KEYS = [
|
||||
"agenthub.codex.bypass",
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
+254
-417
File diff suppressed because it is too large
Load Diff
+11
-493
@@ -10,8 +10,10 @@
|
||||
* concurrent-write races with the live CLI. Its Overview also holds the
|
||||
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
|
||||
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
|
||||
* The toolbar's Switch profile button links to Settings → AI tools → Profiles
|
||||
* (`ToolProfilesSettings`), where every AI tool's login profiles live.
|
||||
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
|
||||
* files, auth profiles, and the `MdItemList` artifacts) also opens a
|
||||
* files, and the `MdItemList` artifacts) also opens a
|
||||
* right-click menu that repeats those buttons — same handlers, same
|
||||
* confirm path — plus Copy name/path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -61,7 +63,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import {
|
||||
Boxes,
|
||||
@@ -89,7 +92,6 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Lock,
|
||||
History,
|
||||
Terminal,
|
||||
@@ -153,10 +155,6 @@ import type {
|
||||
CcPluginItem,
|
||||
CcPluginsResponse,
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
@@ -450,7 +448,6 @@ export function CcConfig() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
||||
const setToast = showToast;
|
||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
||||
|
||||
const scopeRef = useRef(scope);
|
||||
@@ -682,7 +679,6 @@ export function CcConfig() {
|
||||
setAgenthubKey((k) => k + 1);
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
onOpenProfiles={() => setProfilesOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
|
||||
@@ -811,7 +807,6 @@ export function CcConfig() {
|
||||
/>
|
||||
)}
|
||||
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
||||
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -823,20 +818,13 @@ interface HeaderProps {
|
||||
lastUpdated: Date | null;
|
||||
onRefresh: () => void;
|
||||
onOpenBackups: () => void;
|
||||
onOpenProfiles: () => void;
|
||||
wsConnected: boolean;
|
||||
}
|
||||
|
||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||
function Header({
|
||||
loading,
|
||||
lastUpdated,
|
||||
onRefresh,
|
||||
onOpenBackups,
|
||||
onOpenProfiles,
|
||||
wsConnected,
|
||||
}: HeaderProps) {
|
||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const settingsHref = useSettingsHref();
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
const formatted = lastUpdated
|
||||
? lastUpdated.toLocaleTimeString(undefined, {
|
||||
@@ -863,14 +851,14 @@ function Header({
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProfiles}
|
||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
||||
<Link
|
||||
to={settingsHref("agents#tool-profiles")}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" />
|
||||
{t("profiles.openButton")}
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBackups}
|
||||
@@ -4737,476 +4725,6 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth profiles modal ────────────────────────────────────────────────
|
||||
//
|
||||
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||
// they are — that is the whole point of the feature.
|
||||
//
|
||||
// The dashboard cannot log a new account in (only `claude auth login` can mint
|
||||
// tokens), so adding a profile is always "log in, then snapshot what the CLI
|
||||
// wrote" — hence a save button on the live account rather than an add-account
|
||||
// flow. There is no name field: the server derives the identifier, and the user
|
||||
// edits the display label in place by clicking it. That keeps the one string
|
||||
// the user types free of filename rules, so spaces and diacritics are fine.
|
||||
// The save button only appears while the live account is unsaved, because one
|
||||
// account is one profile — a second copy would just report itself active too.
|
||||
|
||||
function ProfilesModal({
|
||||
onClose,
|
||||
onToast,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onToast: (t: NonNullable<Toast>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives — the whole point of the panel is comparing accounts, and one
|
||||
* button per row made the user do that by hand. The cost is bounded: the
|
||||
* live account is read from the CLI and never refreshed, and any other
|
||||
* profile is refreshed only when its token has actually expired, which
|
||||
* leaves it valid for hours of further opens. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const menuOpen = useRef(false);
|
||||
menuOpen.current = !!ctx.menu;
|
||||
// An open right-click menu takes this Escape for itself.
|
||||
useDialog(true, panelRef, () => {
|
||||
if (!menuOpen.current) onClose();
|
||||
});
|
||||
|
||||
/** Run one profile mutation, then refresh the list. */
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
onToast({ kind: "success", message: success });
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : "unknown error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, as soon as there is a list to ask about. Once per
|
||||
// profile per opening: a retry is the row's own button, so a failed account
|
||||
// is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
/** Deletable unless it is the ONLY saved copy of the live account — a
|
||||
* redundant copy has to be removable, or an accidental duplicate could
|
||||
* never be cleaned up. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const startRename = (p: CcProfile) => {
|
||||
setDraft(p.label);
|
||||
setRenaming(p.name);
|
||||
};
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
// A saved profile holds that account's OAuth credentials — deleting it
|
||||
// cannot be undone from here, so it asks first.
|
||||
const remove = async (p: CcProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("profiles.deleteConfirmBody"),
|
||||
confirmLabel: t("profiles.deleteButton"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
};
|
||||
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||
const p = ctx.menu!.item;
|
||||
ctx.close();
|
||||
fn(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-profiles-title"
|
||||
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<UserCog className="w-4 h-4 text-fg-muted" />
|
||||
<span id="cc-profiles-title" className="text-sm font-medium text-fg-primary flex-1">
|
||||
{t("profiles.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{renaming === p.name ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setRenaming(null);
|
||||
if (!next || next === p.label) return;
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startRename(p)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{p.label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
)}
|
||||
{p.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() => switchTo(p)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? t("profiles.switching")
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
<MenuItem onSelect={pick(startRename)}>{t("profiles.rename")}</MenuItem>
|
||||
{!ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||
onSelect={pick((p) => void checkUsage(p.name))}
|
||||
>
|
||||
{t("profiles.usageCheck")}
|
||||
</MenuItem>
|
||||
{deletable(ctx.menu.item) && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy !== null}
|
||||
testId="profile-menu-delete"
|
||||
onSelect={pick((p) => void remove(p))}
|
||||
>
|
||||
{t("profiles.deleteButton")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
||||
<CommandSnippet command="claude auth login" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
// ── Backups modal ──────────────────────────────────────────────────────
|
||||
|
||||
function BackupsModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
@@ -69,6 +69,8 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -295,8 +297,12 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
||||
<ToolProfilesSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
<OmpSharingSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set; right-clicking a row offers
|
||||
* name and email under the username when set, and "New password due" while
|
||||
* the account still has a password an admin gave; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
@@ -273,6 +274,15 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{!u.disabled && u.mustChangePassword && (
|
||||
<span
|
||||
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={t("mustChangeHint")}
|
||||
data-testid="user-must-change"
|
||||
>
|
||||
{t("mustChange")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
|
||||
@@ -227,6 +227,66 @@ describe("Artifacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("splits the Hub and My library into yours and others'", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 1, title: "my-page", shared: true }),
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "their-skill",
|
||||
mine: false,
|
||||
owner: "an",
|
||||
shared: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
// All is split too, others' first.
|
||||
const allOthers = await screen.findByTestId("half-all.others");
|
||||
expect(within(allOthers).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(
|
||||
allOthers.compareDocumentPosition(screen.getByTestId("half-all.own")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
await user.click(await screen.findByRole("button", { name: "Hub" }));
|
||||
const own = await screen.findByTestId("half-hub.own");
|
||||
expect(own).toHaveTextContent("Shared by me");
|
||||
expect(within(own).getByText("my-page")).toBeInTheDocument();
|
||||
const others = screen.getByTestId("half-hub.others");
|
||||
expect(within(others).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(within(others).queryByText("my-page")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "My library" }));
|
||||
expect(await screen.findByTestId("half-mine.others")).toHaveTextContent(
|
||||
"Installed from others"
|
||||
);
|
||||
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages a long section 12 cards at a time", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: Array.from({ length: 14 }, (_, i) =>
|
||||
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
||||
),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("p1");
|
||||
expect(screen.queryByText("p13")).toBeNull();
|
||||
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("p13")).toBeInTheDocument();
|
||||
expect(screen.queryByText("p1")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
@@ -287,13 +347,13 @@ describe("Artifacts", () => {
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
// No Reinstall anywhere, and Uninstall lives only in the ⋯ menu.
|
||||
expect(within(card).queryByRole("button", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(card).queryByRole("button", { name: "Uninstall" })).toBeNull();
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "More actions" }));
|
||||
const menu = screen.getByTestId("artifact-menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Uninstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin only after a confirmation", async () => {
|
||||
@@ -307,7 +367,13 @@ describe("Artifacts", () => {
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
const uninstall = async () => {
|
||||
await user.click(within(card).getByRole("button", { name: "More actions" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Uninstall" })
|
||||
);
|
||||
};
|
||||
await uninstall();
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
||||
|
||||
@@ -316,7 +382,7 @@ describe("Artifacts", () => {
|
||||
uninstalled: "tool@market",
|
||||
paths: [],
|
||||
});
|
||||
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
||||
await uninstall();
|
||||
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||
* same endpoint the trash button does.
|
||||
* takes two clicks before it reaches the API. (The profiles' menu moved with
|
||||
* them to Settings → AI tools: ToolProfilesSettings.test.tsx.)
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { settleConfirm } from "../../lib/confirm";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
@@ -124,41 +123,4 @@ describe("CcConfig — right-click menus", () => {
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
// A saved login is never deleted on one click: it asks first.
|
||||
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
|
||||
* it asks for EVERY saved account's quota, not one row at a time.
|
||||
*
|
||||
* The one-at-a-time version made the user click through their own accounts to
|
||||
* answer the only question the panel exists for — which account still has room.
|
||||
* The other half is that each row fails on its own: an account whose saved
|
||||
* login has been superseded must not stop its neighbours from reporting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
const window = (utilization: number) => ({
|
||||
utilization,
|
||||
resetsAt: "2026-09-18T08:00:00Z",
|
||||
});
|
||||
return {
|
||||
window,
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcp: r({ mcp: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
// The modal's own list: two saved accounts, one of them live.
|
||||
profiles: r({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
}),
|
||||
profileUsage: vi.fn().mockImplementation(async (name: string) => {
|
||||
if (name === "spare") {
|
||||
throw new Error("this profile's saved login has been superseded");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
refreshed: false,
|
||||
fiveHour: window(8),
|
||||
sevenDay: window(70),
|
||||
checkedAt: "2026-09-17T16:00:00Z",
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfiles() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("CcConfig — auth profile quotas", () => {
|
||||
it("asks for every saved account's quota when the modal opens", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText("70%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the account that failed without costing the others their numbers", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("8%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks once per profile, however many times the panel re-renders", async () => {
|
||||
await openProfiles();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
// AI tools → Profiles.
|
||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -796,9 +796,9 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
<div
|
||||
class="flex items-center gap-2 justify-end flex-wrap"
|
||||
>
|
||||
<button
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
type="button"
|
||||
href="/settings/agents#tool-profiles"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-user-cog w-3.5 h-3.5"
|
||||
@@ -851,7 +851,7 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
Switch profile
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
type="button"
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.14 (rebuild client to match)
|
||||
UI build v2.8.17 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+22
-7
@@ -85,13 +85,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`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`). `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.
|
||||
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`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). 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`):
|
||||
|
||||
@@ -104,10 +104,11 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
|
||||
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `POST /api/me/avatar/url` (`{url}`) | the same picture, downloaded by the SERVER from an `https://` link (no userinfo; every resolved address — an IP literal too — must be public, never loopback/private/link-local/CGNAT/reserved, and the connection is pinned to the vetted address; ≤ 3 redirects, each re-checked; 10 s timeout; ≤ 1 MB read; no cookie sent), then checked like an upload (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`/`EBADIMAGE`, `413 ETOOBIG`, `502 EFETCH`, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
@@ -2918,6 +2919,8 @@ Every history row carries `lane_id` (the workspace whose run it belongs to) and
|
||||
GET /api/ai-tools Catalog + detection — { tools: [...], default }
|
||||
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
|
||||
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
|
||||
GET /api/ai-tools/omp-sharing { available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?} } — S3 only, never a token
|
||||
PUT /api/ai-tools/omp-sharing Body: { enabled: boolean } — an admin's; 409 ENOS3 without per-user accounts; audited
|
||||
GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
|
||||
GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced
|
||||
GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
|
||||
@@ -2935,6 +2938,18 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
|
||||
|
||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||
|
||||
### AI tool login profiles
|
||||
|
||||
```
|
||||
GET /api/tool-profiles
|
||||
POST /api/tool-profiles/:tool Body: { name? }
|
||||
PATCH /api/tool-profiles/:tool Body: { name, label }
|
||||
POST /api/tool-profiles/:tool/activate Body: { name }
|
||||
DELETE /api/tool-profiles/:tool Body: { name }
|
||||
```
|
||||
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
```
|
||||
@@ -3056,16 +3071,16 @@ GET /api/artifacts/:id/installs {items: [{user, mine, at, replace
|
||||
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
+1
-1
@@ -625,7 +625,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
|
||||
|
||||
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
||||
|
||||
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
|
||||
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns, and each admin account change; rows older than `AGENTHUB_AUDIT_DAYS` (default 180) are pruned as new ones are written. `users.last_login_at` is the account's last login (kept past its `auth_sessions` row); `users.must_change_password` (0/1) is set when an admin gives the password (create, reset) and cleared when the account picks its own.
|
||||
|
||||
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
|
||||
|
||||
|
||||
@@ -636,6 +636,10 @@ reads the workspace's git surface in five modes:
|
||||
A workspace folder that is **not a git repo** still gets the tree and
|
||||
quick-open, read off the disk (no colours; `.git` hidden, `node_modules`
|
||||
listed but never entered, symlinks never followed, at most 20 000 entries).
|
||||
A whole `.md` file opens rendered, with a **Raw**/**Preview** button for
|
||||
that file; Settings → Appearance → **Open Markdown as** (`agenthub.markdown.view`,
|
||||
`client/src/lib/markdownView.ts`, synced to the account) makes Raw the
|
||||
default instead — here, in the config file viewer and in a chat's file panel.
|
||||
**Download** on a file's right-click menu, or in an open file's header,
|
||||
saves that file (`/git/download`, any type, up to 512 MB); the **Upload**
|
||||
button beside the finder uploads files to the workspace root and **Upload
|
||||
|
||||
+79
-8
@@ -155,6 +155,10 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
- An **admin** or **analyst** sees everything. Each time one of them opens a
|
||||
session a developer owns (its page, transcript, events or cost), the read is
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
|
||||
all) — older ones are deleted as new ones are written, at most once an hour.
|
||||
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||
after the login itself has ended.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
@@ -264,7 +268,22 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
|
||||
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
|
||||
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
|
||||
change and disabling the user each end it.
|
||||
- Passwords are stored as scrypt hashes; any non-empty password is accepted (no minimum length).
|
||||
- Passwords are stored as scrypt hashes. Two bars:
|
||||
- A password an admin **gives** someone — **Add user**, a reset on the Edit
|
||||
form, `agenthub users add` — may be anything non-empty (`123` included).
|
||||
The account is then flagged `must_change_password`: after signing in it
|
||||
sees only a "Choose your own password" page, the server answers every
|
||||
other request with `403 EPWCHANGE` (only `/auth/*`, the `/me` reads and
|
||||
`POST /api/me/password` pass) and no WebSocket opens. A pane token is not
|
||||
held back — agents started before a reset keep running.
|
||||
- A password an account **picks for itself** (that page, Settings → Account,
|
||||
an admin's own on the Edit form) must pass every rule: at least 8
|
||||
characters, a lower-case and an upper-case letter, a digit, a symbol, not
|
||||
containing the username, and not the current password (`400
|
||||
EWEAKPASSWORD`, with the failed `rules`). The form ticks each rule as it is
|
||||
typed (`client/src/lib/passwordRules.ts` mirrors `passwordProblems` in
|
||||
`server/lib/auth.js`). `agenthub users passwd` and the first admin of
|
||||
`agenthub server setup` set no flag.
|
||||
Every login attempt counts before its password is checked, so attempts sent
|
||||
in parallel count too. After 10 failed logins from one address, or 30 for one
|
||||
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
|
||||
@@ -307,7 +326,10 @@ everywhere". Every field is checked before any is written, so a bad email or
|
||||
a workspace the user does not own changes nothing. Every change asks for the admin's OWN
|
||||
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
|
||||
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
|
||||
themselves (`ESELF`), and each change is written to the audit log. In local
|
||||
themselves (`ESELF`), no path (the CLI included) may demote, disable or
|
||||
delete the last enabled admin (`ELASTADMIN`), and each change is written to
|
||||
the audit log. A row says **New password due** while the account still has a
|
||||
password an admin gave. In local
|
||||
mode these routes refuse (`ELOCALMODE`): there is no login to check.
|
||||
|
||||
Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
||||
@@ -335,10 +357,10 @@ The same from the host's shell:
|
||||
|
||||
```bash
|
||||
agenthub users list
|
||||
agenthub users add <name> [--role admin|analyst|developer]
|
||||
agenthub users add <name> [--role admin|analyst|developer] # they choose their own password at first login
|
||||
agenthub users passwd <name> # ends that user's logins
|
||||
agenthub users role <name> <role>
|
||||
agenthub users disable <name> # ends that user's logins; users are never deleted
|
||||
agenthub users role <name> <role> # never the last enabled admin (ELASTADMIN)
|
||||
agenthub users disable <name> # ends that user's logins; never the last enabled admin
|
||||
agenthub users enable <name>
|
||||
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
|
||||
agenthub users audit [--limit N] # who read which developer's session
|
||||
@@ -366,7 +388,7 @@ In server mode an admin can do the same on the **Users** page, in the
|
||||
**Account pool** card: **Add account** and the bin icon to remove one. A Claude
|
||||
account comes from one of three sources:
|
||||
|
||||
- **A saved login**: a profile from Settings → Claude config → Switch account
|
||||
- **A saved login**: a profile from Settings → AI tools → Profiles
|
||||
(log in with `claude auth login`, then save it). It is MOVED into the pool, so
|
||||
the profile leaves the switcher. The account this server's own Claude Code is
|
||||
logged into can be moved in too. It moves with the login the CLI holds right
|
||||
@@ -379,8 +401,11 @@ account comes from one of three sources:
|
||||
using that login where you copied it from.
|
||||
- **A `claude setup-token` token** (`sk-ant-…`), as with `agenthub accounts add`.
|
||||
|
||||
A Codex account is an OpenAI API key, or the whole `~/.codex/auth.json` after
|
||||
`codex login`.
|
||||
A Codex account is an OpenAI API key, the whole `~/.codex/auth.json` after
|
||||
`codex login`, or a saved Codex profile from Settings → AI tools → Profiles
|
||||
(moved in like a Claude one). Each Claude/Codex profile there has a **Move to
|
||||
pool** link that opens this dialog on it, and the card links back with
|
||||
**Profiles in Settings**.
|
||||
|
||||
A **login** (the first two sources) works like a Codex ChatGPT login. Claude
|
||||
Code reads it from no env var, so before a member's pane starts, AgentHub writes
|
||||
@@ -507,6 +532,52 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## Oh My Pi login sharing
|
||||
|
||||
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
||||
login. An admin can let every member's omp run on the HOST account's omp
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
|
||||
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
broker and gateway (`server/lib/omp-share.js`):
|
||||
|
||||
```bash
|
||||
# as the host account: the logins to share (omp's built-in providers only)
|
||||
omp login # e.g. anthropic
|
||||
```
|
||||
|
||||
- While it is on, the dashboard runs, as the host and on loopback only,
|
||||
`omp auth-broker serve --bind=127.0.0.1:4826` over the host's `~/.omp` store
|
||||
and `omp auth-gateway serve --bind=127.0.0.1:4827 --clients=<data dir>/omp-share/clients.json`
|
||||
(ports: `AGENTHUB_OMP_BROKER_PORT`, `AGENTHUB_OMP_GATEWAY_PORT`; logs in
|
||||
`<data dir>/omp-share/`). Both tokens stay in the host's 0700 home; the
|
||||
broker's reaches only the gateway's env. A crashed daemon restarts with a
|
||||
back-off; the server stops both on shutdown, and kills ones a previous
|
||||
server left (by pid file and argv) before starting its own.
|
||||
- A member's omp pane gets one provider `agenthub` in its
|
||||
`~/.omp/agent/models.yml` (written as the member; the rest of the file is
|
||||
kept, a file that is not a YAML mapping is left alone), pointing at the
|
||||
gateway with `apiKey: AGENTHUB_OMP_KEY` — the NAME of the env var holding
|
||||
the pane's key — and `OMP_SKIP_SETUP=1`. It runs on `--model agenthub/<model>`:
|
||||
the model picked in AgentHub, else the host's own `modelRoles.default`
|
||||
(`~/.omp/agent/config.yml`) — left to itself omp would take the provider's
|
||||
first listed model, a retired one the API answers 404 for.
|
||||
- The gateway re-reads its clients file only every 10 s, and an omp that
|
||||
starts while its key is still refused hangs for good. So AgentHub keeps four
|
||||
keys QUEUED in the clients file (hashes only; the keys themselves in
|
||||
`<data dir>/omp-share/tokens.json`, 0600): a new pane takes one that is
|
||||
already listed, and a fresh key joins the queue. A pane's key leaves the
|
||||
file at the first sync (every 15 s) after its pane is gone, and turning
|
||||
sharing off empties both files at once.
|
||||
- A scoped client token calls models only: it cannot read a credential, the
|
||||
gateway's usage or credential health, and refresh tokens never leave the
|
||||
broker. Host panes use the host's login directly and get nothing new.
|
||||
- A custom provider in the host's own `models.yml` (a 9Router, say) is not
|
||||
shared — the gateway serves omp's built-in providers.
|
||||
|
||||
> **Ceiling:** an agent can SPEND through the shared logins while its pane
|
||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
||||
> provider out of their `models.yml` gets it back at their next omp pane.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
|
||||
+94
-3
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.13
|
||||
version: 2.8.17
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -12481,6 +12481,42 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsView'
|
||||
/api/ai-tools/omp-sharing:
|
||||
get:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Oh My Pi login sharing state
|
||||
description: 'Server mode with per-user accounts (S3): whether every member''s omp pane runs on the HOST account''s omp login through omp''s own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.'
|
||||
operationId: aiToolsOmpSharing
|
||||
responses:
|
||||
'200':
|
||||
description: '`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.'
|
||||
put:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Turn Oh My Pi login sharing on or off
|
||||
description: An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.
|
||||
operationId: aiToolsSetOmpSharing
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The new state, as GET.
|
||||
'400':
|
||||
description: EBADFIELD
|
||||
'403':
|
||||
description: Not an admin
|
||||
'409':
|
||||
description: ENOS3 — not server mode with per-user accounts
|
||||
/api/ai-tools/{id}:
|
||||
put:
|
||||
tags:
|
||||
@@ -14498,7 +14534,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: changeMyPassword
|
||||
summary: 'Change the account''s own password: the current one required (throttled); this login stays, every other ends'
|
||||
summary: 'Change the account''s own password: the current one required (throttled); the new one must pass every rule (8+ characters, lower, upper, digit, symbol, not the username) and differ from the current one; this login stays, every other ends; clears mustChangePassword'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -14518,7 +14554,7 @@ paths:
|
||||
'200':
|
||||
description: 'Changed ({ok: true})'
|
||||
'400':
|
||||
description: EBADPASSWORD
|
||||
description: EBADPASSWORD / EWEAKPASSWORD (error.rules names the failed rules)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14844,6 +14880,61 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/avatar/url:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setMyAvatarFromUrl
|
||||
summary: Set the account's picture from an https link the server downloads (public addresses only, at most 3 redirects, PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- url
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
responses:
|
||||
'200':
|
||||
description: Set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'400':
|
||||
description: EBADURL / EPRIVATEURL / EBADIMAGE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'413':
|
||||
description: ETOOBIG
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: EFETCH — the link could not be downloaded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/avatar:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.14",
|
||||
"version": "2.8.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.14",
|
||||
"version": "2.8.17",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.14",
|
||||
"version": "2.8.17",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -119,6 +119,34 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
const hook = (url) => ({
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: `Published: ${url}`,
|
||||
});
|
||||
const own = artifacts.captureFromHook("loose-1", hook("https://claude.ai/artifact/m1"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
assert.equal(own.owner_id, Number(uid));
|
||||
assert.equal(own.lane_id, null);
|
||||
// The host account names nobody; a workspace's owner still wins over the peer.
|
||||
const host = artifacts.captureFromHook("loose-2", hook("https://claude.ai/artifact/m2"), {
|
||||
kind: "host",
|
||||
});
|
||||
assert.equal(host.owner_id, null);
|
||||
const ws = artifacts.captureFromHook("s1", hook("https://claude.ai/artifact/m3"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
const laneOwner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(lane.id).owner_id;
|
||||
assert.equal(ws.owner_id, laneOwner);
|
||||
assert.equal(ws.lane_id, lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
@@ -145,6 +173,12 @@ describe("files", () => {
|
||||
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||
// Deleting the source on disk takes nothing away: only a delete in AgentHub does.
|
||||
const live = path.join(ws, "out", "report.html");
|
||||
fs.rmSync(live);
|
||||
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 200);
|
||||
assert.match((await request("GET", `/api/artifacts/${id}/raw`)).text, /<h1>hi<\/h1>/);
|
||||
fs.writeFileSync(live, "changed");
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
@@ -160,6 +194,32 @@ describe("files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("my library", () => {
|
||||
it("adds the shared skills of others the caller installed, and only those", () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, source, shared) VALUES ('skill', ?, ?, ?, ?)"
|
||||
);
|
||||
const theirs = Number(ins.run("theirs", 77, "lib-theirs", 1).lastInsertRowid);
|
||||
const notInstalled = Number(ins.run("other", 77, "lib-other", 1).lastInsertRowid);
|
||||
const unshared = Number(ins.run("private", 77, "lib-private", 0).lastInsertRowid);
|
||||
const own = Number(ins.run("own", 5, "lib-own", 0).lastInsertRowid);
|
||||
for (const name of ["lib-theirs", "lib-private"])
|
||||
fs.mkdirSync(path.join(process.env.CLAUDE_HOME, "skills", name), { recursive: true });
|
||||
const req = { user: { id: 5, role: "admin" } };
|
||||
const ids = artifacts.list(req, { scope: "mine", kind: "skill" }).map((a) => [a.id, a.mine]);
|
||||
assert.deepEqual(
|
||||
ids.filter(([id]) => [theirs, notInstalled, unshared, own].includes(id)).sort(),
|
||||
[
|
||||
[theirs, false],
|
||||
[own, true],
|
||||
].sort()
|
||||
);
|
||||
db.prepare(
|
||||
`DELETE FROM artifacts WHERE id IN (${[theirs, notInstalled, unshared, own].join(",")})`
|
||||
).run();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
|
||||
@@ -338,6 +338,42 @@ describe("moving a saved profile in from the Users page", () => {
|
||||
pool.removeAccount("work");
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and lists Codex profiles without a token", async () => {
|
||||
const dir = path.join(TMP, ".claude", "agenthub-profiles", "codex");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const idToken = `x.${Buffer.from(JSON.stringify({ email: "cx@example.com" })).toString("base64url")}.s`;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "cx.json"),
|
||||
JSON.stringify({
|
||||
name: "cx",
|
||||
label: "Codex work",
|
||||
secret: {
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: "at-cx",
|
||||
refresh_token: "rt-cx",
|
||||
account_id: "a-cx",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const list = await call("GET", "/api/accounts/profiles?tool=codex");
|
||||
assert.deepEqual(
|
||||
list.body.profiles.map((p) => [p.name, p.label, p.email]),
|
||||
[["cx", "Codex work", "cx@example.com"]]
|
||||
);
|
||||
assert.ok(!JSON.stringify(list.body).includes("rt-cx"));
|
||||
const moved = await call("POST", "/api/accounts", {
|
||||
tool: "codex",
|
||||
profile: "cx",
|
||||
currentPassword: "admin-pass-1",
|
||||
});
|
||||
assert.equal(moved.status, 201);
|
||||
assert.equal(moved.body.account.kind, "chatgpt");
|
||||
assert.equal(fs.existsSync(path.join(dir, "cx.json")), false, "the pool is its one holder");
|
||||
require("../lib/codex-pool").removeAccount("cx");
|
||||
});
|
||||
|
||||
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
|
||||
const pasted = oauth("p");
|
||||
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups.
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
|
||||
* never reaches a loopback/private address.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -108,6 +110,51 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("a picture from a link: https only, never a private or local address", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const post = (url) =>
|
||||
fetch(BASE + "/api/me/avatar/url", {
|
||||
method: "POST",
|
||||
headers: { cookie: ava, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
for (const [url, code] of [
|
||||
["not a url", "EBADURL"],
|
||||
["http://example.com/a.png", "EBADURL"],
|
||||
["https://user:pw@example.com/a.png", "EBADURL"],
|
||||
["https://127.0.0.1/a.png", "EPRIVATEURL"],
|
||||
["https://[::1]/a.png", "EPRIVATEURL"],
|
||||
["https://localhost/a.png", "EPRIVATEURL"],
|
||||
["https://169.254.169.254/latest", "EPRIVATEURL"],
|
||||
]) {
|
||||
const res = await post(url);
|
||||
assert.equal(res.status, 400, url);
|
||||
assert.equal((await res.json()).error.code, code, url);
|
||||
}
|
||||
assert.equal((await post("")).status, 400);
|
||||
|
||||
const { isPublicAddress } = require("../lib/avatar-url");
|
||||
for (const a of [
|
||||
"10.1.2.3",
|
||||
"172.20.0.1",
|
||||
"192.168.1.1",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"fd00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1",
|
||||
"2002:7f00::1",
|
||||
"ff02::1",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(a), false, a);
|
||||
}
|
||||
for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) {
|
||||
assert.equal(isPublicAddress(a), true, a);
|
||||
}
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @file omp-share.test.js
|
||||
* @description Oh My Pi login sharing (server/lib/omp-share.js): a member's
|
||||
* `models.yml` gets one `agenthub` provider pointing at the loopback gateway
|
||||
* with the key's env var NAME as its key (the rest of the file kept, a
|
||||
* non-mapping refused); a pane takes a key the clients file listed BEFORE it
|
||||
* started (the gateway re-reads it only every 10 s), and a gone pane's key
|
||||
* leaves it; a spawn is untouched while sharing is off, for a host pane, or
|
||||
* without S3; and the route refuses outside S3. No omp daemon is started.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omp-share-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_OMP_GATEWAY_PORT = "4999";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const sha = (t) => crypto.createHash("sha256").update(t).digest("hex");
|
||||
const { createApp, startServer } = require("../index");
|
||||
const share = require("../lib/omp-share");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
|
||||
const realS3 = linux.s3Enabled;
|
||||
const realLive = tmux.liveSessions;
|
||||
const flag = path.join(TMP, "data", "omp-share", "enabled");
|
||||
const clientsFile = path.join(TMP, "data", "omp-share", "clients.json");
|
||||
|
||||
function turnOn() {
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
linux.s3Enabled = () => true;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
linux.s3Enabled = realS3;
|
||||
tmux.liveSessions = realLive;
|
||||
fs.rmSync(flag, { force: true });
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("mergedModels", () => {
|
||||
it("adds the provider to an empty or missing file", () => {
|
||||
const doc = yaml.load(share.mergedModels(""));
|
||||
assert.deepEqual(doc, {
|
||||
providers: {
|
||||
agenthub: {
|
||||
baseUrl: "http://127.0.0.1:4999",
|
||||
apiKey: "AGENTHUB_OMP_KEY",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the member's own providers and settings", () => {
|
||||
const text =
|
||||
"equivalence:\n x: y\nproviders:\n mine:\n baseUrl: http://h/v1\n apiKey: K\n";
|
||||
const doc = yaml.load(share.mergedModels(text));
|
||||
assert.equal(doc.providers.mine.baseUrl, "http://h/v1");
|
||||
assert.deepEqual(doc.equivalence, { x: "y" });
|
||||
assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_OMP_KEY");
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is already there as is", () => {
|
||||
const once = share.mergedModels("");
|
||||
assert.equal(share.mergedModels(once), null);
|
||||
});
|
||||
|
||||
it("replaces a stale provider of ours", () => {
|
||||
const stale = "providers:\n agenthub:\n baseUrl: http://127.0.0.1:1\n";
|
||||
assert.equal(
|
||||
yaml.load(share.mergedModels(stale)).providers.agenthub.baseUrl,
|
||||
"http://127.0.0.1:4999"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a mapping", () => {
|
||||
assert.throws(() => share.mergedModels("- a\n- b\n"), /mapping/);
|
||||
assert.throws(() => share.mergedModels("providers: [1]\n"), /mapping/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareModel", () => {
|
||||
const host = "anthropic/claude-sonnet-5-5";
|
||||
it("runs on the host's default when none was asked for", () => {
|
||||
assert.equal(share.shareModel(null, host), "agenthub/anthropic/claude-sonnet-5-5");
|
||||
});
|
||||
it("maps a requested model onto the gateway's provider", () => {
|
||||
assert.equal(share.shareModel("claude-opus-5-5", host), "agenthub/anthropic/claude-opus-5-5");
|
||||
assert.equal(share.shareModel("openai-codex/gpt-5.5", host), "agenthub/openai-codex/gpt-5.5");
|
||||
assert.equal(share.shareModel("agenthub/anthropic/x", host), "agenthub/anthropic/x");
|
||||
});
|
||||
it("leaves the choice to omp when there is nothing to go on", () => {
|
||||
assert.equal(share.shareModel(null, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forSpawn", () => {
|
||||
const member = { login: "ah-mia", home: path.join(TMP, "home-mia") };
|
||||
|
||||
it("is null while sharing is off, for a host pane, or without S3", () => {
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1"), null); // off
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1"), null); // no S3
|
||||
linux.s3Enabled = () => true;
|
||||
assert.equal(share.forSpawn(null, "agenthub-lane-1"), null); // the host's
|
||||
});
|
||||
|
||||
it("hands out a key the gateway already listed, and writes models.yml AS the member", () => {
|
||||
turnOn();
|
||||
share.syncClients(); // the queue, listed before any pane asks
|
||||
const listedBefore = JSON.parse(fs.readFileSync(clientsFile, "utf8"));
|
||||
const writes = [];
|
||||
const real = { read: runAs.readFileSafe, mkdir: runAs.mkdirAs, write: runAs.writeAs };
|
||||
runAs.readFileSafe = () => {
|
||||
const e = new Error("missing");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
};
|
||||
runAs.mkdirAs = (acct, dir) => writes.push(["mkdir", acct.login, dir]);
|
||||
runAs.writeAs = (acct, file, mode, content) => writes.push([mode, acct.login, file, content]);
|
||||
tmux.liveSessions = () => new Set();
|
||||
try {
|
||||
const s = share.forSpawn(member, "agenthub-lane-7");
|
||||
const key = s.env.AGENTHUB_OMP_KEY;
|
||||
assert.match(key, /^ahomp_/);
|
||||
assert.equal(s.env.OMP_SKIP_SETUP, "1");
|
||||
const hash = sha(key);
|
||||
assert.ok(
|
||||
Object.values(listedBefore).some((c) => c.tokenSha256 === hash),
|
||||
"the key was listed before the pane started"
|
||||
);
|
||||
const after = JSON.parse(fs.readFileSync(clientsFile, "utf8"));
|
||||
assert.deepEqual(after["agenthub-lane-7"], { tokenSha256: hash });
|
||||
// its place in the queue is filled again, and no plaintext reaches the clients file
|
||||
assert.equal(Object.keys(after).filter((k) => k.startsWith("standby-")).length, 4);
|
||||
assert.ok(!fs.readFileSync(clientsFile, "utf8").includes("ahomp_"));
|
||||
assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600");
|
||||
s.place();
|
||||
const agentDir = path.join(member.home, ".omp", "agent");
|
||||
assert.deepEqual(writes[0], ["mkdir", "ah-mia", agentDir]);
|
||||
assert.equal(writes[1][0], "replace");
|
||||
assert.equal(writes[1][2], path.join(agentDir, "models.yml"));
|
||||
assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_OMP_KEY");
|
||||
} finally {
|
||||
runAs.readFileSafe = real.read;
|
||||
runAs.mkdirAs = real.mkdir;
|
||||
runAs.writeAs = real.write;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneTokens", () => {
|
||||
const now = 1_000_000;
|
||||
const tokens = () => ({
|
||||
standby: [],
|
||||
used: {
|
||||
"agenthub-lane-21": { hash: "b", at: now - 120_000 }, // live
|
||||
"agenthub-lane-22": { hash: "c", at: now - 120_000 }, // pane gone
|
||||
"agenthub-lane-23": { hash: "d", at: now - 5_000 }, // just started
|
||||
},
|
||||
});
|
||||
|
||||
it("drops only a gone pane's key past the grace, and keeps the queue full", () => {
|
||||
const t = share.pruneTokens(tokens(), new Set(["agenthub-lane-21"]), now);
|
||||
assert.deepEqual(Object.keys(t.used).sort(), ["agenthub-lane-21", "agenthub-lane-23"]);
|
||||
assert.equal(t.standby.length, 4);
|
||||
});
|
||||
|
||||
it("drops nothing when tmux could not be asked", () => {
|
||||
const t = share.pruneTokens(tokens(), null, now);
|
||||
assert.equal(Object.keys(t.used).length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/ai-tools/omp-sharing", () => {
|
||||
let server;
|
||||
let BASE;
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(() => server.close());
|
||||
|
||||
it("reports it unavailable and refuses the switch outside S3", async () => {
|
||||
const got = await (await fetch(`${BASE}/api/ai-tools/omp-sharing`)).json();
|
||||
assert.equal(got.available, false);
|
||||
assert.equal(got.enabled, false);
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
assert.equal(put.status, 409);
|
||||
assert.equal((await put.json()).error.code, "ENOS3");
|
||||
assert.equal(fs.existsSync(flag), false);
|
||||
});
|
||||
|
||||
it("wants a boolean", async () => {
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: "yes" }),
|
||||
});
|
||||
assert.equal(put.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -815,6 +815,8 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), [
|
||||
at("omp"),
|
||||
// A workspace at the account's home stays there (omp moves to a temp dir otherwise).
|
||||
"--allow-home",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
@@ -823,7 +825,12 @@ describe("pty-run", () => {
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
|
||||
pty.spawnRun({
|
||||
laneId: 18,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "omp",
|
||||
resumeSessionId: "--abcdefgh",
|
||||
}),
|
||||
{ code: "EBADSESSION" }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file server-mode-password-change.test.js
|
||||
* @description A password an admin GIVES (Users page create or reset) may be
|
||||
* anything non-empty, and the account must then pick its own before anything
|
||||
* else: its login reaches only the `/me` reads and `POST /api/me/password`
|
||||
* (EPWCHANGE), opens no WebSocket, and its own password must pass every rule
|
||||
* (EWEAKPASSWORD with the failed `rules`, never the current one). Also: the
|
||||
* last login survives a logout (`users.last_login_at`), the last enabled
|
||||
* admin can never be demoted or disabled (ELASTADMIN, the CLI's path
|
||||
* included) and old audit entries are pruned (`AGENTHUB_AUDIT_DAYS`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-password-change-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "Admin-pass-2026";
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.status !== 200) return null;
|
||||
return { cookie: res.headers.get("set-cookie").split(";")[0], user: (await res.json()).user };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_AUDIT_DAYS;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a password an admin gave", () => {
|
||||
it("is accepted weak, then the user must pick a strong one of their own", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const made = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
assert.equal(made.body.user.mustChangePassword, true);
|
||||
|
||||
const neo = await login("neo", "123");
|
||||
assert.equal(neo.user.mustChangePassword, true);
|
||||
// Only its own account and the password change, until it has one.
|
||||
assert.equal(
|
||||
(await call("GET", "/api/lanes", { cookie: neo.cookie })).body.error.code,
|
||||
"EPWCHANGE"
|
||||
);
|
||||
const patch = await call("PATCH", "/api/me/account", {
|
||||
cookie: neo.cookie,
|
||||
body: { displayName: "Neo" },
|
||||
});
|
||||
assert.equal(patch.body.error.code, "EPWCHANGE");
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: neo.cookie })).status, 200);
|
||||
const me = await call("GET", "/api/auth/me", { cookie: neo.cookie });
|
||||
assert.equal(me.body.user.mustChangePassword, true);
|
||||
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "neo12345" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "symbol", "username"]);
|
||||
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "Green-Tea-42" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/lanes", { cookie: neo.cookie })).status, 200);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/auth/me", { cookie: neo.cookie })).body.user.mustChangePassword,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("an admin's reset flags the account again; their own new password must pass every rule", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const neoId = auth.findUser("neo").id;
|
||||
const reset = await call("PATCH", `/api/users/${neoId}`, {
|
||||
cookie: boss,
|
||||
body: { password: "abc", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
assert.equal(reset.body.user.mustChangePassword, true);
|
||||
assert.equal((await login("neo", "abc")).user.mustChangePassword, true);
|
||||
|
||||
const bossId = auth.findUser("boss").id;
|
||||
const own = (password) =>
|
||||
call("PATCH", `/api/users/${bossId}`, {
|
||||
cookie: boss,
|
||||
body: { password, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal((await own("123")).body.error.code, "EWEAKPASSWORD");
|
||||
const same = await own(BOSS_PASS);
|
||||
assert.equal(same.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(same.body.error.rules, ["reused"]);
|
||||
const done = await own("Blue-Sky-77!");
|
||||
assert.equal(done.status, 200);
|
||||
assert.equal(done.body.user.mustChangePassword, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account bookkeeping", () => {
|
||||
it("keeps the last login after the user signs out", async () => {
|
||||
const pass = "Blue-Sky-77!";
|
||||
const s = await login("boss", pass);
|
||||
await call("POST", "/api/auth/logout", { cookie: s.cookie });
|
||||
const id = auth.findUser("boss").id;
|
||||
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(id);
|
||||
const stats = auth.loginStats(id);
|
||||
assert.equal(stats.logins, 0);
|
||||
assert.ok(stats.lastLoginAt, "the last login is still known");
|
||||
assert.equal(auth.allLoginStats().get(id).lastLoginAt, stats.lastLoginAt);
|
||||
});
|
||||
|
||||
it("never demotes, disables or deletes the last enabled admin", () => {
|
||||
for (const fn of [
|
||||
() => auth.setRole("boss", "developer"),
|
||||
() => auth.setDisabled("boss", true),
|
||||
() => auth.deleteUser("boss"),
|
||||
]) {
|
||||
assert.throws(fn, { code: "ELASTADMIN" });
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
assert.equal(auth.findUser("boss").disabled, 0);
|
||||
});
|
||||
|
||||
it("prunes audit entries older than AGENTHUB_AUDIT_DAYS", () => {
|
||||
const { pruneAudit } = require("../lib/audit");
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO audit_log (user_id, owner_id, method, path, at) VALUES (1, 1, 'GET', ?, ?)"
|
||||
);
|
||||
insert.run("/old", new Date(Date.now() - 40 * 864e5).toISOString());
|
||||
insert.run("/new", new Date().toISOString());
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "0";
|
||||
assert.equal(pruneAudit({ force: true }), 0, "0 keeps everything");
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "30";
|
||||
assert.ok(pruneAudit({ force: true }) >= 1);
|
||||
const paths = db
|
||||
.prepare("SELECT path FROM audit_log")
|
||||
.all()
|
||||
.map((r) => r.path);
|
||||
assert.ok(!paths.includes("/old"));
|
||||
assert.ok(paths.includes("/new"));
|
||||
});
|
||||
});
|
||||
@@ -230,19 +230,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "digit", "username"]);
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "New-Pass-2026" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||
assert.ok(await login("leo", "leo-new-pass"));
|
||||
assert.ok(await login("leo", "New-Pass-2026"));
|
||||
});
|
||||
|
||||
it("signs out other devices", async () => {
|
||||
const a = await login("leo", "leo-new-pass");
|
||||
const b = await login("leo", "leo-new-pass");
|
||||
const a = await login("leo", "New-Pass-2026");
|
||||
const b = await login("leo", "New-Pass-2026");
|
||||
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
|
||||
assert.ok(r.body.ended >= 1);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
|
||||
|
||||
@@ -116,6 +116,10 @@ before(async () => {
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
// A second admin, so demoting "boss" never leaves the install without one (ELASTADMIN).
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES ('boss2', 'admin', 'x')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for the non-Claude login profiles (tool-profiles.js): saving the
|
||||
* live Codex login, switching between logins, the guards that keep a switch from
|
||||
* losing the only copy of one, and Oh My Pi's credential identity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tool-profiles-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, ".codex");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
|
||||
const AUTH = path.join(process.env.CODEX_HOME, "auth.json");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const jwt = (claims) => `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
||||
|
||||
/** Leave a ChatGPT login on disk the way `codex login` would. */
|
||||
function login(account, email, refresh = "rt-1") {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
AUTH,
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email,
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: "at",
|
||||
refresh_token: refresh,
|
||||
account_id: account,
|
||||
},
|
||||
last_refresh: "2026-10-01T00:00:00Z",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("tool-profiles: codex", () => {
|
||||
it("reports the live login and saves it under a name derived from it", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
assert.deepEqual(profiles.listProfiles("codex").active, {
|
||||
kind: "chatgpt",
|
||||
email: "alice@example.com",
|
||||
detail: "plus",
|
||||
});
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.name, "alice");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].secret, undefined, "never a token in a listing");
|
||||
const mode = fs.statSync(path.join(profiles.profilesDir("codex"), "alice.json")).mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
});
|
||||
|
||||
it("re-saving refreshes the login's own profile; a second name is EDUPLICATE", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-a", "alice@example.com", "rt-2");
|
||||
assert.equal(profiles.saveProfile("codex").name, "alice");
|
||||
assert.equal(profiles.readProfile("codex", "alice").secret.tokens.refresh_token, "rt-2");
|
||||
assert.throws(() => profiles.saveProfile("codex", "other"), { code: "EDUPLICATE" });
|
||||
});
|
||||
|
||||
it("switches, saving the outgoing login back first", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
// Alice's tokens rotated while she was live? Bob is live now; switch to Alice.
|
||||
const r = profiles.activateProfile("codex", "alice");
|
||||
assert.equal(r.savedOutgoing, "bob");
|
||||
const live = JSON.parse(fs.readFileSync(AUTH, "utf8"));
|
||||
assert.equal(live.tokens.account_id, "acct-a");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active]),
|
||||
[
|
||||
["alice", true],
|
||||
["bob", false],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-c", "carol@example.com");
|
||||
assert.throws(() => profiles.activateProfile("codex", "alice"), { code: "EUNSAVED" });
|
||||
});
|
||||
|
||||
it("will not delete the live login's profile, deletes another", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.throws(() => profiles.deleteProfile("codex", "bob"), { code: "EACTIVE" });
|
||||
profiles.deleteProfile("codex", "alice");
|
||||
assert.deepEqual(
|
||||
profiles.listProfiles("codex").items.map((p) => p.name),
|
||||
["bob"]
|
||||
);
|
||||
});
|
||||
|
||||
it("renames the label only, and refuses a taken one", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
profiles.renameProfile("codex", "alice", "Công việc");
|
||||
assert.equal(
|
||||
profiles.listProfiles("codex").items.find((p) => p.name === "alice").label,
|
||||
"Công việc"
|
||||
);
|
||||
assert.throws(() => profiles.renameProfile("codex", "bob", "công việc"), {
|
||||
code: "ELABELTAKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("an API-key login is identified by its key, shown by its last four", () => {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-test-abcdefghij1234" }));
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.kind, "apikey");
|
||||
assert.equal(r.detail, "…1234");
|
||||
});
|
||||
|
||||
it("saving with no login is ENOAUTH", () => {
|
||||
assert.throws(() => profiles.saveProfile("codex"), { code: "ENOAUTH" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: tools", () => {
|
||||
it("lists antigravity as unsupported and refuses it", () => {
|
||||
const agy = profiles.tools().find((t) => t.tool === "antigravity");
|
||||
assert.equal(agy.supported, false);
|
||||
assert.throws(() => profiles.listProfiles("antigravity"), { code: "EUNSUPPORTED" });
|
||||
assert.throws(() => profiles.listProfiles("nope"), { code: "EBADTOOL" });
|
||||
});
|
||||
|
||||
it("an omp credential is identified by who it is, never by its rotating token", () => {
|
||||
const a = { provider: "anthropic", credential: { type: "oauth", access: "1", email: "a@x" } };
|
||||
const b = { provider: "anthropic", credential: { type: "oauth", access: "2", email: "a@x" } };
|
||||
assert.equal(profiles.ompCredentialId(a), profiles.ompCredentialId(b));
|
||||
assert.equal(profiles.ompCredentialId(a), "anthropic:a@x");
|
||||
});
|
||||
|
||||
it("a switch hands the adapter the outgoing login so it can restore it", () => {
|
||||
let disk = { who: "a" };
|
||||
const writes = [];
|
||||
profiles.__setAdapter("fake", {
|
||||
label: "Fake",
|
||||
read: () => disk,
|
||||
write: (secret, outgoing) => {
|
||||
writes.push([secret.who, outgoing && outgoing.who]);
|
||||
disk = secret;
|
||||
return [];
|
||||
},
|
||||
identify: (s) => ({ key: s.who, kind: "x", email: null, detail: null }),
|
||||
});
|
||||
profiles.saveProfile("fake", "a");
|
||||
disk = { who: "b" };
|
||||
profiles.saveProfile("fake", "b");
|
||||
profiles.activateProfile("fake", "a");
|
||||
assert.deepEqual(writes, [["a", "b"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: omp", () => {
|
||||
it("switches through a cleared store + `omp auth import -`, never by argv", () => {
|
||||
const omp = path.join(ROOT, ".omp");
|
||||
const db = path.join(omp, "agent", "agent.db");
|
||||
const state = path.join(ROOT, "omp-state.json");
|
||||
const argv = path.join(ROOT, "omp-argv.log");
|
||||
fs.mkdirSync(path.dirname(db), { recursive: true });
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const d = new DatabaseSync(db);
|
||||
d.exec(`CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, data TEXT);
|
||||
CREATE TABLE auth_credential_blocks (credential_id INTEGER);
|
||||
CREATE TABLE auth_credential_refresh_leases (credential_id INTEGER);
|
||||
INSERT INTO auth_credentials (provider, data) VALUES ('anthropic', '{}');`);
|
||||
d.close();
|
||||
// A fake omp: export prints the state file, import REPLACES it from stdin
|
||||
// only when the store was cleared first (as the real one merges).
|
||||
const bin = path.join(ROOT, "omp");
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
fs.appendFileSync(${JSON.stringify(argv)}, process.argv.slice(2).join(" ") + "\\n");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(${JSON.stringify(db)});
|
||||
const rows = db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
|
||||
if (process.argv[3] === "export") {
|
||||
process.stdout.write(fs.readFileSync(${JSON.stringify(state)}, "utf8"));
|
||||
} else {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
if (rows) { process.stderr.write("merged into a non-empty store"); process.exit(1); }
|
||||
fs.writeFileSync(${JSON.stringify(state)}, JSON.stringify(input));
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const set = (email, token) =>
|
||||
fs.writeFileSync(
|
||||
state,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
credentials: [
|
||||
{ provider: "anthropic", credential: { type: "oauth", access: token, email } },
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.AGENTHUB_OMP_DIR = omp;
|
||||
process.env.AGENTHUB_OMP_BIN = bin;
|
||||
try {
|
||||
set("a@x", "secret-a");
|
||||
assert.equal(profiles.saveProfile("omp").name, "a");
|
||||
set("b@x", "secret-b");
|
||||
profiles.saveProfile("omp");
|
||||
const r = profiles.activateProfile("omp", "a");
|
||||
assert.equal(r.savedOutgoing, "b");
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(state, "utf8")).credentials[0].credential.email,
|
||||
"a@x"
|
||||
);
|
||||
assert.ok(!fs.readFileSync(argv, "utf8").includes("secret"), "secrets only on stdin");
|
||||
const { items, active } = profiles.listProfiles("omp");
|
||||
assert.equal(active.email, "a@x");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active, p.detail]),
|
||||
[
|
||||
["a", true, "anthropic"],
|
||||
["b", false, "anthropic"],
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_OMP_DIR;
|
||||
delete process.env.AGENTHUB_OMP_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). A session id may carry `_` (OpenCode's `ses_…`).
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"detect": [
|
||||
"omp"
|
||||
],
|
||||
"args": [
|
||||
"--allow-home"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
|
||||
@@ -1942,6 +1942,19 @@ for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
||||
}
|
||||
}
|
||||
// When the account last logged in (kept past its logins' expiry, unlike
|
||||
// `auth_sessions`), and whether it must pick its own password before anything
|
||||
// else (set when an admin creates it or resets its password — server/lib/auth.js).
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
} catch {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} ${type}`).run();
|
||||
}
|
||||
}
|
||||
// An admin's pin of one server-mode user to one pool account per tool
|
||||
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||
// The account is named, not referenced — the pool lives in files.
|
||||
|
||||
@@ -142,6 +142,7 @@ const snapshotsRouter = require("./routes/snapshots");
|
||||
const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const toolProfilesRouter = require("./routes/tool-profiles");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
@@ -209,6 +210,7 @@ function createApp() {
|
||||
app.use("/api/import", importRouter);
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/tool-profiles", toolProfilesRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
|
||||
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
|
||||
@@ -1297,6 +1299,10 @@ if (require.main === module) {
|
||||
startBackgroundServices();
|
||||
// The status-area icon (server/lib/tray.js) — production servers only.
|
||||
require("./lib/tray").startTrayWatch();
|
||||
// Oh My Pi login sharing's broker + gateway, when an admin turned it on (S3).
|
||||
require("./lib/omp-share")
|
||||
.start()
|
||||
.catch((err) => console.warn("omp share failed to start:", err.message));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(listenFailure(err, PORT));
|
||||
@@ -1312,6 +1318,12 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
}
|
||||
shutdownInProgress = true;
|
||||
// Its omp daemons are this server's children; KillMode=process would keep them.
|
||||
try {
|
||||
require("./lib/omp-share").stop();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
console.log(`\n${signal} received — shutting down gracefully… (hit Ctrl+C again to force)`);
|
||||
|
||||
// Drop realtime clients first — open WS sockets otherwise hold the HTTP
|
||||
|
||||
+30
-9
@@ -39,7 +39,8 @@
|
||||
* Share, unshare, install and uninstall are written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
* copies already installed stay as they were (uninstall and install again
|
||||
* to take the new one).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -149,7 +150,8 @@ function view(row, req, owners = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||
* shared skills/plugins of others the caller has installed; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
@@ -158,8 +160,12 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ? AND a.in_library = 1");
|
||||
params.push(req.user.id);
|
||||
// Their own library, plus what others shared that they installed (kept
|
||||
// below only when `installed` — a disk read, so it cannot be SQL).
|
||||
where.push(
|
||||
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
|
||||
);
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
}
|
||||
@@ -193,7 +199,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -284,12 +291,25 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a hook's page belongs to when no workspace owns its session (a member's
|
||||
* `claude` started from their own terminal): the S3 member the hook's
|
||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||
* The host's account is shared by every admin, so it names nobody.
|
||||
*/
|
||||
function peerOwner(peer) {
|
||||
if (peer?.kind !== "member" || !peer.name) return null;
|
||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||
return row ? row.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||
* owned by the session's workspace owner. Returns the row or null.
|
||||
* owned by the session's workspace owner, else by the member who sent the
|
||||
* hook (`peerOwner`). Returns the row or null.
|
||||
*/
|
||||
function captureFromHook(sessionId, data) {
|
||||
function captureFromHook(sessionId, data, peer = null) {
|
||||
const input = data?.tool_input || {};
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const text = JSON.stringify(data?.tool_response ?? "");
|
||||
@@ -297,7 +317,8 @@ function captureFromHook(sessionId, data) {
|
||||
if (!m) return null;
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const laneId = sessionLane(sessionId);
|
||||
const ownerId = laneId != null ? sessionOwner(sessionId) : peerOwner(peer);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
url,
|
||||
ownerId
|
||||
@@ -309,7 +330,7 @@ function captureFromHook(sessionId, data) {
|
||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
owner_id: ownerId,
|
||||
lane_id: sessionLane(sessionId),
|
||||
lane_id: laneId,
|
||||
session_id: String(sessionId),
|
||||
source: file ? file.slice(0, 1000) : null,
|
||||
url,
|
||||
|
||||
+40
-10
@@ -8,11 +8,47 @@
|
||||
* for a member (who can only read their own). An admin's account change on
|
||||
* the Users page is written here too (`recordAccountChange`), and so is an
|
||||
* admin's look into another user's home (`recordHomeRead`, File Explorer).
|
||||
* Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
|
||||
* all): older ones are deleted as new ones are written, at most once an hour.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
const DEFAULT_KEEP_DAYS = 180;
|
||||
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
||||
let lastPrune = 0;
|
||||
|
||||
/** Days of audit history kept; 0 = forever. */
|
||||
function keepDays() {
|
||||
const v = process.env.AGENTHUB_AUDIT_DAYS;
|
||||
if (v === undefined || v === "") return DEFAULT_KEEP_DAYS;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_KEEP_DAYS;
|
||||
}
|
||||
|
||||
/** Deletes entries older than `keepDays()`; returns how many. `force` skips the hourly gate. */
|
||||
function pruneAudit({ force = false } = {}) {
|
||||
const days = keepDays();
|
||||
if (!days || (!force && Date.now() - lastPrune < PRUNE_EVERY_MS)) return 0;
|
||||
lastPrune = Date.now();
|
||||
const cutoff = new Date(Date.now() - days * 864e5).toISOString();
|
||||
try {
|
||||
return sql("DELETE FROM audit_log WHERE at < ?").run(cutoff).changes;
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not prune:", err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes one entry, then prunes when it is time. */
|
||||
function insertEntry(userId, ownerId, sessionId, method, path) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(userId, ownerId, sessionId, method, path);
|
||||
pruneAudit();
|
||||
}
|
||||
|
||||
// Paths that name one session's content.
|
||||
const SESSION_PATHS = [
|
||||
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
|
||||
@@ -35,9 +71,7 @@ function auditGuard(req, res, next) {
|
||||
if (owner === u.id) continue;
|
||||
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
|
||||
if (ownerRole !== "developer") continue;
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
insertEntry(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
@@ -64,20 +98,16 @@ function listAudit(limit = 100) {
|
||||
/** An admin's account change from the Users page: who, on whom, what (never
|
||||
* a password). `owner_id` is the account changed; `path` carries the action. */
|
||||
function recordAccountChange(actorId, targetId, method, action) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, targetId, method, String(action).slice(0, 500));
|
||||
insertEntry(actorId, targetId, null, method, String(action).slice(0, 500));
|
||||
}
|
||||
|
||||
/** An admin opening another user's home in the File Explorer (read-only). */
|
||||
function recordHomeRead(actorId, ownerId, method, what) {
|
||||
try {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, ownerId, method, String(what).slice(0, 500));
|
||||
insertEntry(actorId, ownerId, null, method, String(what).slice(0, 500));
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead, pruneAudit };
|
||||
|
||||
+130
-17
@@ -22,6 +22,15 @@
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
* reset — `validatePassword`), and such an account is flagged
|
||||
* `must_change_password`; a password its OWNER picks must pass every rule of
|
||||
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
|
||||
* cookie login reaches only `/auth/*`, the `/me` reads and
|
||||
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
|
||||
* not held back — its agents were started before the reset.
|
||||
* - Neither the dashboard nor the CLI can leave the install with no enabled
|
||||
* admin (`assertKeepsAdmin`, ELASTADMIN).
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
@@ -54,6 +63,8 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
// The AI tools' login profiles: switching one changes the host's own login.
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
@@ -79,6 +90,7 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
const STAFF_ONLY_PREFIXES = [
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/remote-sources",
|
||||
"/webhooks",
|
||||
@@ -134,6 +146,39 @@ function validatePassword(password) {
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
/** Shortest password an account may pick for itself. */
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
|
||||
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
|
||||
* the username). The client's checklist (client/src/lib/passwordRules.ts)
|
||||
* mirrors these; this is the one that decides.
|
||||
*/
|
||||
function passwordProblems(password, username) {
|
||||
const p = String(password ?? "");
|
||||
const out = [];
|
||||
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(p)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(p)) out.push("upper");
|
||||
if (!/\p{N}/u.test(p)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
|
||||
const name = String(username || "").toLowerCase();
|
||||
if (name && p.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
|
||||
function validateOwnPassword(password, username) {
|
||||
validatePassword(password);
|
||||
const failed = passwordProblems(password, username);
|
||||
if (failed.length) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
|
||||
rules: failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||
|
||||
const publicUser = (u) =>
|
||||
@@ -147,6 +192,8 @@ const publicUser = (u) =>
|
||||
// When the avatar was last set (null = none): the client's cache-buster.
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -199,7 +246,8 @@ function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
|
||||
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
@@ -210,8 +258,8 @@ async function createUser(username, password, role = "developer") {
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
"INSERT INTO users (username, role, password_hash, must_change_password) VALUES (?, ?, ?, ?)"
|
||||
).run(name, role, hash, mustChange ? 1 : 0);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
@@ -227,14 +275,29 @@ function requireUser(username) {
|
||||
}
|
||||
|
||||
/** New password; every login of that user ends — but `keepToken`'s, when
|
||||
* the user changed their own password from that login. */
|
||||
async function setPassword(username, password, { keepToken } = {}) {
|
||||
* the user changed their own password from that login. `mustChange`: an admin
|
||||
* gave it, so the user must pick their own next; without it the flag clears. */
|
||||
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
|
||||
const u = requireUser(username);
|
||||
validatePassword(password);
|
||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
|
||||
await hashPassword(password),
|
||||
mustChange ? 1 : 0,
|
||||
u.id
|
||||
);
|
||||
endSessions(u.id, keepToken);
|
||||
}
|
||||
|
||||
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
|
||||
async function assertNotCurrent(userId, password) {
|
||||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
||||
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
|
||||
rules: ["reused"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||||
function endSessions(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
@@ -246,18 +309,52 @@ function endSessions(userId, exceptToken) {
|
||||
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||||
}
|
||||
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and their last login
|
||||
* ever (`users.last_login_at`; the newest login's start for one made before it). */
|
||||
function loginStats(userId) {
|
||||
const r = sql(
|
||||
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE user_id = ? AND expires_at > ?`
|
||||
).get(userId, new Date().toISOString());
|
||||
return { logins: r.n, lastLoginAt: r.last || null };
|
||||
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
|
||||
return { logins: r.n, lastLoginAt: at || r.last || null };
|
||||
}
|
||||
|
||||
/** `loginStats` for every account in two queries: Map(user id → stats). */
|
||||
function allLoginStats() {
|
||||
const now = new Date().toISOString();
|
||||
const live = new Map(
|
||||
sql(
|
||||
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE expires_at > ? GROUP BY user_id`
|
||||
)
|
||||
.all(now)
|
||||
.map((r) => [r.user_id, r])
|
||||
);
|
||||
const out = new Map();
|
||||
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
|
||||
const r = live.get(u.id);
|
||||
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
|
||||
* no longer one — the CLI included, which has no "yourself" to refuse.
|
||||
*/
|
||||
function assertKeepsAdmin(u, what) {
|
||||
if (u.role !== "admin" || u.disabled) return;
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
|
||||
}
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
@@ -287,6 +384,7 @@ function stopHostPanes(userId) {
|
||||
* `deleteUser` below is the one that removes an account for good. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
if (disabled) assertKeepsAdmin(u, "disabled");
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
@@ -316,12 +414,7 @@ function setDisabled(username, disabled) {
|
||||
*/
|
||||
function deleteUser(username) {
|
||||
const u = requireUser(username);
|
||||
if (u.role === "admin") {
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||
}
|
||||
assertKeepsAdmin(u, "deleted");
|
||||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
const pty = require("./pty-run");
|
||||
@@ -514,6 +607,7 @@ async function login(req, username, password) {
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
@@ -598,6 +692,12 @@ const underPrefix = (p, x) => {
|
||||
|
||||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
/** What a login flagged `must_change_password` may still call. */
|
||||
function mayBeforePasswordChange(method, lp) {
|
||||
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
|
||||
return method === "POST" && lp === "/me/password";
|
||||
}
|
||||
|
||||
/** Express middleware for `/api`; a no-op in local mode. */
|
||||
function authGuard(req, res, next) {
|
||||
if (!isServerMode()) return next();
|
||||
@@ -610,9 +710,15 @@ function authGuard(req, res, next) {
|
||||
? next()
|
||||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||||
}
|
||||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
|
||||
return deny(res, 403, "EPWCHANGE", "choose your own password first");
|
||||
}
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
@@ -655,8 +761,10 @@ function isWebSocketLoggedIn(req) {
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
// Which terminals a login may open is the run check's (websocket.js).
|
||||
return Boolean(userFromRequest(req));
|
||||
// Which terminals a login may open is the run check's (websocket.js). None
|
||||
// until a password an admin gave is replaced.
|
||||
const user = userFromRequest(req);
|
||||
return Boolean(user && !user.mustChangePassword);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -684,8 +792,13 @@ module.exports = {
|
||||
validateProfile,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
passwordProblems,
|
||||
assertNotCurrent,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
endSessions,
|
||||
loginStats,
|
||||
allLoginStats,
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file avatar-url.js
|
||||
* @description Fetches an avatar picture from a URL the account typed
|
||||
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
|
||||
* browser, which most image hosts' CORS would block — downloads it. A URL the
|
||||
* user names makes the SERVER connect somewhere, so it is held to: `https://`
|
||||
* with no userinfo, a host whose EVERY resolved address is public (no
|
||||
* loopback, private, link-local, CGNAT, multicast or reserved range — the
|
||||
* dashboard's own network is never reachable this way), the connection pinned
|
||||
* to the vetted address (a second DNS answer cannot rebind it), at most
|
||||
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
|
||||
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
|
||||
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const dns = require("node:dns");
|
||||
const https = require("node:https");
|
||||
const net = require("node:net");
|
||||
const { MAX_AVATAR_BYTES } = require("./avatars");
|
||||
|
||||
const MAX_REDIRECTS = 3;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const blocked = new net.BlockList();
|
||||
for (const [addr, bits] of [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv4");
|
||||
}
|
||||
for (const [addr, bits] of [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["64:ff9b::", 96],
|
||||
["64:ff9b:1::", 48],
|
||||
["100::", 64],
|
||||
["2001::", 23],
|
||||
["2001:db8::", 32],
|
||||
["2002::", 16],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv6");
|
||||
}
|
||||
|
||||
/** True only for a globally routable unicast address. */
|
||||
function isPublicAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
if (family === 4) return !blocked.check(address, "ipv4");
|
||||
if (family !== 6) return false;
|
||||
const lower = address.toLowerCase();
|
||||
// An IPv4-mapped/compatible address is judged as the IPv4 address it carries.
|
||||
const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
||||
if (mapped) return isPublicAddress(mapped[1]);
|
||||
if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false;
|
||||
// Only global unicast (2000::/3) is public.
|
||||
if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false;
|
||||
return !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
|
||||
function parseAvatarUrl(text) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(text ?? "").trim());
|
||||
} catch {
|
||||
throw fail("EBADURL", "not a valid URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
|
||||
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
|
||||
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
|
||||
// Node never calls `lookup` for an IP-literal host, so it is checked here.
|
||||
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
||||
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lookup` for `https.request`: resolves every address and refuses the host
|
||||
* when ANY of them is not public, then hands over only vetted ones.
|
||||
*/
|
||||
function safeLookup(hostname, options, cb) {
|
||||
if (typeof options === "function") [cb, options] = [options, {}];
|
||||
const literal = hostname.replace(/^\[|\]$/g, "");
|
||||
const done = (addrs) => {
|
||||
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
|
||||
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
|
||||
}
|
||||
if (options?.all) return cb(null, addrs);
|
||||
return cb(null, addrs[0].address, addrs[0].family);
|
||||
};
|
||||
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
|
||||
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
|
||||
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
|
||||
done(addrs);
|
||||
});
|
||||
}
|
||||
|
||||
function getOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
lookup: safeLookup,
|
||||
agent: false,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve({ redirect: new URL(res.headers.location, url).href });
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume();
|
||||
return reject(fail("EFETCH", `the server answered ${status}`));
|
||||
}
|
||||
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
|
||||
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
|
||||
req.on("error", (e) =>
|
||||
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
|
||||
async function fetchAvatarImage(text) {
|
||||
let url = parseAvatarUrl(text);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const out = await getOnce(url);
|
||||
if (out.body) return out.body;
|
||||
url = parseAvatarUrl(out.redirect);
|
||||
}
|
||||
throw fail("EFETCH", "too many redirects");
|
||||
}
|
||||
|
||||
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* @file omp-share.js
|
||||
* @description Server mode with S3: the HOST account's Oh My Pi (omp) login,
|
||||
* shared with every member's omp pane without any member ever holding it.
|
||||
* An admin's switch (Settings → AI tools → Oh My Pi login sharing,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`), off by default.
|
||||
*
|
||||
* While it is on, this server keeps two omp daemons of its own running, as the
|
||||
* host, on loopback only:
|
||||
* - `omp auth-broker serve` over the host's own `~/.omp` credential store —
|
||||
* the vault. Its bearer token (`~/.omp/auth-broker.token`, 0600 in the
|
||||
* host's home) reaches nothing but the gateway's env.
|
||||
* - `omp auth-gateway serve --clients=<file>` — an API gateway that answers
|
||||
* `/v1/messages`, `/v1/chat/completions`, `/v1/responses`, `/v1/models`
|
||||
* with the broker's credentials put in upstream. Its own unscoped token
|
||||
* stays in the host's home too.
|
||||
* A member's omp pane reaches the gateway as a SCOPED client: it is handed one
|
||||
* key (`AGENTHUB_OMP_KEY`, through tmux -e / its launch file) that the
|
||||
* clients file had already listed — by SHA-256 only — before the pane started
|
||||
* (the gateway re-reads that file only every 10 s; see `takeToken`), and its
|
||||
* `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is
|
||||
* that env var's NAME. A scoped client can call models but never read a
|
||||
* credential or the gateway's usage, and its key stops working within the
|
||||
* next sync + the gateway's re-read once its pane is gone (`syncClients`) — so
|
||||
* an agent can spend through the login while its pane lives, never carry it
|
||||
* away. omp's gateway serves only its built-in providers' models (a custom
|
||||
* `models.yml` provider of the host, such as a 9Router, is not shared).
|
||||
*
|
||||
* Nothing here returns or logs a token. Local mode and a server without S3
|
||||
* never start anything.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const PROVIDER = "agenthub";
|
||||
const TOKEN_ENV = "AGENTHUB_OMP_KEY";
|
||||
const SYNC_MS = 15_000;
|
||||
|
||||
const port = (name, fallback) => {
|
||||
const n = parseInt(process.env[name] || "", 10);
|
||||
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
||||
};
|
||||
const brokerPort = () => port("AGENTHUB_OMP_BROKER_PORT", 4826);
|
||||
const gatewayPort = () => port("AGENTHUB_OMP_GATEWAY_PORT", 4827);
|
||||
|
||||
const dir = () => path.join(require("./claude-home").getDataDir(), "omp-share");
|
||||
const onFlag = () => path.join(dir(), "enabled");
|
||||
const clientsFile = () => path.join(dir(), "clients.json");
|
||||
const pidsFile = () => path.join(dir(), "pids.json");
|
||||
const tokensFile = () => path.join(dir(), "tokens.json");
|
||||
const logFile = (name) => path.join(dir(), `${name}.log`);
|
||||
/** The host's own omp config dir, where the broker keeps its token. */
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
|
||||
function available() {
|
||||
return require("./linux-accounts").s3Enabled();
|
||||
}
|
||||
|
||||
const enabled = () => fs.existsSync(onFlag());
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// ── tokens and the clients file ───────────────────────────────────────────
|
||||
//
|
||||
// The gateway re-reads its clients file only every 10 s, and an omp that
|
||||
// starts while its key is still refused hangs for good (its model discovery
|
||||
// fails once and is never retried). So a pane never gets a key minted for
|
||||
// it: it takes one of STANDBY keys that have been listed for a while, and a
|
||||
// fresh one takes that key's place in the queue. `tokens.json` (0600, the
|
||||
// host's data dir) keeps the queue and which run holds which key, so both
|
||||
// survive a restart; a key whose pane is gone leaves the file at the next sync.
|
||||
|
||||
const STANDBY = 4;
|
||||
/** A run's key stays listed this long before its pane must show up in tmux. */
|
||||
const GRACE_MS = 60_000;
|
||||
|
||||
const sha = (t) => crypto.createHash("sha256").update(t).digest("hex");
|
||||
const newToken = () => `ahomp_${crypto.randomBytes(24).toString("base64url")}`;
|
||||
|
||||
function readTokens() {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(tokensFile(), "utf8"));
|
||||
return {
|
||||
standby: Array.isArray(j.standby) ? j.standby.filter((t) => typeof t === "string") : [],
|
||||
used: j.used && typeof j.used === "object" ? j.used : {},
|
||||
};
|
||||
} catch {
|
||||
return { standby: [], used: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeAtomic(file, text) {
|
||||
ensureDir();
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function refill(t) {
|
||||
while (t.standby.length < STANDBY) t.standby.push(newToken());
|
||||
}
|
||||
|
||||
/** The gateway's view: every queued key and every key a run holds — hashes only. */
|
||||
function clientsFor(t) {
|
||||
const out = {};
|
||||
for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) };
|
||||
for (const [id, u] of Object.entries(t.used)) {
|
||||
if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let lastClients = null;
|
||||
|
||||
function save(t) {
|
||||
writeAtomic(tokensFile(), `${JSON.stringify(t)}\n`);
|
||||
const text = `${JSON.stringify(clientsFor(t), null, 2)}\n`;
|
||||
if (text === lastClients && fs.existsSync(clientsFile())) return;
|
||||
writeAtomic(clientsFile(), text);
|
||||
lastClients = text;
|
||||
}
|
||||
|
||||
/** A key for run `runId`, already listed with the gateway; its place in the queue is refilled. */
|
||||
function takeToken(runId) {
|
||||
const t = readTokens();
|
||||
refill(t);
|
||||
const token = t.standby.shift();
|
||||
t.used[runId] = { hash: sha(token), at: Date.now() };
|
||||
refill(t);
|
||||
save(t);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the keys of runs whose pane is gone (after GRACE_MS), keep the queue
|
||||
* full, and rewrite both files. `live`: the tmux session names, or null when
|
||||
* tmux could not say — then nothing is dropped.
|
||||
*/
|
||||
function pruneTokens(t, live, now = Date.now()) {
|
||||
if (live) {
|
||||
for (const [id, u] of Object.entries(t.used)) {
|
||||
if (!live.has(id) && now - (Number(u?.at) || 0) > GRACE_MS) delete t.used[id];
|
||||
}
|
||||
}
|
||||
refill(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Rewrite the clients file from the live omp panes (a gone pane's key drops out). */
|
||||
function syncClients() {
|
||||
if (!enabled() || !available()) return;
|
||||
try {
|
||||
save(pruneTokens(readTokens(), require("./tmux").liveSessions()));
|
||||
} catch (err) {
|
||||
console.warn("omp share: clients sync failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sharing turned off: no key stays listed, queued or held. */
|
||||
function revokeAll() {
|
||||
lastClients = null;
|
||||
if (fs.existsSync(tokensFile()) || fs.existsSync(clientsFile())) {
|
||||
save({ standby: [], used: {} });
|
||||
}
|
||||
}
|
||||
|
||||
// ── a member's models.yml ─────────────────────────────────────────────────
|
||||
|
||||
function providerEntry() {
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${gatewayPort()}`,
|
||||
apiKey: TOKEN_ENV,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of `models.yml` with our provider set, or null when it is already
|
||||
* there as is. Throws on a file that is not a YAML mapping — it is the
|
||||
* member's, and is never overwritten with something it did not say.
|
||||
*/
|
||||
function mergedModels(text) {
|
||||
const yaml = require("js-yaml");
|
||||
const doc = text && text.trim() ? yaml.load(text) : {};
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
throw new Error("models.yml is not a YAML mapping");
|
||||
}
|
||||
if (
|
||||
doc.providers != null &&
|
||||
(typeof doc.providers !== "object" || Array.isArray(doc.providers))
|
||||
) {
|
||||
throw new Error("models.yml `providers` is not a mapping");
|
||||
}
|
||||
const want = providerEntry();
|
||||
const have = doc.providers?.[PROVIDER];
|
||||
if (have && JSON.stringify(have) === JSON.stringify(want)) return null;
|
||||
doc.providers = { ...(doc.providers || {}), [PROVIDER]: want };
|
||||
return yaml.dump(doc, { lineWidth: -1, noRefs: true });
|
||||
}
|
||||
|
||||
/** Point a member's omp at the gateway: their `~/.omp/agent/models.yml`, written AS them. */
|
||||
function writeMemberModels(acct) {
|
||||
const runAs = require("./run-as");
|
||||
const agentDir = path.join(acct.home, ".omp", "agent");
|
||||
const file = path.join(agentDir, "models.yml");
|
||||
let text = "";
|
||||
try {
|
||||
text = runAs.readFileSafe(file, "utf8");
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
runAs.mkdirAs(acct, agentDir);
|
||||
}
|
||||
const next = mergedModels(text);
|
||||
if (next !== null) runAs.writeAs(acct, file, "replace", next);
|
||||
}
|
||||
|
||||
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
|
||||
function hostDefaultModel() {
|
||||
try {
|
||||
const doc = require("js-yaml").load(
|
||||
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
|
||||
);
|
||||
const m = doc?.modelRoles?.default;
|
||||
return typeof m === "string" && m.includes("/") ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--model` a shared pane runs on: the requested model, or the host's
|
||||
* default, as the `agenthub` provider's id (`agenthub/<provider>/<model>`).
|
||||
* Without it omp falls back to the provider's FIRST listed model — a retired
|
||||
* `claude-3-5-sonnet` on the gateway, which answers 404 and retries forever —
|
||||
* or to a built-in provider the member has no login for.
|
||||
*/
|
||||
function shareModel(requested, hostDefault = hostDefaultModel()) {
|
||||
const want = requested || hostDefault;
|
||||
if (!want) return requested || null;
|
||||
if (want.startsWith(`${PROVIDER}/`)) return want;
|
||||
if (want.includes("/")) return `${PROVIDER}/${want}`;
|
||||
const provider = hostDefault ? hostDefault.split("/")[0] : "anthropic";
|
||||
return `${PROVIDER}/${provider}/${want}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `spawnRun` for an omp pane: `{env, model(), place()}` — env to add,
|
||||
* the `--model` to run on (`shareModel`) and the home to prepare — or
|
||||
* null when sharing is off (the pane then runs on the member's own login).
|
||||
* Only a MEMBER's pane — the host's omp already holds the login itself.
|
||||
*/
|
||||
function forSpawn(member, runId) {
|
||||
if (!member || !enabled() || !available()) return null;
|
||||
const token = takeToken(runId);
|
||||
return {
|
||||
// The key itself, never in argv: tmux -e, or a member's launch file.
|
||||
env: { OMP_SKIP_SETUP: "1", [TOKEN_ENV]: token },
|
||||
model: (requested) => shareModel(requested),
|
||||
place() {
|
||||
try {
|
||||
writeMemberModels(member);
|
||||
} catch (err) {
|
||||
console.warn(`omp share: ${member.login}'s models.yml left as is:`, err.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── the two daemons ───────────────────────────────────────────────────────
|
||||
|
||||
const procs = { broker: null, gateway: null };
|
||||
const status = { broker: null, gateway: null }; // { pid, startedAt } | { error }
|
||||
let timers = [];
|
||||
let stopping = false;
|
||||
|
||||
function ompBinary() {
|
||||
return require("./ai-tools").detect("omp").path;
|
||||
}
|
||||
|
||||
function readPids() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pidsFile(), "utf8")) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePids() {
|
||||
ensureDir();
|
||||
const pids = {};
|
||||
for (const [k, p] of Object.entries(procs)) if (p?.pid) pids[k] = p.pid;
|
||||
fs.writeFileSync(pidsFile(), JSON.stringify(pids), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** A daemon an earlier server left behind (KillMode=process keeps children): ours by its argv only. */
|
||||
function killStale() {
|
||||
for (const [name, pid] of Object.entries(readPids())) {
|
||||
if (!Number.isInteger(pid)) continue;
|
||||
let argv = "";
|
||||
try {
|
||||
argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (argv.includes(`auth-${name}\0serve`)) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function portOpen(p) {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.connect({ host: "127.0.0.1", port: p });
|
||||
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||
s.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitPort(p, ms = 15_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (await portOpen(p)) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The env a daemon gets: the host's basics only, never AgentHub's own secrets. */
|
||||
function daemonEnv(extra = {}) {
|
||||
const keep = ["HOME", "USER", "LOGNAME", "PATH", "LANG", "LC_ALL", "TMPDIR"];
|
||||
const env = {};
|
||||
for (const k of keep) if (process.env[k]) env[k] = process.env[k];
|
||||
return { ...env, OMP_SKIP_SETUP: "1", ...extra };
|
||||
}
|
||||
|
||||
function launch(name, bin, args, env) {
|
||||
ensureDir();
|
||||
const out = fs.openSync(logFile(name), "a", 0o600);
|
||||
const child = spawn(bin, args, { env, stdio: ["ignore", out, out], windowsHide: true });
|
||||
fs.closeSync(out);
|
||||
procs[name] = child;
|
||||
status[name] = { pid: child.pid, startedAt: new Date().toISOString() };
|
||||
child.on("error", (err) => (status[name] = { error: err.message }));
|
||||
child.on("exit", (code, signal) => {
|
||||
// One this module stopped (or replaced) itself is no crash.
|
||||
if (procs[name] !== child) return;
|
||||
procs[name] = null;
|
||||
if (stopping || !enabled()) return;
|
||||
status[name] = { error: `exited (${signal || code})` };
|
||||
// The pair restarts together: the gateway holds the broker's token.
|
||||
restartSoon();
|
||||
});
|
||||
writePids();
|
||||
return child;
|
||||
}
|
||||
|
||||
let restartTimer = null;
|
||||
let backoff = 5_000;
|
||||
function restartSoon() {
|
||||
if (restartTimer) return;
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
start().catch(() => {});
|
||||
backoff = Math.min(backoff * 2, 5 * 60_000);
|
||||
}, backoff);
|
||||
if (restartTimer.unref) restartTimer.unref();
|
||||
}
|
||||
|
||||
async function startDaemons() {
|
||||
const bin = ompBinary();
|
||||
if (!bin) {
|
||||
status.broker = status.gateway = { error: "omp is not installed" };
|
||||
return;
|
||||
}
|
||||
killStale();
|
||||
// The gateway refuses to start without the clients file it names, and
|
||||
// must list the queued keys from its first read.
|
||||
save(pruneTokens(readTokens(), null));
|
||||
const bport = brokerPort();
|
||||
const gport = gatewayPort();
|
||||
for (const p of [bport, gport]) {
|
||||
if (await waitPortClosed(p)) continue;
|
||||
status.broker = status.gateway = { error: `port ${p} is taken` };
|
||||
return;
|
||||
}
|
||||
launch("broker", bin, ["auth-broker", "serve", `--bind=127.0.0.1:${bport}`], daemonEnv());
|
||||
if (!(await waitPort(bport))) {
|
||||
status.broker = { error: "the broker did not start listening" };
|
||||
return restartSoon();
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = fs.readFileSync(path.join(ompDir(), "auth-broker.token"), "utf8").trim();
|
||||
} catch {
|
||||
status.broker = { error: "the broker wrote no token" };
|
||||
return restartSoon();
|
||||
}
|
||||
launch(
|
||||
"gateway",
|
||||
bin,
|
||||
["auth-gateway", "serve", `--bind=127.0.0.1:${gport}`, `--clients=${clientsFile()}`],
|
||||
daemonEnv({
|
||||
OMP_AUTH_BROKER_URL: `http://127.0.0.1:${bport}`,
|
||||
OMP_AUTH_BROKER_TOKEN: token,
|
||||
})
|
||||
);
|
||||
if (await waitPort(gport)) backoff = 5_000;
|
||||
else status.gateway = { error: "the gateway did not start listening" };
|
||||
}
|
||||
|
||||
/** A port a previous daemon is still letting go of gets a few seconds. */
|
||||
async function waitPortClosed(p, ms = 5_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (!(await portOpen(p))) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stopDaemons() {
|
||||
for (const [name, child] of Object.entries(procs)) {
|
||||
if (!child) continue;
|
||||
procs[name] = null;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(pidsFile(), { force: true });
|
||||
} catch {
|
||||
/* nothing recorded */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the daemons and the clients sync — when sharing is on, under S3 only. */
|
||||
async function start() {
|
||||
stopping = false;
|
||||
if (!available() || !enabled()) return;
|
||||
if (!timers.length) {
|
||||
const t = setInterval(syncClients, SYNC_MS);
|
||||
if (t.unref) t.unref();
|
||||
timers.push(t);
|
||||
}
|
||||
syncClients();
|
||||
if (procs.broker || procs.gateway) return;
|
||||
await startDaemons();
|
||||
}
|
||||
|
||||
/** Stop everything this module started (the server's shutdown, or the switch turned off). */
|
||||
function stop() {
|
||||
stopping = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
timers = [];
|
||||
if (restartTimer) clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
status.broker = status.gateway = null;
|
||||
}
|
||||
|
||||
async function setEnabled(on) {
|
||||
ensureDir();
|
||||
if (on) {
|
||||
fs.writeFileSync(onFlag(), "", { mode: 0o600 });
|
||||
await start();
|
||||
} else {
|
||||
fs.rmSync(onFlag(), { force: true });
|
||||
stop();
|
||||
// No pane keeps a way in once sharing is off.
|
||||
revokeAll();
|
||||
}
|
||||
return state();
|
||||
}
|
||||
|
||||
/** What the settings card shows — never a token. */
|
||||
function state() {
|
||||
const live = (name) => (procs[name] ? status[name] : null);
|
||||
return {
|
||||
available: available(),
|
||||
enabled: enabled(),
|
||||
installed: !!ompBinary(),
|
||||
broker: live("broker")
|
||||
? { running: true }
|
||||
: { running: false, error: status.broker?.error || null },
|
||||
gateway: live("gateway")
|
||||
? { running: true, port: gatewayPort() }
|
||||
: { running: false, error: status.gateway?.error || null },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
available,
|
||||
enabled,
|
||||
setEnabled,
|
||||
start,
|
||||
stop,
|
||||
state,
|
||||
forSpawn,
|
||||
syncClients,
|
||||
// exported for the tests
|
||||
mergedModels,
|
||||
providerEntry,
|
||||
shareModel,
|
||||
takeToken,
|
||||
pruneTokens,
|
||||
clientsFor,
|
||||
readTokens,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
};
|
||||
+18
-2
@@ -302,7 +302,11 @@ function buildCodexArgv({
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||
function buildCatalogArgv(
|
||||
entry,
|
||||
bin,
|
||||
{ model, resumeSessionId, resumeLast, bypass, initialPrompt }
|
||||
) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||
@@ -485,6 +489,15 @@ function spawnRun(args) {
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, with a key already listed there.
|
||||
const ompShare =
|
||||
tool === "omp"
|
||||
? require("./omp-share").forSpawn(
|
||||
require("./run-as").accountForLane(laneId),
|
||||
runIdForLane(laneId, slot)
|
||||
)
|
||||
: null;
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -540,7 +553,8 @@ function spawnRun(args) {
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
tool,
|
||||
model,
|
||||
// A shared omp login runs on the gateway's provider (omp-share.js).
|
||||
model: ompShare ? ompShare.model(model) : model,
|
||||
permissionMode,
|
||||
effort,
|
||||
sandbox,
|
||||
@@ -654,6 +668,7 @@ function spawnRun(args) {
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? poolAccount.env : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
...(ompShare ? ompShare.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
@@ -688,6 +703,7 @@ function spawnRun(args) {
|
||||
const prepareHome = () => {
|
||||
codexPool?.place();
|
||||
poolAccount?.place();
|
||||
ompShare?.place();
|
||||
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
|
||||
require("./run-as").markClaudeOnboarded(member);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @file tool-profiles.js
|
||||
* @description Named login profiles for the AI tools other than Claude Code —
|
||||
* the same idea as cc-profiles.js (which stays Claude's, with its Keychain and
|
||||
* `.claude.json` rules): snapshot the login a tool's CLI holds right now, and
|
||||
* swap it back in later, touching nothing but that login.
|
||||
*
|
||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||
* key). Config, sessions, skills and history stay where they are.
|
||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||
* `~/.omp/agent/agent.db`; a failed import puts the outgoing set back.
|
||||
* - `antigravity`: not switchable — agy keeps its login in the OS keyring
|
||||
* (Secret Service), which AgentHub does not read or write.
|
||||
*
|
||||
* Profiles live one JSON file each under
|
||||
* `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (dir 0700, files 0600). They hold
|
||||
* live tokens, so `listProfiles` returns identity metadata only; the
|
||||
* token-bearing `readProfile` is for the account pool's move-in, never a route.
|
||||
* The rules are cc-profiles.js's: one account is one profile (`EDUPLICATE`), a
|
||||
* switch saves the outgoing login back into its own profile first and refuses
|
||||
* one that was never saved (`EUNSAVED`), and the only saved copy of the live
|
||||
* login is not deletable (`EACTIVE`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const cp = require("node:child_process");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const sha = (text) => crypto.createHash("sha256").update(String(text)).digest("hex");
|
||||
|
||||
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codex ──────────────────────────────────────────────────────────────
|
||||
|
||||
const codexAuthFile = () => path.join(getCodexHome(), "auth.json");
|
||||
|
||||
/** The id_token's claims, or null. Not signature-checked: a label, not a proof. */
|
||||
function jwtClaims(token) {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const codex = {
|
||||
label: "Codex",
|
||||
read() {
|
||||
const auth = readJson(codexAuthFile());
|
||||
return auth && typeof auth === "object" && (auth.tokens || auth.OPENAI_API_KEY) ? auth : null;
|
||||
},
|
||||
write(secret) {
|
||||
writeJson(codexAuthFile(), secret);
|
||||
return [codexAuthFile()];
|
||||
},
|
||||
identify(auth) {
|
||||
if (auth.tokens && auth.tokens.id_token) {
|
||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||
const claim = c["https://api.openai.com/auth"] || {};
|
||||
const account = auth.tokens.account_id || claim.chatgpt_account_id || null;
|
||||
return {
|
||||
key: `chatgpt:${account || c.email || sha(auth.tokens.id_token).slice(0, 16)}`,
|
||||
kind: "chatgpt",
|
||||
email: c.email || null,
|
||||
detail: claim.chatgpt_plan_type || null,
|
||||
};
|
||||
}
|
||||
const key = String(auth.OPENAI_API_KEY || "");
|
||||
return {
|
||||
key: `apikey:${sha(key).slice(0, 16)}`,
|
||||
kind: "apikey",
|
||||
email: null,
|
||||
detail: key ? `…${key.slice(-4)}` : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
|
||||
function ompBin() {
|
||||
const bin = process.env.AGENTHUB_OMP_BIN || require("./ai-tools").detect("omp").path;
|
||||
if (!bin) throw err("ENOTINSTALLED", "Oh My Pi (omp) is not installed on this machine");
|
||||
return bin;
|
||||
}
|
||||
|
||||
function ompRun(args, input) {
|
||||
return cp.execFileSync(ompBin(), args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
timeout: 20000,
|
||||
maxBuffer: 8 << 20,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** One credential's stable part: who it is, never the rotating token. */
|
||||
function ompCredentialId(c) {
|
||||
const cred = (c && c.credential) || {};
|
||||
const who =
|
||||
cred.email ||
|
||||
cred.accountId ||
|
||||
(cred.type === "oauth" ? "oauth" : sha(JSON.stringify(cred)).slice(0, 12));
|
||||
return `${c.provider}:${who}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty omp's credential store: the credentials and what hangs off them (rate
|
||||
* blocks, refresh leases). omp's own triggers bump `auth_change_revision`, so
|
||||
* a running omp re-reads. ponytail: this writes omp's schema directly (it has
|
||||
* no logout command); a schema change there surfaces as an error here, and the
|
||||
* caller puts the outgoing set back.
|
||||
*/
|
||||
function ompClear() {
|
||||
const file = path.join(ompDir(), "agent", "agent.db");
|
||||
if (!fs.existsSync(file)) return;
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const t of [
|
||||
"auth_credential_blocks",
|
||||
"auth_credential_refresh_leases",
|
||||
"auth_credentials",
|
||||
]) {
|
||||
db.exec(`DELETE FROM ${t}`);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const omp = {
|
||||
label: "Oh My Pi",
|
||||
read() {
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(ompRun(["auth", "export"]));
|
||||
} catch (e) {
|
||||
if (e.code === "ENOTINSTALLED") throw e;
|
||||
throw err("EOMP", `omp auth export failed: ${(e.stderr || e.message || "").trim()}`);
|
||||
}
|
||||
return out && Array.isArray(out.credentials) && out.credentials.length > 0 ? out : null;
|
||||
},
|
||||
write(secret, outgoing) {
|
||||
ompClear();
|
||||
try {
|
||||
ompRun(["auth", "import", "-"], JSON.stringify(secret));
|
||||
} catch (e) {
|
||||
if (outgoing) {
|
||||
try {
|
||||
ompRun(["auth", "import", "-"], JSON.stringify(outgoing));
|
||||
} catch {
|
||||
/* the outgoing set is still in its profile file */
|
||||
}
|
||||
}
|
||||
throw err("EOMP", `omp auth import failed: ${(e.stderr || e.message || "").trim()}`);
|
||||
}
|
||||
return [path.join(ompDir(), "agent", "agent.db")];
|
||||
},
|
||||
identify(exp) {
|
||||
const ids = [...new Set(exp.credentials.map(ompCredentialId))].sort();
|
||||
const email = exp.credentials.map((c) => c.credential && c.credential.email).find(Boolean);
|
||||
return {
|
||||
key: ids.join("|"),
|
||||
kind: "credentials",
|
||||
email: email || null,
|
||||
detail: [...new Set(exp.credentials.map((c) => c.provider))].join(", ") || null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let ADAPTERS = { codex, omp };
|
||||
|
||||
/** Tools listed in the Profiles panel that cannot be switched, and why. */
|
||||
const UNSUPPORTED = {
|
||||
antigravity:
|
||||
"Antigravity keeps its login in the system keyring (Secret Service), which AgentHub does not read or write. Sign in or out inside agy itself.",
|
||||
};
|
||||
|
||||
// ── The store ──────────────────────────────────────────────────────────
|
||||
|
||||
function adapter(tool) {
|
||||
const a = ADAPTERS[tool];
|
||||
if (!a) {
|
||||
throw err(
|
||||
UNSUPPORTED[tool] ? "EUNSUPPORTED" : "EBADTOOL",
|
||||
UNSUPPORTED[tool] || `no profiles for tool "${tool}"`
|
||||
);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const profilesDir = (tool) => path.join(getClaudeHome(), "agenthub-profiles", tool);
|
||||
|
||||
function profileFile(tool, name) {
|
||||
adapter(tool);
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(profilesDir(tool), `${name}.json`);
|
||||
}
|
||||
|
||||
function normalizeLabel(label) {
|
||||
const clean = typeof label === "string" ? label.trim() : "";
|
||||
if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
const base = String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "profile";
|
||||
}
|
||||
|
||||
function freeName(tool, base) {
|
||||
let name = base;
|
||||
for (let n = 2; fs.existsSync(path.join(profilesDir(tool), `${name}.json`)); n += 1) {
|
||||
name = `${base}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** One stored profile WITH its secret, or null. Never for a route. */
|
||||
function readProfile(tool, name) {
|
||||
const stored = readJson(profileFile(tool, name));
|
||||
return stored && typeof stored === "object" && stored.secret ? stored : null;
|
||||
}
|
||||
|
||||
/** Token-free view of the live login, or null when the tool holds none. */
|
||||
function liveIdentity(tool) {
|
||||
const a = adapter(tool);
|
||||
const live = a.read();
|
||||
return live ? { live, identity: a.identify(live) } : { live: null, identity: null };
|
||||
}
|
||||
|
||||
const publicIdentity = (id) => (id ? { kind: id.kind, email: id.email, detail: id.detail } : null);
|
||||
|
||||
/**
|
||||
* Every stored profile of `tool` plus the login that is live right now.
|
||||
* @returns `{ tool, items, active }` — `items[].active` marks the profile of
|
||||
* the live login. Secrets and identity keys are never included.
|
||||
*/
|
||||
function listProfiles(tool) {
|
||||
const { identity: live } = liveIdentity(tool);
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(profilesDir(tool))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* none saved yet */
|
||||
}
|
||||
const items = [];
|
||||
for (const name of names) {
|
||||
if (!NAME_RE.test(name)) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readProfile(tool, name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stored) continue;
|
||||
const id = ADAPTERS[tool].identify(stored.secret);
|
||||
items.push({
|
||||
name,
|
||||
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
|
||||
savedAt: stored.savedAt || null,
|
||||
active: Boolean(live) && id.key === live.key,
|
||||
...publicIdentity(id),
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
||||
return { tool, items, active: publicIdentity(live) };
|
||||
}
|
||||
|
||||
/** The saved profile holding the same login as `identity`, other than `except`. */
|
||||
function holderOf(tool, key, except = null) {
|
||||
for (const p of listProfiles(tool).items) {
|
||||
if (p.name === except) continue;
|
||||
const stored = readProfile(tool, p.name);
|
||||
if (stored && ADAPTERS[tool].identify(stored.secret).key === key) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the login `tool` holds right now. Re-saving into the login's own
|
||||
* profile refreshes it; a second copy under another name is `EDUPLICATE`.
|
||||
*/
|
||||
function saveProfile(tool, name) {
|
||||
const { live, identity } = liveIdentity(tool);
|
||||
if (!live) {
|
||||
throw err("ENOAUTH", `${ADAPTERS[tool].label} holds no login on this machine - sign in first`);
|
||||
}
|
||||
const explicit = name !== undefined && name !== null;
|
||||
const readable =
|
||||
(identity.email || "").split("@")[0] ||
|
||||
(identity.detail ? `${identity.kind}-${identity.detail}` : identity.kind);
|
||||
let target;
|
||||
if (explicit) {
|
||||
profileFile(tool, name);
|
||||
target = name;
|
||||
} else {
|
||||
const own = holderOf(tool, identity.key);
|
||||
target = own ? own.name : freeName(tool, slugify(readable));
|
||||
}
|
||||
const clash = holderOf(tool, identity.key, target);
|
||||
if (clash) {
|
||||
throw err(
|
||||
"EDUPLICATE",
|
||||
`this login is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
|
||||
);
|
||||
}
|
||||
const existing = readProfile(tool, target);
|
||||
const label = (existing && existing.label) || (explicit ? target : readable);
|
||||
writeJson(profileFile(tool, target), {
|
||||
name: target,
|
||||
label,
|
||||
savedAt: Date.now(),
|
||||
secret: live,
|
||||
});
|
||||
return { ok: true, name: target, label, ...publicIdentity(identity) };
|
||||
}
|
||||
|
||||
function renameProfile(tool, name, label) {
|
||||
const stored = readProfile(tool, name);
|
||||
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const clean = normalizeLabel(label);
|
||||
const taken = listProfiles(tool).items.find(
|
||||
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
|
||||
);
|
||||
if (taken) throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
|
||||
writeJson(profileFile(tool, name), { ...stored, label: clean });
|
||||
return { ok: true, name, label: clean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a stored profile the tool's live login. The outgoing login is saved back
|
||||
* into its own profile first (its tokens may have rotated since), and a switch
|
||||
* away from a login that was never saved is refused.
|
||||
*/
|
||||
function activateProfile(tool, name) {
|
||||
const a = adapter(tool);
|
||||
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const { live, identity } = liveIdentity(tool);
|
||||
let savedOutgoing = null;
|
||||
if (live) {
|
||||
const mine = holderOf(tool, identity.key);
|
||||
if (!mine) {
|
||||
throw err(
|
||||
"EUNSAVED",
|
||||
"the login in use right now is not saved as a profile - save it first, or it is lost on switch"
|
||||
);
|
||||
}
|
||||
savedOutgoing = saveProfile(tool, mine.name).name;
|
||||
}
|
||||
const target = readProfile(tool, name);
|
||||
const written = a.write(target.secret, live);
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
}
|
||||
|
||||
function deleteProfile(tool, name) {
|
||||
const file = profileFile(tool, name);
|
||||
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const entry = listProfiles(tool).items.find((p) => p.name === name);
|
||||
if (entry && entry.active) {
|
||||
throw err(
|
||||
"EACTIVE",
|
||||
"cannot delete the saved copy of the login in use right now - switch away first"
|
||||
);
|
||||
}
|
||||
fs.rmSync(file, { force: true });
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
/** Remove a profile whatever it holds: the pool took its login (routes/accounts.js). */
|
||||
function dropProfile(tool, name) {
|
||||
fs.rmSync(profileFile(tool, name), { force: true });
|
||||
}
|
||||
|
||||
/** The tools this module covers, switchable or not, for the Profiles panel. */
|
||||
function tools() {
|
||||
return [
|
||||
...Object.keys(ADAPTERS).map((id) => ({ tool: id, supported: true })),
|
||||
...Object.entries(UNSUPPORTED).map(([id, reason]) => ({ tool: id, supported: false, reason })),
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tools,
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// Token-bearing: for routes/accounts.js's move into the pool, never a response.
|
||||
readProfile,
|
||||
dropProfile,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
ompCredentialId,
|
||||
__setAdapter: (tool, a) => {
|
||||
ADAPTERS = { ...ADAPTERS, [tool]: a };
|
||||
},
|
||||
};
|
||||
@@ -40,7 +40,8 @@ const schemas = {
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
resumeById: {
|
||||
type: "boolean",
|
||||
description: "Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
description:
|
||||
"Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
},
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
@@ -132,6 +133,46 @@ const paths = {
|
||||
responses: ok,
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/omp-sharing": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Oh My Pi login sharing state",
|
||||
description:
|
||||
"Server mode with per-user accounts (S3): whether every member's omp pane runs on the HOST account's omp login through omp's own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.",
|
||||
operationId: "aiToolsOmpSharing",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.",
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Turn Oh My Pi login sharing on or off",
|
||||
description:
|
||||
"An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.",
|
||||
operationId: "aiToolsSetOmpSharing",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["enabled"],
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The new state, as GET." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "Not an admin" },
|
||||
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}": {
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - POST /api/me/avatar/url (Auth) — the same picture downloaded from a link
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
@@ -267,7 +268,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "changeMyPassword",
|
||||
summary:
|
||||
"Change the account's own password: the current one required (throttled); this login stays, every other ends",
|
||||
"Change the account's own password: the current one required (throttled); the new one must pass every rule (8+ characters, lower, upper, digit, symbol, not the username) and differ from the current one; this login stays, every other ends; clears mustChangePassword",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -285,7 +286,7 @@ const paths = {
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Changed ({ok: true})" },
|
||||
400: err("EBADPASSWORD"),
|
||||
400: err("EBADPASSWORD / EWEAKPASSWORD (error.rules names the failed rules)"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("EREAUTH (current password wrong) / ELOCALMODE"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
@@ -494,6 +495,34 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/avatar/url": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "setMyAvatarFromUrl",
|
||||
summary:
|
||||
"Set the account's picture from an https link the server downloads (public addresses only, at most 3 redirects, PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["url"],
|
||||
properties: { url: { type: "string", format: "uri" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Set", content: accountBody },
|
||||
400: err("EBADURL / EPRIVATEURL / EBADIMAGE"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("ELOCALMODE"),
|
||||
413: err("ETOOBIG"),
|
||||
502: err("EFETCH — the link could not be downloaded"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/avatar": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
* 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
|
||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||
* in the same way. `GET /profiles[?tool=codex]` 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
|
||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
|
||||
@@ -35,6 +37,7 @@ const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const toolProfiles = require("../lib/tool-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
const router = Router();
|
||||
@@ -57,8 +60,19 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
|
||||
/** Saved switch-user profiles an admin may move into the pool — never a token. */
|
||||
router.get("/profiles", (_req, res) => {
|
||||
router.get("/profiles", (req, res) => {
|
||||
try {
|
||||
if (req.query.tool === "codex") {
|
||||
return res.json({
|
||||
profiles: toolProfiles.listProfiles("codex").items.map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
email: p.email,
|
||||
subscriptionType: p.detail,
|
||||
active: p.active,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
profiles: profiles.listProfiles().items.map((p) => ({
|
||||
name: p.name,
|
||||
@@ -185,6 +199,20 @@ function fromProfile(name, poolName) {
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a saved Codex profile (Settings → AI tools → Profiles) into the pool.
|
||||
* The profile file goes, as for Claude: the pool is now the login's holder.
|
||||
* The host's own CODEX_HOME is left as it is — Codex's old refresh token still
|
||||
* refreshes (codex-pool.js), so the host keeps working on it.
|
||||
*/
|
||||
function fromCodexProfile(name, poolName) {
|
||||
const stored = toolProfiles.readProfile("codex", name);
|
||||
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
|
||||
const account = codexPool.addAccount(poolName || name, { auth: stored.secret });
|
||||
toolProfiles.dropProfile("codex", name);
|
||||
return account;
|
||||
}
|
||||
|
||||
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
|
||||
function codexSecret(token) {
|
||||
const text = String(token || "").trim();
|
||||
@@ -203,7 +231,9 @@ router.post(
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
|
||||
@@ -36,6 +36,36 @@ router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
res.json(aiTools.view());
|
||||
});
|
||||
|
||||
// Oh My Pi login sharing (server/lib/omp-share.js): S3 only, an admin's
|
||||
// switch (`/ai-tools` writes are admin-only, auth.js ADMIN_WRITE_PREFIXES).
|
||||
// Declared before `/:id`, which would take `omp-sharing` as a tool id.
|
||||
router.get("/omp-sharing", (_req, res) => {
|
||||
res.json(require("../lib/omp-share").state());
|
||||
});
|
||||
|
||||
router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
|
||||
const on = req.body?.enabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "enabled: boolean" } });
|
||||
}
|
||||
const share = require("../lib/omp-share");
|
||||
if (!share.available()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOS3", message: "sharing needs server mode with per-user accounts" },
|
||||
});
|
||||
}
|
||||
const state = await share.setEnabled(on);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`omp login sharing ${on ? "on" : "off"}`
|
||||
);
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||
// Every field checked before anything is written: a bad one saves nothing.
|
||||
|
||||
@@ -521,7 +521,7 @@ function applyFirstUserDescriptor(sessionId, result) {
|
||||
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
|
||||
}
|
||||
|
||||
const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
const processEventTx = db.transaction((hookType, data, tool = "claude", peer = null) => {
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
@@ -1297,7 +1297,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
||||
try {
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data);
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
|
||||
if (row) {
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||
}
|
||||
@@ -1313,12 +1313,12 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
* that transaction COMMITTED — in order, in the same tick. A frame sent from
|
||||
* inside it described a row a later throw could still roll back.
|
||||
*/
|
||||
function processEvent(hookType, data, tool) {
|
||||
function processEvent(hookType, data, tool, peer = null) {
|
||||
const outer = pendingMarks;
|
||||
pendingMarks = [];
|
||||
let marks = null;
|
||||
try {
|
||||
const out = holdFrames(() => processEventTx(hookType, data, tool));
|
||||
const out = holdFrames(() => processEventTx(hookType, data, tool, peer));
|
||||
marks = pendingMarks;
|
||||
return out;
|
||||
} finally {
|
||||
@@ -1628,7 +1628,7 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
held = [];
|
||||
let result = null;
|
||||
try {
|
||||
result = processEvent(hook_type, data, tool);
|
||||
result = processEvent(hook_type, data, tool, pushSource ? null : peer);
|
||||
if (result) {
|
||||
if (pushSource) {
|
||||
// Tagged in the same tick processEvent created it, so no local liveness
|
||||
|
||||
+31
-4
@@ -18,14 +18,18 @@
|
||||
* `/account` (server mode) is the signed-in account itself for Settings →
|
||||
* Account: GET its profile, workspace/session counts and logins; PATCH its
|
||||
* display name and email; `POST /password` changes its own password (the old
|
||||
* one required, throttled like a step-up; every OTHER login ends);
|
||||
* one required, throttled like a step-up; every OTHER login ends; the new one
|
||||
* must pass every rule of `auth.passwordProblems` and differ from the old —
|
||||
* EWEAKPASSWORD with the failed `rules` — and it clears the "pick your own
|
||||
* password" flag an admin's create/reset sets);
|
||||
* `POST /sign-out-others` ends every login but this one. These writes take the
|
||||
* account from the login COOKIE only — a pane token (an agent acting as its
|
||||
* owner) may not change the owner's login. Each change is audited.
|
||||
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
|
||||
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
|
||||
* account writes like the profile's. The sidebar shows it beside the name.
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` downloads the same
|
||||
* from an https link (public addresses only, server/lib/avatar-url.js) and
|
||||
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
@@ -63,6 +67,7 @@ const PREF_KEYS = [
|
||||
"agenthub.tour",
|
||||
"agenthub.chatUi",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
];
|
||||
const MAX_VALUE = 8 * 1024;
|
||||
|
||||
@@ -170,7 +175,11 @@ const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADIMAGE: 400,
|
||||
EBADURL: 400,
|
||||
EPRIVATEURL: 400,
|
||||
EFETCH: 502,
|
||||
ETOOBIG: 413,
|
||||
EREAUTH: 403,
|
||||
ETOOMANY: 429,
|
||||
@@ -206,7 +215,10 @@ function accountWrite(handler) {
|
||||
} catch (err) {
|
||||
const status = ACCOUNT_STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
// EWEAKPASSWORD names the rules the password failed.
|
||||
res.status(status).json({
|
||||
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -233,6 +245,10 @@ router.post(
|
||||
sameOriginGuard,
|
||||
accountWrite(async (req, user, body) => {
|
||||
await auth.confirmPassword(user, body.currentPassword);
|
||||
// The account's own choice: every rule, and not the one it has now. This
|
||||
// also clears a "pick your own password" flag an admin's reset left.
|
||||
auth.validateOwnPassword(body.newPassword, user.username);
|
||||
await auth.assertNotCurrent(user.id, body.newPassword);
|
||||
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
|
||||
return { out: { ok: true }, action: "own password changed" };
|
||||
})
|
||||
@@ -273,6 +289,17 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
// The same picture from a link: the server downloads it (server/lib/avatar-url.js).
|
||||
router.post(
|
||||
"/avatar/url",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const buf = await require("../lib/avatar-url").fetchAvatarImage(body.url);
|
||||
avatars.setAvatar(user.id, buf);
|
||||
return { out: accountView(user.id), action: "own avatar set from a link" };
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/avatar",
|
||||
sameOriginGuard,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file tool-profiles.js
|
||||
* @description `/api/tool-profiles` — the login profiles of the AI tools other
|
||||
* than Claude Code (server/lib/tool-profiles.js; Claude's stay under
|
||||
* `/api/cc-config/profiles`, and Settings → AI tools → Profiles shows both).
|
||||
* `GET /` lists every covered tool (`supported: false` + `reason` for one that
|
||||
* cannot be switched); `POST /:tool` saves the live login, `PATCH /:tool`
|
||||
* renames, `POST /:tool/activate` switches, `DELETE /:tool` deletes — the
|
||||
* profile name always in the body. Identity metadata only, never a token. The
|
||||
* writes change the HOST's own login, so `/tool-profiles` is in
|
||||
* ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES like `/cc-config`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
// Every write swaps a live login: never from a cross-site form.
|
||||
router.use((req, res, next) => (req.method === "GET" ? next() : sameOriginGuard(req, res, next)));
|
||||
|
||||
const STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADTOOL: 404,
|
||||
ENOTFOUND: 404,
|
||||
EUNSUPPORTED: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
EBADJSON: 409,
|
||||
EACTIVE: 409,
|
||||
EUNSAVED: 409,
|
||||
ENOAUTH: 409,
|
||||
EDUPLICATE: 409,
|
||||
ELABELTAKEN: 409,
|
||||
EOMP: 502,
|
||||
};
|
||||
|
||||
const handle = (fn) => async (req, res) => {
|
||||
try {
|
||||
res.json(await fn(req));
|
||||
} catch (e) {
|
||||
res
|
||||
.status(STATUS[e.code] || 500)
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
};
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
handle(() => ({
|
||||
tools: profiles.tools().map((t) => {
|
||||
if (!t.supported) return { ...t, items: [], active: null };
|
||||
try {
|
||||
return { ...t, ...profiles.listProfiles(t.tool), error: null };
|
||||
} catch (e) {
|
||||
// One tool's failure (omp not installed) never hides the others.
|
||||
return { ...t, items: [], active: null, error: { code: e.code, message: e.message } };
|
||||
}
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
const name = (req) => (req.body || {}).name;
|
||||
|
||||
router.post(
|
||||
"/:tool",
|
||||
handle((req) => profiles.saveProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.patch(
|
||||
"/:tool",
|
||||
handle((req) => profiles.renameProfile(req.params.tool, name(req), (req.body || {}).label))
|
||||
);
|
||||
router.post(
|
||||
"/:tool/activate",
|
||||
handle((req) => profiles.activateProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.delete(
|
||||
"/:tool",
|
||||
handle((req) => profiles.deleteProfile(req.params.tool, name(req)))
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
+83
-32
@@ -25,7 +25,11 @@
|
||||
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
||||
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
||||
* an admin never demotes or disables themselves (ESELF), every change is
|
||||
* audited, and in local mode — no login to check — they all refuse.
|
||||
* audited, and in local mode — no login to check — they all refuse. A
|
||||
* password an admin gives another account (create, reset) may be anything
|
||||
* non-empty and makes that account pick its own at its next login
|
||||
* (`must_change_password`); an admin's own new password must pass every
|
||||
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -33,7 +37,6 @@ const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const { listUsers } = auth;
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
@@ -54,42 +57,72 @@ const USAGE_COLS = [
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(userId, sinceHour, rules) {
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
SELECT s.id AS session_id, l.owner_id AS uid
|
||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
SELECT s.id, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
|
||||
/** Map(user id → {cost, tokens}) every account's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(sinceHour, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
const w = sessionWhere({ user: { id: userId, role: "developer" } }, "s.id", "s.lane_id");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
`WITH owned AS (${OWNED_SESSIONS})
|
||||
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h JOIN sessions s ON s.id = h.session_id
|
||||
WHERE h.hour >= ? AND ${w.where}
|
||||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
|
||||
WHERE h.hour >= ?
|
||||
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(sinceHour, ...w.params);
|
||||
const tokens = rows.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
return { cost: rows.length ? calculateCost(rows, rules).total_cost : 0, tokens };
|
||||
.all(sinceHour);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
|
||||
byUser.get(r.uid).push(r);
|
||||
}
|
||||
const out = new Map();
|
||||
for (const [uid, list] of byUser) {
|
||||
const tokens = list.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||
|
||||
/** Map(key → n) from rows of `{k, n}`. */
|
||||
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
const users = listUsers().map((u) => {
|
||||
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
|
||||
return {
|
||||
...u,
|
||||
...auth.loginStats(u.id),
|
||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
||||
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
|
||||
.n,
|
||||
last7d: usageSince(u.id, hourAgo(7), rules),
|
||||
last30d: usageSince(u.id, hourAgo(30), rules),
|
||||
};
|
||||
});
|
||||
// A fixed number of grouped queries, however many accounts there are.
|
||||
const logins = auth.allLoginStats();
|
||||
const workspaces = counts(
|
||||
db.prepare("SELECT owner_id AS k, COUNT(*) AS n FROM lanes GROUP BY owner_id").all()
|
||||
);
|
||||
const sessions = counts(
|
||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
||||
);
|
||||
const last7d = usageSince(hourAgo(7), rules);
|
||||
const last30d = usageSince(hourAgo(30), rules);
|
||||
const none = { cost: 0, tokens: 0 };
|
||||
const users = listUsers().map((u) => ({
|
||||
...u,
|
||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||
workspaces: workspaces.get(u.id) || 0,
|
||||
sessions: sessions.get(u.id) || 0,
|
||||
last7d: last7d.get(u.id) || none,
|
||||
last30d: last30d.get(u.id) || none,
|
||||
}));
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
@@ -112,6 +145,7 @@ const STATUS = {
|
||||
EBADEMAIL: 400,
|
||||
ENOTOWNED: 409,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADROLE: 400,
|
||||
EBADFIELD: 400,
|
||||
EREAUTH: 403,
|
||||
@@ -150,7 +184,9 @@ function accountChange(handler) {
|
||||
} catch (err) {
|
||||
const status = STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
res.status(status).json({
|
||||
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -196,7 +232,8 @@ router.post(
|
||||
"/",
|
||||
accountChange(async (_req, body) => {
|
||||
const role = body.role === undefined ? "developer" : body.role;
|
||||
const user = await auth.createUser(body.username, body.password, role);
|
||||
// The admin's password is a starting one: the user picks their own first.
|
||||
const user = await auth.createUser(body.username, body.password, role, { mustChange: true });
|
||||
return { status: 201, user, action: `create ${user.username} (${user.role})` };
|
||||
})
|
||||
);
|
||||
@@ -226,7 +263,13 @@ router.patch(
|
||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||
}
|
||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||
if (body.password !== undefined) auth.validatePassword(body.password);
|
||||
if (body.password !== undefined) {
|
||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||
if (self) {
|
||||
auth.validateOwnPassword(body.password, u.username);
|
||||
await auth.assertNotCurrent(u.id, body.password);
|
||||
} else auth.validatePassword(body.password);
|
||||
}
|
||||
if (body.signOut !== undefined && body.signOut !== true) {
|
||||
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
||||
}
|
||||
@@ -265,7 +308,7 @@ router.patch(
|
||||
// the login they are doing it from.
|
||||
const keepToken = self ? auth.cookieToken(req) : undefined;
|
||||
if (body.password !== undefined) {
|
||||
await auth.setPassword(u.username, body.password, { keepToken });
|
||||
await auth.setPassword(u.username, body.password, { keepToken, mustChange: !self });
|
||||
done.push("password reset");
|
||||
}
|
||||
if (body.signOut) {
|
||||
@@ -375,7 +418,15 @@ router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
const u = target(req);
|
||||
await auth.setPassword(u.username, body.password);
|
||||
const self = u.id === req.user.id;
|
||||
if (self) {
|
||||
auth.validateOwnPassword(body.password, u.username);
|
||||
await auth.assertNotCurrent(u.id, body.password);
|
||||
}
|
||||
await auth.setPassword(u.username, body.password, {
|
||||
keepToken: self ? auth.cookieToken(req) : undefined,
|
||||
mustChange: !self,
|
||||
});
|
||||
return { user: auth.publicUser(u), action: `${u.username}: password reset` };
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user