Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb65dbe795 | |||
| 0df2594f19 | |||
| 477dd03d31 | |||
| 9e6f16400a | |||
| a31f314688 | |||
| 89f58a37e8 |
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file ServerSettings.tsx
|
||||
* @description Administration → **Server settings** (server mode, an admin
|
||||
* only): every setting that acts on the server for everyone, moved out of
|
||||
* Settings so a user's Settings holds only their own things. Laptop
|
||||
* connections and every user's enrolled machines, members' test runs (S3),
|
||||
* the shared Oh My Pi login (S3), Artifacts Hub installs, the host's git
|
||||
* logins, scheduled reports, start at login / the icon / Quit, model pricing
|
||||
* and the data tools. Each block keeps its own component (each still checks
|
||||
* the role and hides itself where it does not apply); in local mode these
|
||||
* stay in Settings. A `#anchor` in the URL scrolls to its block.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemotePolicy } from "../remote/RemotePolicy";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { MemberPolicySettings } from "../settings/MemberPolicySettings";
|
||||
import { OmpSharingSettings } from "../settings/OmpSharingSettings";
|
||||
import { ArtifactsHubSettings } from "../settings/ArtifactsHubSettings";
|
||||
import { ReportSettings } from "../settings/ReportSettings";
|
||||
import { AutostartSettings } from "../settings/AutostartSettings";
|
||||
import { AllPricingSettings } from "../settings/PricingSettings";
|
||||
import { SettingsSection } from "../settings/SettingsRow";
|
||||
import { GitCredentialsConfig } from "../../pages/GitCredentialsConfig";
|
||||
import { Settings } from "../../pages/Settings";
|
||||
|
||||
function Block({ id, children }: { id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
const t1 = window.setTimeout(() => {
|
||||
document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({
|
||||
block: "start",
|
||||
});
|
||||
}, 50);
|
||||
return () => window.clearTimeout(t1);
|
||||
}, [hash]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6" data-testid="server-settings">
|
||||
<Block id="company-hub">
|
||||
<SettingsSection
|
||||
id="company-hub-admin"
|
||||
title={t("companyHub.adminTitle")}
|
||||
description={t("companyHub.adminDescription")}
|
||||
>
|
||||
<RemotePolicy />
|
||||
<RemoteDevices canRevoke />
|
||||
</SettingsSection>
|
||||
</Block>
|
||||
{/* These carry their own anchors (member-policy, omp-sharing, artifacts-hub). */}
|
||||
<MemberPolicySettings />
|
||||
<OmpSharingSettings />
|
||||
<ArtifactsHubSettings />
|
||||
<Block id="git-credentials">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</Block>
|
||||
<Block id="reports">
|
||||
<ReportSettings />
|
||||
</Block>
|
||||
<AutostartSettings />
|
||||
<Block id="pricing">
|
||||
<AllPricingSettings />
|
||||
</Block>
|
||||
{/* Its own anchor: #data. */}
|
||||
<Settings section="data" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file ServerSettings.test.tsx
|
||||
* @description Administration → Server settings: every server-wide block is
|
||||
* there (laptop connections + every machine, members' tests, omp sharing,
|
||||
* Artifacts installs, git logins, reports, start at login, pricing, data),
|
||||
* and an admin turns laptop connections on after one confirm (no password).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { 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";
|
||||
|
||||
const stub = vi.hoisted(() => (id: string) => () => <div data-testid={id} />);
|
||||
vi.mock("../../settings/MemberPolicySettings", () => ({
|
||||
MemberPolicySettings: stub("member-policy"),
|
||||
}));
|
||||
vi.mock("../../settings/OmpSharingSettings", () => ({ OmpSharingSettings: stub("omp-sharing") }));
|
||||
vi.mock("../../settings/ArtifactsHubSettings", () => ({
|
||||
ArtifactsHubSettings: stub("artifacts-hub"),
|
||||
}));
|
||||
vi.mock("../../settings/ReportSettings", () => ({ ReportSettings: stub("reports") }));
|
||||
vi.mock("../../settings/AutostartSettings", () => ({ AutostartSettings: stub("autostart") }));
|
||||
vi.mock("../../settings/PricingSettings", () => ({ AllPricingSettings: stub("pricing") }));
|
||||
vi.mock("../../../pages/GitCredentialsConfig", () => ({ GitCredentialsConfig: stub("git-creds") }));
|
||||
vi.mock("../../../pages/Settings", () => ({ Settings: stub("data") }));
|
||||
vi.mock("../../remote/RemoteDevices", () => ({ RemoteDevices: stub("remote-devices") }));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
remoteHub: { policy: vi.fn(), setPolicy: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { ServerSettings } from "../ServerSettings";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
const show = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServerSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("ServerSettings", () => {
|
||||
it("holds every server-wide block", async () => {
|
||||
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
show();
|
||||
for (const id of [
|
||||
"remote-devices",
|
||||
"member-policy",
|
||||
"omp-sharing",
|
||||
"artifacts-hub",
|
||||
"git-creds",
|
||||
"reports",
|
||||
"autostart",
|
||||
"pricing",
|
||||
"data",
|
||||
]) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
}
|
||||
expect(await screen.findByRole("switch", { name: /laptop/i })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it("turns laptop connections on after a confirm, with no password", async () => {
|
||||
api.remoteHub.policy.mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
api.remoteHub.setPolicy.mockResolvedValue({
|
||||
allowDevices: true,
|
||||
changedAt: "2026-11-09T04:00:00.000Z",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
show();
|
||||
const sw = await screen.findByRole("switch", { name: /laptop/i });
|
||||
await user.click(sw);
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0]).toMatchObject({ destructive: true });
|
||||
await waitFor(() => expect(api.remoteHub.setPolicy).toHaveBeenCalledWith(true));
|
||||
expect(api.remoteHub.setPolicy.mock.calls[0]).toHaveLength(1);
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file RemoteDevices.tsx
|
||||
* @description The hub's view of remote clients (server mode, server/hub/):
|
||||
* a user's enrolled machines with Revoke (the viewer's own password again —
|
||||
* the server checks it and writes the audit entry), and their Company-account
|
||||
* a user's enrolled machines with Revoke (asked once; the server checks
|
||||
* who may and writes the audit entry), and their Company-account
|
||||
* sessions grouped device › folder with what the gateway counted. Shown in
|
||||
* Settings for the signed-in user's own machines and on the Users page's user
|
||||
* detail for an admin/analyst (`userId`).
|
||||
@@ -16,7 +16,6 @@ import { Link } from "react-router-dom";
|
||||
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
const fmt = (n: number) =>
|
||||
@@ -44,7 +43,8 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
|
||||
const groups = useMemo(() => {
|
||||
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
|
||||
for (const s of sessions) {
|
||||
const d = s.deviceName;
|
||||
// Devices carry no machine name any more ("device"): the id tells them apart.
|
||||
const d = `${s.deviceName} #${s.deviceId}`;
|
||||
if (!byDevice.has(d)) byDevice.set(d, new Map());
|
||||
const folders = byDevice.get(d)!;
|
||||
const f = s.cwd || "—";
|
||||
@@ -64,7 +64,9 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
|
||||
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="font-medium text-fg-primary">{d.name}</span>
|
||||
<span className="font-medium text-fg-primary">
|
||||
{d.name} #{d.id}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{d.revokedAt
|
||||
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
|
||||
@@ -74,11 +76,7 @@ export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevok
|
||||
</span>
|
||||
{d.lastSeenAt && (
|
||||
<span className="text-fg-muted">
|
||||
·{" "}
|
||||
{t("companyHub.lastSeen", {
|
||||
time: formatDateTime(d.lastSeenAt),
|
||||
ip: d.lastIp || "?",
|
||||
})}
|
||||
· {t("companyHub.lastSeen", { time: formatDateTime(d.lastSeenAt) })}
|
||||
</span>
|
||||
)}
|
||||
{canRevoke && d.active && (
|
||||
@@ -178,15 +176,14 @@ function RevokeDialog({
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!password || busy) return;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.remoteHub.revoke(device.id, password);
|
||||
await api.remoteHub.revoke(device.id);
|
||||
toast.success(t("companyHub.revoked", { name: device.name }));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
@@ -203,38 +200,14 @@ function RevokeDialog({
|
||||
confirmLabel={t("companyHub.revoke")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!password}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("companyHub.yourPassword")}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file RemotePolicy.tsx
|
||||
* @description The hub admin's switch for remote clients (server mode,
|
||||
* `server/hub/remote-policy.js`): whether laptops running AgentHub local may
|
||||
* connect at all. Off by default — company sessions then run only on the
|
||||
* hub, and nothing of a user's own machine reaches the hub or Anthropic.
|
||||
* Flipping it asks once what it does (no password: only an admin reaches
|
||||
* Administration, and the server checks the role and audits the change).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
export function RemotePolicy() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.remoteHub
|
||||
.policy()
|
||||
.then((p) => setAllowed(p.allowDevices))
|
||||
.catch(() => setAllowed(null));
|
||||
}, []);
|
||||
|
||||
if (allowed === null) return null;
|
||||
|
||||
const flip = async (on: boolean) => {
|
||||
if (busy) return;
|
||||
const ok = await confirmDialog({
|
||||
title: on ? t("companyHub.policyOnTitle") : t("companyHub.policyOffTitle"),
|
||||
message: on ? t("companyHub.policyOnConfirm") : t("companyHub.policyOffConfirm"),
|
||||
confirmLabel: on ? t("companyHub.policyOnAction") : t("companyHub.policyOffAction"),
|
||||
destructive: on,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const p = await api.remoteHub.setPolicy(on);
|
||||
setAllowed(p.allowDevices);
|
||||
toast.success(
|
||||
p.allowDevices ? t("companyHub.policyOnToast") : t("companyHub.policyOffToast")
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 flex items-start gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
data-testid="remote-policy"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg-primary">{t("companyHub.policyTitle")}</div>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{allowed ? t("companyHub.policyOnHint") : t("companyHub.policyOffHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowed}
|
||||
disabled={busy}
|
||||
label={t("companyHub.policyTitle")}
|
||||
onChange={(v) => void flip(v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file BackupSettings.tsx
|
||||
* @description Settings → Data → Backup: the daily database backup of
|
||||
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
|
||||
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||
* @description Settings → Data → Backup: the scheduled database backup of
|
||||
* `server/lib/db-backup.js` — the folder (best on another disk), how often
|
||||
* (every day … every 30 days), the server's local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||
* while it runs, then toasts the outcome) and the newest backup in the folder.
|
||||
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
|
||||
* field the environment set is read-only. Restoring is manual and the card says
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { AlertTriangle, DatabaseBackup } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { BackupState } from "../../lib/api";
|
||||
@@ -23,12 +24,24 @@ import { SettingsSection } from "./SettingsRow";
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** Days between backups offered in the picker (the server takes 1–30). */
|
||||
const EVERY = [1, 2, 3, 7, 14, 30];
|
||||
const everyLabel = (t: TFunction<"settings">, n: number) =>
|
||||
n === 1
|
||||
? t("backup.everyDay")
|
||||
: n === 7
|
||||
? t("backup.everyWeek")
|
||||
: n === 14
|
||||
? t("backup.everyTwoWeeks")
|
||||
: t("backup.everyDays", { count: n });
|
||||
|
||||
export function BackupSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [state, setState] = useState<BackupState | null>(null);
|
||||
const [dir, setDir] = useState("");
|
||||
const [hour, setHour] = useState(2);
|
||||
const [keep, setKeep] = useState("7");
|
||||
const [every, setEvery] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -37,6 +50,7 @@ export function BackupSettings() {
|
||||
setDir(s.dir);
|
||||
setHour(s.hour);
|
||||
setKeep(String(s.keep));
|
||||
setEvery(s.every ?? 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,11 +80,12 @@ export function BackupSettings() {
|
||||
|
||||
const keepN = Number(keep);
|
||||
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
|
||||
const patch: { dir?: string; hour?: number; keep?: number } = {};
|
||||
const patch: { dir?: string; hour?: number; keep?: number; every?: number } = {};
|
||||
if (state) {
|
||||
if (dir.trim() !== state.dir) patch.dir = dir.trim();
|
||||
if (hour !== state.hour) patch.hour = hour;
|
||||
if (keepN !== state.keep) patch.keep = keepN;
|
||||
if (every !== (state.every ?? 1)) patch.every = every;
|
||||
}
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
|
||||
@@ -125,6 +140,19 @@ export function BackupSettings() {
|
||||
</label>
|
||||
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={String(every)}
|
||||
onChange={(v) => setEvery(Number(v))}
|
||||
label={t("backup.every")}
|
||||
disabled={!state || owned?.every}
|
||||
searchable={false}
|
||||
options={(EVERY.includes(every)
|
||||
? EVERY
|
||||
: [...EVERY, every].sort((a, b) => a - b)
|
||||
).map((n) => ({ value: String(n), label: everyLabel(t, n) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<Select
|
||||
value={String(hour)}
|
||||
@@ -192,7 +220,7 @@ export function BackupSettings() {
|
||||
)}
|
||||
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
|
||||
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
|
||||
{(owned?.dir || owned?.hour || owned?.keep) && (
|
||||
{(owned?.dir || owned?.hour || owned?.keep || owned?.every) && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("privacy.envOwned")}
|
||||
|
||||
@@ -881,3 +881,21 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every vendor's rate table, as the Pricing page shows them (Settings in
|
||||
* local mode, Administration → Server settings in server mode). */
|
||||
export function AllPricingSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PricingSettings
|
||||
vendor="claude"
|
||||
allowReset
|
||||
id="pricing-claude"
|
||||
heading={t("pricing.vendor.claude")}
|
||||
/>
|
||||
<PricingSettings vendor="openai" id="pricing-openai" heading={t("pricing.vendor.openai")} />
|
||||
<PricingSettings vendor="google" id="pricing-google" heading={t("pricing.vendor.google")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file BackupSettings.test.tsx
|
||||
* @description Settings → Data → Backup: a save sends only the fields that
|
||||
* changed, "Back up now" is off until a folder is saved, and the newest backup
|
||||
* changed (a weekly schedule is `every: 7`), "Back up now" is off until a folder is saved, and the newest backup
|
||||
* in the folder is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -22,6 +22,7 @@ const off = {
|
||||
dir: "",
|
||||
hour: 2,
|
||||
keep: 7,
|
||||
every: 1,
|
||||
running: false,
|
||||
last: null,
|
||||
files: [],
|
||||
@@ -50,6 +51,19 @@ describe("BackupSettings", () => {
|
||||
await waitFor(() => expect(run).toBeEnabled());
|
||||
});
|
||||
|
||||
it("saves a weekly schedule as every: 7", async () => {
|
||||
const user = userEvent.setup();
|
||||
backup.get.mockResolvedValue({ ...off, dir: "/mnt/hdd/b" });
|
||||
backup.save.mockImplementation((p) =>
|
||||
Promise.resolve({ ok: true, ...off, dir: "/mnt/hdd/b", ...p })
|
||||
);
|
||||
render(<BackupSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "How often" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Every week" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ every: 7 }));
|
||||
});
|
||||
|
||||
it("shows the newest backup", async () => {
|
||||
backup.get.mockResolvedValue({
|
||||
...off,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @description Settings → Company hub. Local: the enroll form sends the hub
|
||||
* address, username and password once and clears the password; enrolled, it
|
||||
* lists what was sent and never shows a token; Log out asks first. Hub
|
||||
* (server mode): the user's own machines with Revoke (their password again)
|
||||
* and who opened their sessions.
|
||||
* (server mode): the user's own machines with Revoke (one confirm, no
|
||||
* password) and who opened their sessions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() },
|
||||
remoteHub: { devices: vi.fn(), sessions: vi.fn(), revoke: vi.fn(), audit: vi.fn() },
|
||||
remoteHub: {
|
||||
devices: vi.fn(),
|
||||
sessions: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
policy: vi.fn(),
|
||||
setPolicy: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -101,7 +108,7 @@ describe("CompanyHubSettings — local", () => {
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — hub", () => {
|
||||
it("shows the user's machines, revokes with their password, and who read their sessions", async () => {
|
||||
it("shows the user's machines, revokes after a confirm, and who read their sessions", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({
|
||||
entries: [
|
||||
@@ -131,14 +138,25 @@ describe("CompanyHubSettings — hub", () => {
|
||||
});
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
|
||||
vi.mocked(api.remoteHub.policy).mockResolvedValue({ allowDevices: false, changedAt: null });
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText("mia-pc #7")).toBeInTheDocument());
|
||||
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
|
||||
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
|
||||
await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1");
|
||||
expect(screen.queryByLabelText(/your password|mật khẩu của bạn/i)).toBeNull();
|
||||
const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i });
|
||||
await user.click(buttons[buttons.length - 1]!);
|
||||
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7, "my-pass-1"));
|
||||
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("leaves the laptop switch to Administration (no switch here)", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({ entries: [] });
|
||||
vi.mocked(api.remoteHub.devices).mockResolvedValue({ devices: [] });
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
await show();
|
||||
await screen.findByTestId("remote-devices");
|
||||
expect(screen.queryByRole("switch", { name: /laptop/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* `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`; for Cursor a
|
||||
* Cursor API key — or removes one, each time with their own password again;
|
||||
* Cursor API key — or removes one (asked first; no password: the server checks the role);
|
||||
* 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`, from a `MemberPicker` filtered by role
|
||||
@@ -628,7 +628,7 @@ function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||
/**
|
||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||
* on another account of the tool moves here when ticked; unticking one sends
|
||||
* them back to the least-used account. Saved in one request, password again.
|
||||
* them back to the least-used account. Saved in one request.
|
||||
*/
|
||||
function EditAccountDialog({
|
||||
pool,
|
||||
@@ -646,7 +646,6 @@ function EditAccountDialog({
|
||||
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -658,7 +657,7 @@ function EditAccountDialog({
|
||||
}, []);
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const ready = changed;
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
@@ -667,7 +666,7 @@ function EditAccountDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen]));
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
@@ -723,16 +722,6 @@ function EditAccountDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
@@ -773,7 +762,6 @@ function AddAccountDialog({
|
||||
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -787,9 +775,9 @@ function AddAccountDialog({
|
||||
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
|
||||
const ready = fromProfile
|
||||
? profile.length > 0
|
||||
: name.trim().length > 0 && secret.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -802,7 +790,7 @@ function AddAccountDialog({
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
const r = await api.accountPool.add(body);
|
||||
onDone(r.account.name);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -914,16 +902,6 @@ function AddAccountDialog({
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
@@ -944,16 +922,15 @@ function RemoveAccountDialog({
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.remove(row.tool, row.name, current);
|
||||
await api.accountPool.remove(row.tool, row.name);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -970,7 +947,6 @@ function RemoveAccountDialog({
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
@@ -983,16 +959,6 @@ function RemoveAccountDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -8,19 +8,18 @@
|
||||
* runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* time asked once; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
@@ -135,21 +134,20 @@ function RestartDialog({
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
await api.users.tmuxRestart(one.id);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
const r = await api.users.tmuxRestartIdle();
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
@@ -176,45 +174,14 @@ function RestartDialog({
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
* there (its members come along and the emptied pool is deleted on save). A
|
||||
* column's menu moves an account — or a saved login profile, moved into the
|
||||
* pool on save — in without dragging; Pick members ticks members by role; a
|
||||
* pool is renamed in place. Nothing is sent until Save, which asks for the
|
||||
* admin's own password ONCE and applies every change: profiles moved in
|
||||
* pool is renamed in place. Every change SAVES ITSELF (a moment after the
|
||||
* last edit, so a rename is not sent per keystroke): profiles moved in
|
||||
* (`POST /api/accounts`), new pools created, each changed pool set exactly
|
||||
* (`PATCH`), merged pools deleted (`DELETE`). The trash on a saved pool
|
||||
* deletes it on its own.
|
||||
* (`PATCH`), merged pools deleted (`DELETE`). A new pool starts with a name
|
||||
* ("Pool 3") so it can be saved at once; a pool whose name was cleared waits.
|
||||
* While a save runs the board takes no drag; a failed save says why, keeps
|
||||
* what went through, and is tried again on the next change or by Retry. The
|
||||
* trash on a saved pool deletes it (asked first). No password: only an admin
|
||||
* edits, and the server checks the role.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -31,7 +35,6 @@ import {
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
@@ -76,7 +79,7 @@ type Draft = {
|
||||
userIds: number[];
|
||||
accounts: Record<PoolTool, string[]>;
|
||||
};
|
||||
/** A saved pool merged into another: deleted on Save. */
|
||||
/** A saved pool merged into another: deleted on the next save. */
|
||||
type Removed = { id: number; name: string; into: string };
|
||||
/** What is being dragged; `from` = the column key it left (null = shared). */
|
||||
type Drag =
|
||||
@@ -119,10 +122,21 @@ export function PoolGroups({
|
||||
renderAccount: (tool: PoolTool, name: string) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [draft, setDraft] = useState<Draft[]>(() => fromPool(pool));
|
||||
const [removed, setRemoved] = useState<Removed[]>([]);
|
||||
const [draft, setDraftNow] = useState<Draft[]>(() => fromPool(pool));
|
||||
const [removed, setRemovedNow] = useState<Removed[]>([]);
|
||||
// While a save runs the board is frozen: an edit made then would be thrown
|
||||
// away by the re-read that follows a good save (and a pool still being
|
||||
// created could be created twice), so every user edit goes through these.
|
||||
const savingNow = useRef(false);
|
||||
const saveRun = useRef(0);
|
||||
const setDraft: typeof setDraftNow = (v) => {
|
||||
if (!savingNow.current) setDraftNow(v);
|
||||
};
|
||||
const setRemoved: typeof setRemovedNow = (v) => {
|
||||
if (!savingNow.current) setRemovedNow(v);
|
||||
};
|
||||
const base = useRef(pool);
|
||||
// Set by Save: the next pool read replaces the board whatever it holds.
|
||||
// Set by a save: the next pool read replaces the board whatever it holds.
|
||||
const saved = useRef(false);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [profiles, setProfiles] = useState<Record<PoolTool, PoolProfile[]>>({
|
||||
@@ -134,6 +148,8 @@ export function PoolGroups({
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// A failed save, and the board it failed on: retried once the board changes.
|
||||
const [saveError, setSaveError] = useState<{ message: string; at: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
@@ -146,13 +162,18 @@ export function PoolGroups({
|
||||
saved.current || (removed.length === 0 && sigAll(draft) === sigAll(fromPool(base.current)));
|
||||
base.current = pool;
|
||||
saved.current = false;
|
||||
// The re-read after a good save: the board is the server's again, edits reopen.
|
||||
if (savingNow.current) {
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
if (untouched) {
|
||||
setDraft(fromPool(pool));
|
||||
setRemoved([]);
|
||||
setDraftNow(fromPool(pool));
|
||||
setRemovedNow([]);
|
||||
} else {
|
||||
const ids = new Set((pool.groups ?? []).map((g) => g.id));
|
||||
setDraft((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
|
||||
setRemoved((rs) => rs.filter((r) => ids.has(r.id)));
|
||||
setDraftNow((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
|
||||
setRemovedNow((rs) => rs.filter((r) => ids.has(r.id)));
|
||||
}
|
||||
}, [pool]);
|
||||
|
||||
@@ -226,11 +247,22 @@ export function PoolGroups({
|
||||
seq.current += 1;
|
||||
return `new${seq.current}`;
|
||||
};
|
||||
/** A name no pool has yet ("Pool 3"), so a new pool can be saved at once. */
|
||||
const freshName = () => {
|
||||
const taken = new Set([
|
||||
...draft.map((d) => d.name.trim()),
|
||||
...(pool.groups ?? []).map((g) => g.name),
|
||||
]);
|
||||
let n = draft.length + 1;
|
||||
while (taken.has(t("pool.board.defaultName", { n }))) n += 1;
|
||||
return t("pool.board.defaultName", { n });
|
||||
};
|
||||
const newPool = (seed?: Partial<Pick<Draft, "userIds" | "accounts">>) => {
|
||||
const key = nextKey();
|
||||
const name = freshName();
|
||||
setDraft((ds) => [
|
||||
...ds,
|
||||
{ key, id: null, name: "", userIds: [], accounts: emptyAccounts(), ...seed },
|
||||
{ key, id: null, name, userIds: [], accounts: emptyAccounts(), ...seed },
|
||||
]);
|
||||
return key;
|
||||
};
|
||||
@@ -285,7 +317,7 @@ export function PoolGroups({
|
||||
moveAccount(drag.tool, drag.name, to);
|
||||
};
|
||||
const dragProps = (drag: Drag) =>
|
||||
isAdmin
|
||||
isAdmin && !saving
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
@@ -308,7 +340,7 @@ export function PoolGroups({
|
||||
? dragging.kind === "user" && dragging.id === drag.id
|
||||
: dragging.kind === "account" && dragging.tool === drag.tool && dragging.name === drag.name);
|
||||
const dropZone = (key: string | null) =>
|
||||
isAdmin
|
||||
isAdmin && !saving
|
||||
? {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes(DND)) return;
|
||||
@@ -325,7 +357,7 @@ export function PoolGroups({
|
||||
}
|
||||
: {};
|
||||
|
||||
// ── What Save will do ─────────────────────────────────────────────────
|
||||
// ── What the next save does ───────────────────────────────────────────
|
||||
const original = new Map(fromPool(pool).map((d) => [d.key, sig(d)]));
|
||||
const changedGroups = draft.filter((d) => d.id === null || original.get(d.key) !== sig(d));
|
||||
const pendingProfiles = [
|
||||
@@ -346,10 +378,49 @@ export function PoolGroups({
|
||||
])
|
||||
) as Record<PoolTool, string[]>;
|
||||
const ungrouped = people.filter((u) => groupNameOf(u.id) === null).map((u) => u.id);
|
||||
const discard = () => {
|
||||
setDraft(fromPool(pool));
|
||||
setRemoved([]);
|
||||
|
||||
// ── Auto-save ─────────────────────────────────────────────────────────
|
||||
const boardSig = `${sigAll(draft)}#${removed.map((r) => r.id).join(",")}`;
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
savingNow.current = true;
|
||||
const run = ++saveRun.current;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const r = await applyBoard(draft, changedGroups, pendingProfiles, removed);
|
||||
if (r.error !== null) {
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
// Keep what went through, so a retry never repeats it.
|
||||
setDraftNow(r.work);
|
||||
setRemovedNow(r.gone);
|
||||
const message = errorText(t, r.error);
|
||||
setSaveError({
|
||||
message,
|
||||
at: `${sigAll(r.work)}#${r.gone.map((x) => x.id).join(",")}`,
|
||||
});
|
||||
toast.error(t("pool.board.saveFailed", { error: message }));
|
||||
return;
|
||||
}
|
||||
// Stay frozen until the re-read lands (it replaces the board); a read
|
||||
// that never comes must not freeze it for good.
|
||||
saved.current = true;
|
||||
onChanged();
|
||||
window.setTimeout(() => {
|
||||
if (saveRun.current !== run || !savingNow.current) return;
|
||||
savingNow.current = false;
|
||||
setSaving(false);
|
||||
}, 5000);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !dirty || unnamed || saving) return;
|
||||
// The board a save just failed on is not sent again by itself: Retry, or
|
||||
// the next change, does it.
|
||||
if (saveError && saveError.at === boardSig) return;
|
||||
const timer = window.setTimeout(() => void save(), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin, dirty, unnamed, saving, boardSig, saveError]);
|
||||
|
||||
const memberChip = (id: number, from: string | null) => {
|
||||
const u = byId.get(id);
|
||||
@@ -573,7 +644,10 @@ export function PoolGroups({
|
||||
{t("pool.groups.description")} {isAdmin && t("pool.board.howTo")}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
className={`mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3 ${saving ? "pointer-events-none opacity-70" : ""}`}
|
||||
aria-busy={saving}
|
||||
>
|
||||
{column(
|
||||
null,
|
||||
<div>
|
||||
@@ -599,7 +673,8 @@ export function PoolGroups({
|
||||
<input
|
||||
className="w-full rounded border border-transparent bg-transparent px-1 -mx-1 text-sm font-semibold text-fg-primary transition-colors hover:border-border focus:border-accent focus:outline-none"
|
||||
value={d.name}
|
||||
autoFocus={d.id === null && !d.name}
|
||||
readOnly={saving}
|
||||
autoFocus={d.id === null}
|
||||
onChange={(e) => patch(d.key, (x) => ({ ...x, name: e.target.value }))}
|
||||
placeholder={t("pool.groups.name")}
|
||||
aria-label={t("pool.groups.name")}
|
||||
@@ -634,6 +709,7 @@ export function PoolGroups({
|
||||
? setDeleting(existing)
|
||||
: setDraft((ds) => ds.filter((x) => x.key !== d.key))
|
||||
}
|
||||
disabled={saving}
|
||||
title={t("pool.groups.delete", { name })}
|
||||
aria-label={t("pool.groups.delete", { name })}
|
||||
className="btn-icon hover:text-status-danger"
|
||||
@@ -675,30 +751,28 @@ export function PoolGroups({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && dirty && (
|
||||
{isAdmin && (saving || saveError || unnamed) && (
|
||||
<div
|
||||
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
|
||||
data-testid="pool-board-save"
|
||||
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
|
||||
data-testid="pool-board-status"
|
||||
role="status"
|
||||
>
|
||||
<span className="flex-1 text-xs text-fg-secondary">
|
||||
{t("pool.board.pending", {
|
||||
count: changedGroups.length + pendingProfiles.length + removed.length,
|
||||
})}
|
||||
{unnamed && (
|
||||
<span className="ml-2 text-status-warning">{t("pool.board.needsName")}</span>
|
||||
{saving ? (
|
||||
t("pool.board.saving")
|
||||
) : saveError ? (
|
||||
<span className="text-status-danger">
|
||||
{t("pool.board.saveFailed", { error: saveError.message })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-status-warning">{t("pool.board.needsName")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button type="button" onClick={discard} className="btn-ghost text-xs">
|
||||
{t("pool.board.discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={unnamed}
|
||||
onClick={() => setSaving(true)}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("pool.save")}
|
||||
</button>
|
||||
{saveError && !saving && !unnamed && (
|
||||
<button type="button" onClick={() => void save()} className="btn-primary text-xs">
|
||||
{t("pool.board.retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -722,26 +796,6 @@ export function PoolGroups({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{saving && (
|
||||
<SaveBoardDialog
|
||||
draft={draft}
|
||||
changed={changedGroups}
|
||||
profiles={pendingProfiles}
|
||||
removed={removed}
|
||||
profileLabel={profileLabel}
|
||||
onClose={() => setSaving(false)}
|
||||
onProgress={(d, r) => {
|
||||
setDraft(d);
|
||||
setRemoved(r);
|
||||
}}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.board.saved"));
|
||||
setSaving(false);
|
||||
saved.current = true;
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteGroupDialog
|
||||
group={deleting}
|
||||
@@ -807,157 +861,64 @@ function PickMembersDialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* Save: the admin's password once, then every staged change in order —
|
||||
* profiles moved into the pool, new pools created, changed pools set, merged
|
||||
* pools deleted. What already went through is written back into the board, so
|
||||
* a retry after a failure never moves a profile in twice, makes a pool again
|
||||
* or deletes one twice.
|
||||
* One save of the board, in order — profiles moved into the pool, new pools
|
||||
* created, changed pools set, merged pools deleted. Returns the board as far
|
||||
* as it went (`work`, `gone`) and the error that stopped it (null = all
|
||||
* done), so a retry never moves a profile in twice, makes a pool again or
|
||||
* deletes one twice.
|
||||
*/
|
||||
function SaveBoardDialog({
|
||||
draft,
|
||||
changed,
|
||||
profiles,
|
||||
removed,
|
||||
profileLabel,
|
||||
onClose,
|
||||
onProgress,
|
||||
onDone,
|
||||
}: {
|
||||
draft: Draft[];
|
||||
changed: Draft[];
|
||||
async function applyBoard(
|
||||
draft: Draft[],
|
||||
changed: Draft[],
|
||||
/** `tool|profile:name` entries to move into the pool first. */
|
||||
profiles: string[];
|
||||
profiles: string[],
|
||||
/** Saved pools merged into another, deleted last. */
|
||||
removed: Removed[];
|
||||
profileLabel: (tool: PoolTool, name: string) => string;
|
||||
onClose: () => void;
|
||||
onProgress: (d: Draft[], removed: Removed[]) => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let work = draft.map((d) => ({
|
||||
...d,
|
||||
userIds: [...d.userIds],
|
||||
accounts: { ...d.accounts },
|
||||
}));
|
||||
const todo = new Set(changed.map((d) => d.key));
|
||||
let gone = [...removed];
|
||||
try {
|
||||
for (const entry of profiles) {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
const profile = ref.slice(PROFILE.length);
|
||||
const r = await api.accountPool.add({ tool, name: profile, profile }, current);
|
||||
work = work.map((d) => ({
|
||||
...d,
|
||||
accounts: {
|
||||
...d.accounts,
|
||||
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
for (const d of work) {
|
||||
if (!todo.has(d.key)) continue;
|
||||
let id = d.id;
|
||||
if (id === null) {
|
||||
id = (await api.accountPool.createGroup(d.name.trim(), current)).group.id;
|
||||
const made = id;
|
||||
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
|
||||
}
|
||||
await api.accountPool.updateGroup(
|
||||
id,
|
||||
{
|
||||
...(d.id !== null ? { name: d.name.trim() } : {}),
|
||||
userIds: d.userIds,
|
||||
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
|
||||
},
|
||||
current
|
||||
);
|
||||
todo.delete(d.key);
|
||||
}
|
||||
// Last: the merged pools' members already moved into their new pool.
|
||||
for (const r of removed) {
|
||||
await api.accountPool.deleteGroup(r.id, current);
|
||||
gone = gone.filter((x) => x.id !== r.id);
|
||||
}
|
||||
onDone();
|
||||
} catch (e) {
|
||||
onProgress(work, gone);
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
removed: Removed[]
|
||||
): Promise<{ work: Draft[]; gone: Removed[]; error: unknown }> {
|
||||
let work = draft.map((d) => ({
|
||||
...d,
|
||||
userIds: [...d.userIds],
|
||||
accounts: { ...d.accounts },
|
||||
}));
|
||||
const todo = new Set(changed.map((d) => d.key));
|
||||
let gone = [...removed];
|
||||
try {
|
||||
for (const entry of profiles) {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
const profile = ref.slice(PROFILE.length);
|
||||
const r = await api.accountPool.add({ tool, name: profile, profile });
|
||||
work = work.map((d) => ({
|
||||
...d,
|
||||
accounts: {
|
||||
...d.accounts,
|
||||
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.board.saveTitle")}
|
||||
message={t("pool.board.saveHint")}
|
||||
confirmLabel={t("pool.save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<ul className="mt-3 space-y-1 text-xs text-fg-secondary" data-testid="pool-board-summary">
|
||||
{profiles.map((entry) => {
|
||||
const [tool, ref] = entry.split("|") as [PoolTool, string];
|
||||
return (
|
||||
<li key={entry}>
|
||||
{t("pool.board.summaryProfile", {
|
||||
name: profileLabel(tool, ref.slice(PROFILE.length)),
|
||||
})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{removed.map((r) => (
|
||||
<li key={`m${r.id}`}>{t("pool.board.summaryMerge", { from: r.name, into: r.into })}</li>
|
||||
))}
|
||||
{changed.map((d) => (
|
||||
<li key={d.key}>
|
||||
{t(d.id === null ? "pool.board.summaryNew" : "pool.board.summaryGroup", {
|
||||
name: d.name.trim(),
|
||||
count: d.userIds.length,
|
||||
accounts: TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
for (const d of work) {
|
||||
if (!todo.has(d.key)) continue;
|
||||
let id = d.id;
|
||||
if (id === null) {
|
||||
id = (await api.accountPool.createGroup(d.name.trim())).group.id;
|
||||
const made = id;
|
||||
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
|
||||
}
|
||||
await api.accountPool.updateGroup(id, {
|
||||
...(d.id !== null ? { name: d.name.trim() } : {}),
|
||||
userIds: d.userIds,
|
||||
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
|
||||
});
|
||||
todo.delete(d.key);
|
||||
}
|
||||
// Last: the merged pools' members already moved into their new pool.
|
||||
for (const r of removed) {
|
||||
await api.accountPool.deleteGroup(r.id);
|
||||
gone = gone.filter((x) => x.id !== r.id);
|
||||
}
|
||||
return { work, gone, error: null };
|
||||
} catch (e) {
|
||||
return { work, gone, error: e };
|
||||
}
|
||||
}
|
||||
|
||||
function DeleteGroupDialog({
|
||||
@@ -970,15 +931,14 @@ function DeleteGroupDialog({
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.deleteGroup(group.id, current);
|
||||
await api.accountPool.deleteGroup(group.id);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
@@ -994,27 +954,9 @@ function DeleteGroupDialog({
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), 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`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
* what changed — the server validates the whole change before it applies any of it. A
|
||||
* refusal is shown inside the dialog, which stays open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -109,7 +108,6 @@ export function UserEditDialog({
|
||||
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
|
||||
);
|
||||
const quotaLabel = useQuotaLabel();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -141,7 +139,8 @@ export function UserEditDialog({
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount))
|
||||
patch.hostAccount = hostAccount;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
@@ -172,14 +171,14 @@ export function UserEditDialog({
|
||||
// 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 && !quotaBad && !busy;
|
||||
const ready = changed && !weakOwn && !quotaBad && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.update(user.id, patch, current);
|
||||
await api.users.update(user.id, patch);
|
||||
onDone(t("done.updated", { name: user.displayName || user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
@@ -196,10 +195,6 @@ export function UserEditDialog({
|
||||
/** Delete the user for good — asked first, with what goes, in red. */
|
||||
const remove = async () => {
|
||||
if (self || busy) return;
|
||||
if (!current) {
|
||||
setError(t("delete.needsPassword"));
|
||||
return;
|
||||
}
|
||||
const name = user.displayName || user.username;
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete.title", { name }),
|
||||
@@ -211,7 +206,7 @@ export function UserEditDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.remove(user.id, current);
|
||||
await api.users.remove(user.id);
|
||||
onDone(t("done.deleted", { name }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
@@ -593,17 +588,6 @@ export function UserEditDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* line with its bar — red once used up, with when it opens again;
|
||||
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
|
||||
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
|
||||
* without one of their own gets — an admin edits it with their own password
|
||||
* without one of their own gets — an admin edits it
|
||||
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
|
||||
* setting is part of the Edit user form (`UserEditDialog`). A user over their
|
||||
* quota can start no session and their prompts are refused until it resets.
|
||||
@@ -120,7 +120,7 @@ export function parseLimit(text: string): number | null {
|
||||
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/** The default quota: shown to staff, edited by an admin with their own password. */
|
||||
/** The default quota: shown to staff, edited by an admin. */
|
||||
export function QuotaDefaultCard({
|
||||
quotaDefault,
|
||||
isAdmin,
|
||||
@@ -182,11 +182,10 @@ function QuotaDefaultDialog({
|
||||
const [on, setOn] = useState(current !== null);
|
||||
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
|
||||
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const value = parseLimit(limit);
|
||||
const ready = password.length > 0 && (!on || value !== null) && !busy;
|
||||
const ready = (!on || value !== null) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -196,8 +195,7 @@ function QuotaDefaultDialog({
|
||||
await api.users.setQuotaDefault(
|
||||
on && value !== null
|
||||
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
|
||||
: null,
|
||||
password
|
||||
: null
|
||||
);
|
||||
onDone(t("quota.defaultSaved"));
|
||||
} catch (e) {
|
||||
@@ -246,18 +244,6 @@ function QuotaDefaultDialog({
|
||||
disabled={!on}
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||
* other installed tool; an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* admin adds an account with no password of their own asked (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* The board moves members and accounts between pools (drag, or a column's
|
||||
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||
* splits an account off onto New pool, merges a pool by dragging its only
|
||||
* included), or a setup-token — and removes one after a confirm, and an
|
||||
* analyst sees no add or remove control. An account's Edit ticks the users
|
||||
* who run on it; the users show on the account. The board moves members and
|
||||
* accounts between pools (drag, or a column's menu) — an account sits in one
|
||||
* pool, so a move takes it out of the other — splits an account off onto New
|
||||
* pool (named "Pool <n>" until renamed), merges a pool by dragging its only
|
||||
* account into another, adds a saved profile to a pool, shows each account's
|
||||
* plan, and saves every change with ONE password.
|
||||
* plan, and saves every change by itself a moment after the last edit — a
|
||||
* failed save says why and is sent again only by Retry, never twice over;
|
||||
* the board is frozen while a save runs, so no edit made then is lost.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -156,6 +158,16 @@ function drag(from: Element, to: Element) {
|
||||
fireEvent.drop(to, { dataTransfer });
|
||||
}
|
||||
|
||||
/**
|
||||
* The pool the server answers with, a fresh object on every read — as a real
|
||||
* re-read after a save is — so the board takes it as new.
|
||||
*/
|
||||
const serve = (pool: AccountPool) =>
|
||||
accountPool.list.mockImplementation(async () => structuredClone(pool));
|
||||
|
||||
/** The board saves itself ~600 ms after the last edit. */
|
||||
const SAVED = { timeout: 3000 };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
@@ -242,21 +254,23 @@ describe("AccountPoolCard", () => {
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({
|
||||
tool: "cursor",
|
||||
name: "cu-1",
|
||||
token: "key_abc",
|
||||
})
|
||||
);
|
||||
expect(accountPool.add.mock.calls[0]).toHaveLength(1);
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
it("builds pools on the board: members, accounts and profiles moved in, saved by itself", async () => {
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -276,45 +290,45 @@ describe("AccountPoolCard", () => {
|
||||
expect(teamA).toHaveTextContent("leo");
|
||||
expect(teamA).toHaveTextContent("team-1");
|
||||
await screen.findByText("mia");
|
||||
expect(screen.queryByTestId("pool-board-save")).toBeNull(); // no Save any more
|
||||
|
||||
// The edits land at once (fireEvent), well inside the save's 600 ms wait.
|
||||
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||
const names = screen.getAllByLabelText("Pool name");
|
||||
await user.type(names[names.length - 1]!, "Team B");
|
||||
const names = screen.getAllByLabelText("Pool name") as HTMLInputElement[];
|
||||
const name = names[names.length - 1]!;
|
||||
expect(name.value).toBe("Pool 2"); // a new pool starts with a name
|
||||
fireEvent.change(name, { target: { value: "Team B" } });
|
||||
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||
expect(teamB).toHaveTextContent("mia");
|
||||
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||
await user.selectOptions(add, "codex|cx-1");
|
||||
await user.selectOptions(add, "claude|profile:work");
|
||||
fireEvent.change(add, { target: { value: "codex|cx-1" } });
|
||||
fireEvent.change(add, { target: { value: "claude|profile:work" } });
|
||||
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled(); // waits for the last edit
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(2, {
|
||||
userIds: [7],
|
||||
accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] },
|
||||
}),
|
||||
SAVED
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
expect(accountPool.add).toHaveBeenCalledWith({ tool: "claude", name: "work", profile: "work" });
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B");
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
// Team A did not change: nothing is sent for it.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
// The profile moved in first, then the pool was made, then filled.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.add)).toBeLessThan(order(accountPool.createGroup));
|
||||
expect(order(accountPool.createGroup)).toBeLessThan(order(accountPool.updateGroup));
|
||||
});
|
||||
|
||||
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -326,7 +340,6 @@ describe("AccountPoolCard", () => {
|
||||
],
|
||||
});
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
@@ -341,27 +354,88 @@ describe("AccountPoolCard", () => {
|
||||
expect(held(teamA)).toEqual(["team-2"]);
|
||||
expect(held(shared)).toContain("team-1");
|
||||
expect(teamA).toHaveTextContent("team-2");
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||
);
|
||||
expect(shared).toHaveTextContent("leo");
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
|
||||
name: "Team A",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
}),
|
||||
SAVED
|
||||
);
|
||||
// The three edits went out as ONE save.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.createGroup).not.toHaveBeenCalled();
|
||||
expect(accountPool.deleteGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says why a save failed, keeps what went through, and sends again only on Retry", async () => {
|
||||
serve(POOL);
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 4, name: "Pool 1" } });
|
||||
accountPool.updateGroup
|
||||
.mockRejectedValueOnce(new Error("disk full"))
|
||||
.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
drag(
|
||||
within(screen.getByTestId("pool-shared"))
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes("team-2"))!,
|
||||
screen.getByTestId("pool-new")
|
||||
);
|
||||
const status = await screen.findByText(/Not saved: disk full/, undefined, SAVED);
|
||||
expect(status.closest("[data-testid=pool-board-status]")).toBeInTheDocument();
|
||||
// The failed board is not re-sent by itself.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(2), SAVED);
|
||||
// The pool was made once: the retry fills the one already created.
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Pool 1");
|
||||
expect(accountPool.updateGroup).toHaveBeenLastCalledWith(4, {
|
||||
name: "Pool 1",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the board while a save runs, so no edit made then is lost", async () => {
|
||||
serve(POOL);
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
accountPool.createGroup.mockImplementation(
|
||||
() => new Promise((r) => (finish = () => r({ group: { id: 4, name: "Pool 1" } })))
|
||||
);
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
drag(
|
||||
within(screen.getByTestId("pool-shared"))
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes("team-2"))!,
|
||||
screen.getByTestId("pool-new")
|
||||
);
|
||||
await waitFor(() => expect(accountPool.createGroup).toHaveBeenCalledTimes(1), SAVED);
|
||||
const name = screen.getByDisplayValue("Pool 1");
|
||||
expect(screen.getByTestId("pool-shared").closest("[aria-busy]")).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true"
|
||||
);
|
||||
// A rename typed mid-save is refused, not queued behind the save.
|
||||
fireEvent.change(name, { target: { value: "Renamed" } });
|
||||
expect(name).toHaveValue("Pool 1");
|
||||
finish(null);
|
||||
await waitFor(() => expect(accountPool.updateGroup).toHaveBeenCalledTimes(1), SAVED);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(4, {
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows each account's plan on its card", async () => {
|
||||
@@ -376,7 +450,7 @@ describe("AccountPoolCard", () => {
|
||||
});
|
||||
|
||||
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
serve({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
@@ -396,7 +470,6 @@ describe("AccountPoolCard", () => {
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||
await screen.findByText("mia");
|
||||
@@ -409,40 +482,32 @@ describe("AccountPoolCard", () => {
|
||||
const pools = screen.getAllByTestId("pool-group");
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||
const name = within(pools[2]!).getByLabelText("Pool name") as HTMLInputElement;
|
||||
expect(name.value).toBe("Pool 3");
|
||||
fireEvent.change(name, { target: { value: "Solo" } });
|
||||
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||
drag(item(teamB!, "cx-1"), teamA!);
|
||||
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||
expect(teamA).toHaveTextContent("mia");
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||
"boss-pass"
|
||||
);
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2), SAVED);
|
||||
expect(accountPool.deleteGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(1, {
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
});
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo");
|
||||
expect(accountPool.createGroup).toHaveBeenCalledTimes(1);
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(3, {
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
});
|
||||
// The members moved before the emptied pool was deleted.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||
const last = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
Math.max(...fn.mock.invocationCallOrder);
|
||||
expect(last(accountPool.updateGroup)).toBeLessThan(last(accountPool.deleteGroup));
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
@@ -470,18 +535,18 @@ describe("AccountPoolCard", () => {
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({
|
||||
tool: "claude",
|
||||
name: "work",
|
||||
profile: "work",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
it("adds a setup-token once a name and token are typed, with no password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
@@ -489,37 +554,35 @@ describe("AccountPoolCard", () => {
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
expect(confirm).toBeDisabled(); // no token yet
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({
|
||||
tool: "claude",
|
||||
name: "team-2",
|
||||
token: "sk-ant-oat01-xyz",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
it("removes an account after a confirm, with no password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
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" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
expect(accountPool.remove).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1"));
|
||||
});
|
||||
|
||||
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||
it("sets who runs on an account from its Edit, with no password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: {
|
||||
@@ -550,12 +613,9 @@ describe("AccountPoolCard", () => {
|
||||
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save"
|
||||
)!;
|
||||
expect(save).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save).toBeEnabled();
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
await waitFor(() => expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7]));
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||
@@ -574,16 +634,12 @@ describe("AccountPoolCard", () => {
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("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"
|
||||
)
|
||||
expect(accountPool.add).toHaveBeenCalledWith({ tool: "codex", name: "cx", profile: "cx" })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* user per account, names the installed tmux and warns until tmux-member is
|
||||
* installed; a row's cell shows the version with a badge when it is older than
|
||||
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||
* one member — or every idle outdated one, the working member left out — only
|
||||
* with their own password typed; an analyst gets no "Restart idle".
|
||||
* one member — or every idle outdated one, the working member left out — after
|
||||
* one confirm (no password); an analyst gets no "Restart idle".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -112,16 +112,15 @@ describe("MemberTmux", () => {
|
||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts one member only with the admin's password", async () => {
|
||||
it("restarts one member after a confirm, with no password", async () => {
|
||||
users.tmuxRestart.mockResolvedValue({});
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||
const go = screen.getByRole("button", { name: "Restart" });
|
||||
expect(go).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
await userEvent.click(go);
|
||||
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
expect(users.tmuxRestart).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||
@@ -129,8 +128,7 @@ describe("MemberTmux", () => {
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
|
||||
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @file UserEditDialog.test.tsx
|
||||
* @description The admin's account editor: Save stays off until something
|
||||
* changed AND the admin typed their own password; one PATCH carries only the
|
||||
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
||||
* admin editing their own row cannot touch role or state; Generate fills a
|
||||
* 16-character password.
|
||||
* changed (no password of the admin's own is asked); one PATCH carries only
|
||||
* the changed fields (profile, role, disable, workspace add/remove, sign-out);
|
||||
* an admin editing their own row cannot touch role or state; Delete asks a red
|
||||
* confirm first; Generate fills a 16-character password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -72,12 +72,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("UserEditDialog", () => {
|
||||
it("needs a change and the admin's own password before Save", async () => {
|
||||
it("needs a change before Save, and asks for no password", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
expect(save().hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
||||
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
@@ -94,22 +93,18 @@ describe("UserEditDialog", () => {
|
||||
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
||||
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(save());
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
2,
|
||||
{
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
expect(users.update).toHaveBeenCalledWith(2, {
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
})
|
||||
);
|
||||
expect(users.update.mock.calls[0]).toHaveLength(2);
|
||||
expect(onDone).toHaveBeenCalledWith("mia updated");
|
||||
});
|
||||
|
||||
@@ -125,21 +120,15 @@ describe("UserEditDialog", () => {
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("New password"), "abc");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
|
||||
it("deletes a user after a red confirm that says what goes, with no password", async () => {
|
||||
users.remove.mockReset().mockResolvedValue({ user: mia });
|
||||
confirmDialog.mockReset().mockResolvedValue(true);
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
// No password yet: asked for it, nothing sent.
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -147,18 +136,18 @@ describe("UserEditDialog", () => {
|
||||
message: expect.stringContaining("/srv/agenthub/users/mia"),
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
|
||||
it("sends nothing when the delete confirm is cancelled", async () => {
|
||||
users.remove.mockReset();
|
||||
confirmDialog.mockReset().mockResolvedValue(false);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file UserQuota.test.tsx
|
||||
* @description Spend quotas on the Users page: a row's "spent of limit" line
|
||||
* (red once used up, "(default)" when it is the default), the default quota
|
||||
* card (an admin edits it with their own password, an analyst only reads),
|
||||
* card (an admin edits it with no password asked, an analyst only reads),
|
||||
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ describe("QuotaSummary", () => {
|
||||
});
|
||||
|
||||
describe("QuotaDefaultCard", () => {
|
||||
it("an admin sets the default with their own password", async () => {
|
||||
it("an admin sets the default, with no password asked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
|
||||
@@ -77,14 +77,13 @@ describe("QuotaDefaultCard", () => {
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "25");
|
||||
await user.selectOptions(screen.getByLabelText("Period"), "5h");
|
||||
const save = screen.getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // no password yet
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
await user.click(save);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(users.setQuotaDefault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" }),
|
||||
"pw"
|
||||
expect.objectContaining({ limit_usd: 25, period: "5h" })
|
||||
);
|
||||
expect(users.setQuotaDefault.mock.calls[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an analyst only reads it", () => {
|
||||
@@ -131,14 +130,11 @@ describe("UserEditDialog quota", () => {
|
||||
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
|
||||
await user.selectOptions(select, "custom");
|
||||
await user.type(screen.getByLabelText("Limit in dollars"), "40");
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
|
||||
"pw"
|
||||
);
|
||||
expect(users.update).toHaveBeenCalledWith(7, {
|
||||
quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("No limit is a change; an empty custom limit keeps Save off", async () => {
|
||||
@@ -146,13 +142,12 @@ describe("UserEditDialog quota", () => {
|
||||
render(
|
||||
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
|
||||
);
|
||||
await user.type(screen.getByLabelText("Your password"), "pw");
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
|
||||
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on."
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server settings"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,8 @@
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
|
||||
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead.",
|
||||
"movedToAdmin": "Settings that apply to the whole server (laptop connections, tests on this server, git logins, reports, model pricing, data…) are in Administration → Server settings."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search settings",
|
||||
@@ -1271,12 +1272,18 @@
|
||||
"compactFailed": "Compact failed: {{error}}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Daily backup",
|
||||
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
|
||||
"title": "Database backup",
|
||||
"description": "A full copy of the database on a schedule — every day, every week… — into a folder you choose; best on another disk, so a failed drive does not take the backups with it.",
|
||||
"folder": "Backup folder",
|
||||
"folderHint": "An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.",
|
||||
"hour": "Time (server)",
|
||||
"keep": "Backups kept",
|
||||
"every": "How often",
|
||||
"everyDay": "Every day",
|
||||
"everyWeek": "Every week",
|
||||
"everyTwoWeeks": "Every 2 weeks",
|
||||
"everyDays_one": "Every {{count}} day",
|
||||
"everyDays_other": "Every {{count}} days",
|
||||
"runNow": "Back up now",
|
||||
"running": "Backing up…",
|
||||
"done": "Backup written: {{file}}",
|
||||
@@ -1453,7 +1460,7 @@
|
||||
"companyHub": {
|
||||
"title": "Company hub",
|
||||
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
|
||||
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first. Your folder paths, account name and machine name are replaced with ~ and device before sending.",
|
||||
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
|
||||
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
|
||||
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
|
||||
@@ -1481,17 +1488,29 @@
|
||||
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
|
||||
"noDevices": "No machine enrolled.",
|
||||
"revokedAt": "revoked {{time}}",
|
||||
"lastSeen": "seen {{time}} from {{ip}}",
|
||||
"lastSeen": "seen {{time}}",
|
||||
"revoke": "Revoke",
|
||||
"revokeTitle": "Revoke {{name}}?",
|
||||
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
|
||||
"revoked": "{{name}} revoked",
|
||||
"yourPassword": "Your password",
|
||||
"sessions": "Company-account sessions",
|
||||
"unmonitored": "not started from AgentHub",
|
||||
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
|
||||
"purged": "conversation removed (retention)",
|
||||
"whoRead": "Who opened your sessions",
|
||||
"nobodyRead": "Nobody has opened your sessions."
|
||||
"nobodyRead": "Nobody has opened your sessions.",
|
||||
"policyTitle": "Allow laptop connections",
|
||||
"policyOffHint": "Off: AgentHub is used on the server only. Laptops cannot enroll, and enrolled laptops can neither use company accounts nor send anything here.",
|
||||
"policyOnHint": "On: members may run agents on their laptops with company accounts through this hub. That laptop's OS, the paths in its prompts and its device id still reach Anthropic.",
|
||||
"policyOnTitle": "Turn laptop connections on?",
|
||||
"policyOffTitle": "Turn laptop connections off?",
|
||||
"policyOnConfirm": "Members will be able to use company accounts from their own laptops. Prompts from a laptop carry that laptop's details (OS, working folder).",
|
||||
"policyOffConfirm": "Every enrolled laptop stops using company accounts at once; sessions running on a laptop will fail. Their own accounts keep working.",
|
||||
"policyOnAction": "Turn on",
|
||||
"policyOffAction": "Turn off",
|
||||
"policyOnToast": "Laptop connections turned on",
|
||||
"policyOffToast": "Laptop connections turned off",
|
||||
"adminTitle": "Company hub",
|
||||
"adminDescription": "Whether laptops running AgentHub local may connect to this server, and every machine people enrolled."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,6 @@
|
||||
"noOwner": "no owner",
|
||||
"ownedBy": "owned by {{name}}",
|
||||
"noWorkspaces": "There are no workspaces yet.",
|
||||
"yourPassword": "Your password",
|
||||
"yourPasswordHint": "Type your own password to confirm this change.",
|
||||
"save": "Save",
|
||||
"dialog": {
|
||||
"add": "Add a user",
|
||||
@@ -293,7 +291,7 @@
|
||||
"noneShown": "No member matches."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Every change saves itself.",
|
||||
"sharedHint": "Members and accounts in no pool",
|
||||
"toShared": "Send {{name}} back to the shared pool",
|
||||
"removeAccount": "Send {{name}} back to the shared pool",
|
||||
@@ -303,33 +301,27 @@
|
||||
"noSharedAccounts": "No account is left for the shared pool.",
|
||||
"dropMembers": "Drop members here",
|
||||
"untitled": "New pool",
|
||||
"new": "not saved yet",
|
||||
"new": "saving",
|
||||
"pick": "Pick members of {{name}}",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Saved when you Apply.",
|
||||
"apply": "Apply",
|
||||
"addAccount": "Move an account into {{name}}",
|
||||
"addAccountOption": "+ Move an account or saved profile here…",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||
"pending_one": "{{count}} unsaved change",
|
||||
"pending_other": "{{count}} unsaved changes",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool at once",
|
||||
"profilePending": "A saved profile: being moved into the pool",
|
||||
"needsName": "Every pool needs a name.",
|
||||
"discard": "Discard",
|
||||
"saveTitle": "Save the pools",
|
||||
"saveHint": "Type your own password once to apply every change below.",
|
||||
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"saved": "Pools saved",
|
||||
"dropAccounts": "Drag accounts here",
|
||||
"noAccounts": "No accounts",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||
"merged": "{{from}} merged into {{into}}",
|
||||
"newDrop": "Drop here to split it into a pool of its own",
|
||||
"newHint": "Or drag an account or a member here to split it off"
|
||||
"newHint": "Or drag an account or a member here to split it off",
|
||||
"defaultName": "Pool {{n}}",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "Not saved: {{error}}",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"addAccount": "Add account",
|
||||
"otherTools": "Other AI tools"
|
||||
@@ -338,8 +330,7 @@
|
||||
"button": "Delete user",
|
||||
"title": "Delete {{name}}?",
|
||||
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
||||
"confirm": "Delete for good",
|
||||
"needsPassword": "Enter your own password below first."
|
||||
"confirm": "Delete for good"
|
||||
},
|
||||
"tmux": {
|
||||
"title": "Members' tmux",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Cài đặt máy chủ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,7 +1101,8 @@
|
||||
}
|
||||
},
|
||||
"backPrevious": "Quay lại",
|
||||
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện."
|
||||
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện.",
|
||||
"movedToAdmin": "Các cài đặt áp dụng cho cả máy chủ (kết nối laptop, test trên máy chủ, đăng nhập git, báo cáo, giá model, dữ liệu…) nằm ở Quản trị → Cài đặt máy chủ."
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Tìm cài đặt",
|
||||
@@ -1259,12 +1260,18 @@
|
||||
"compactFailed": "Nén thất bại: {{error}}"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup hằng ngày",
|
||||
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
|
||||
"title": "Sao lưu database",
|
||||
"description": "Bản sao đầy đủ của database theo lịch — mỗi ngày, mỗi tuần… — vào thư mục bạn chọn; nên đặt ở ổ khác để ổ hỏng không kéo theo bản sao lưu.",
|
||||
"folder": "Thư mục backup",
|
||||
"folderHint": "Đường dẫn tuyệt đối tới một thư mục có sẵn mà server ghi được. Để trống để tắt backup. AgentHub chỉ xóa đúng các tệp agenthub-YYYYMMDD-HHMMSS.db của chính nó ở đây, bản cũ nhất trước khi vượt số bản giữ lại.",
|
||||
"hour": "Giờ chạy (giờ server)",
|
||||
"keep": "Số bản giữ lại",
|
||||
"every": "Bao lâu một lần",
|
||||
"everyDay": "Mỗi ngày",
|
||||
"everyWeek": "Mỗi tuần",
|
||||
"everyTwoWeeks": "2 tuần một lần",
|
||||
"everyDays_one": "{{count}} ngày một lần",
|
||||
"everyDays_other": "{{count}} ngày một lần",
|
||||
"runNow": "Backup ngay",
|
||||
"running": "Đang backup…",
|
||||
"done": "Đã ghi bản backup: {{file}}",
|
||||
@@ -1441,7 +1448,7 @@
|
||||
"companyHub": {
|
||||
"title": "Hub công ty",
|
||||
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
|
||||
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
|
||||
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi. Đường dẫn thư mục, tên tài khoản và tên máy của bạn được thay bằng ~ và device trước khi gửi.",
|
||||
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
|
||||
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
|
||||
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
|
||||
@@ -1469,17 +1476,29 @@
|
||||
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
|
||||
"noDevices": "Chưa có máy nào đăng ký.",
|
||||
"revokedAt": "đã thu hồi {{time}}",
|
||||
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
|
||||
"lastSeen": "thấy lần cuối {{time}}",
|
||||
"revoke": "Thu hồi",
|
||||
"revokeTitle": "Thu hồi {{name}}?",
|
||||
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
|
||||
"revoked": "Đã thu hồi {{name}}",
|
||||
"yourPassword": "Mật khẩu của bạn",
|
||||
"sessions": "Session tài khoản công ty",
|
||||
"unmonitored": "không mở từ AgentHub",
|
||||
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
|
||||
"purged": "đã xoá hội thoại (quá hạn lưu)",
|
||||
"whoRead": "Ai đã xem session của bạn",
|
||||
"nobodyRead": "Chưa ai xem session của bạn."
|
||||
"nobodyRead": "Chưa ai xem session của bạn.",
|
||||
"policyTitle": "Cho phép kết nối laptop",
|
||||
"policyOffHint": "Đang tắt: chỉ dùng AgentHub trên máy chủ. Laptop không đăng ký được, các laptop đã đăng ký không dùng được tài khoản công ty và không gửi gì lên đây.",
|
||||
"policyOnHint": "Đang bật: member có thể chạy agent trên laptop bằng tài khoản công ty qua hub này. Hệ điều hành, đường dẫn trong prompt và mã thiết bị của laptop đó vẫn đi tới Anthropic.",
|
||||
"policyOnTitle": "Bật kết nối laptop?",
|
||||
"policyOffTitle": "Tắt kết nối laptop?",
|
||||
"policyOnConfirm": "Member sẽ dùng được tài khoản công ty từ laptop của họ. Prompt từ laptop mang thông tin của chính laptop đó (hệ điều hành, thư mục làm việc).",
|
||||
"policyOffConfirm": "Mọi laptop đã đăng ký ngừng dùng được tài khoản công ty ngay; session đang chạy trên laptop sẽ báo lỗi. Tài khoản riêng của họ vẫn chạy bình thường.",
|
||||
"policyOnAction": "Bật",
|
||||
"policyOffAction": "Tắt",
|
||||
"policyOnToast": "Đã bật kết nối laptop",
|
||||
"policyOffToast": "Đã tắt kết nối laptop",
|
||||
"adminTitle": "Hub công ty",
|
||||
"adminDescription": "Cho phép hay không cho laptop chạy AgentHub local kết nối vào máy chủ này, và các máy mọi người đã đăng ký."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,6 @@
|
||||
"noOwner": "chưa có chủ",
|
||||
"ownedBy": "của {{name}}",
|
||||
"noWorkspaces": "Chưa có workspace nào.",
|
||||
"yourPassword": "Mật khẩu của bạn",
|
||||
"yourPasswordHint": "Nhập mật khẩu của chính bạn để xác nhận thay đổi này.",
|
||||
"save": "Lưu",
|
||||
"dialog": {
|
||||
"add": "Thêm người dùng",
|
||||
@@ -293,7 +291,7 @@
|
||||
"noneShown": "Không có thành viên nào khớp."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
|
||||
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Mọi thay đổi tự lưu ngay.",
|
||||
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
|
||||
"toShared": "Đưa {{name}} về pool chung",
|
||||
"removeAccount": "Đưa {{name}} về pool chung",
|
||||
@@ -303,33 +301,27 @@
|
||||
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
|
||||
"dropMembers": "Thả thành viên vào đây",
|
||||
"untitled": "Pool mới",
|
||||
"new": "chưa lưu",
|
||||
"new": "đang lưu",
|
||||
"pick": "Chọn thành viên cho {{name}}",
|
||||
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
|
||||
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Tự lưu khi bấm Áp dụng.",
|
||||
"apply": "Áp dụng",
|
||||
"addAccount": "Chuyển tài khoản vào {{name}}",
|
||||
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
|
||||
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
|
||||
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
|
||||
"pending_one": "{{count}} thay đổi chưa lưu",
|
||||
"pending_other": "{{count}} thay đổi chưa lưu",
|
||||
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool ngay",
|
||||
"profilePending": "Profile đã lưu: đang được chuyển vào pool",
|
||||
"needsName": "Pool nào cũng cần có tên.",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"saveTitle": "Lưu các pool",
|
||||
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
|
||||
"summaryProfile": "Chuyển profile {{name}} vào pool",
|
||||
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"saved": "Đã lưu các pool",
|
||||
"dropAccounts": "Kéo tài khoản vào đây",
|
||||
"noAccounts": "Chưa có tài khoản",
|
||||
"accountsCount_one": "{{count}} tài khoản",
|
||||
"accountsCount_other": "{{count}} tài khoản",
|
||||
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
|
||||
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
|
||||
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
|
||||
"merged": "Đã gộp {{from}} vào {{into}}",
|
||||
"newDrop": "Thả vào đây để tách thành pool riêng",
|
||||
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
|
||||
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra",
|
||||
"defaultName": "Pool {{n}}",
|
||||
"saving": "Đang lưu…",
|
||||
"saveFailed": "Chưa lưu được: {{error}}",
|
||||
"retry": "Thử lại"
|
||||
},
|
||||
"addAccount": "Thêm tài khoản",
|
||||
"otherTools": "AI tool khác"
|
||||
@@ -338,8 +330,7 @@
|
||||
"button": "Xoá user",
|
||||
"title": "Xoá {{name}}?",
|
||||
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
||||
"confirm": "Xoá vĩnh viễn",
|
||||
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
||||
"confirm": "Xoá vĩnh viễn"
|
||||
},
|
||||
"tmux": {
|
||||
"title": "Tmux của member",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file imageMetadata.test.ts
|
||||
* @description Tests for the photo-metadata scrubber: JPEG keeps only its
|
||||
* orientation and loses EXIF/XMP/comments and trailing data, PNG loses its
|
||||
* text/EXIF chunks, WebP its EXIF/XMP chunks and flags; anything else, or a
|
||||
* clean image, comes back as the very same object.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scrubImage, scrubImageBytes, scrubImages } from "../imageMetadata";
|
||||
|
||||
const bytes = (...parts: (number[] | string | Uint8Array)[]) => {
|
||||
const flat: number[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === "string") for (const c of p) flat.push(c.charCodeAt(0));
|
||||
else flat.push(...p);
|
||||
}
|
||||
return new Uint8Array(flat);
|
||||
};
|
||||
const read = (b: Blob) =>
|
||||
new Promise<Uint8Array>((ok, no) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(new Uint8Array(r.result as ArrayBuffer));
|
||||
r.onerror = () => no(r.error);
|
||||
r.readAsArrayBuffer(b);
|
||||
});
|
||||
const indexOf = (hay: Uint8Array, needle: string) => {
|
||||
const n = [...needle].map((c) => c.charCodeAt(0));
|
||||
outer: for (let i = 0; i + n.length <= hay.length; i++) {
|
||||
for (let j = 0; j < n.length; j++) if (hay[i + j] !== n[j]) continue outer;
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const has = (hay: Uint8Array, needle: string) => indexOf(hay, needle) >= 0;
|
||||
const seg = (marker: number, body: Uint8Array) =>
|
||||
bytes([0xff, marker, (body.length + 2) >> 8, (body.length + 2) & 0xff], body);
|
||||
|
||||
/** EXIF payload: orientation 6 plus a GPS-looking string. */
|
||||
function exif(orientation: number) {
|
||||
const tiff = new Uint8Array(26);
|
||||
const dv = new DataView(tiff.buffer);
|
||||
tiff.set([0x4d, 0x4d, 0, 0x2a]); // "MM" big-endian
|
||||
dv.setUint32(4, 8);
|
||||
dv.setUint16(8, 1);
|
||||
dv.setUint16(10, 0x0112);
|
||||
dv.setUint16(12, 3);
|
||||
dv.setUint32(14, 1);
|
||||
dv.setUint16(18, orientation);
|
||||
dv.setUint32(22, 0);
|
||||
return bytes("Exif\0\0", tiff, "GPS 10.7769N 106.7009E");
|
||||
}
|
||||
|
||||
function jpeg(opts: { orientation?: number; trailer?: boolean } = {}) {
|
||||
return bytes(
|
||||
[0xff, 0xd8],
|
||||
seg(0xe0, bytes("JFIF\0", [1, 1, 0, 0, 1, 0, 1, 0, 0])),
|
||||
seg(0xe1, exif(opts.orientation ?? 6)),
|
||||
seg(0xe1, bytes("http://ns.adobe.com/xap/1.0/\0<x:xmpmeta>Owner Name</x:xmpmeta>")),
|
||||
seg(0xe2, bytes("ICC_PROFILE\0", [1, 1, 9, 9])),
|
||||
seg(0xfe, bytes("comment: secret")),
|
||||
seg(0xdb, bytes([0, 1, 2, 3])),
|
||||
seg(0xda, bytes([0, 1, 2])),
|
||||
[0x12, 0x34, 0xff, 0x00, 0x56, 0xff, 0xd0, 0x78],
|
||||
[0xff, 0xd9],
|
||||
opts.trailer ? bytes([0xff, 0xd8], seg(0xe1, exif(1)), "MOTION VIDEO") : []
|
||||
);
|
||||
}
|
||||
|
||||
describe("scrubImageBytes — JPEG", () => {
|
||||
it("drops EXIF, XMP and comments but keeps JFIF, ICC, tables and the scan", () => {
|
||||
const out = scrubImageBytes(jpeg({ orientation: 1 }))!;
|
||||
expect(out).not.toBeNull();
|
||||
expect(has(out, "GPS")).toBe(false);
|
||||
expect(has(out, "Owner Name")).toBe(false);
|
||||
expect(has(out, "secret")).toBe(false);
|
||||
expect(has(out, "Exif")).toBe(false);
|
||||
expect(has(out, "JFIF")).toBe(true);
|
||||
expect(has(out, "ICC_PROFILE")).toBe(true);
|
||||
// The scan (header + entropy data with a stuffed byte and a restart marker) is intact.
|
||||
expect([...out.slice(-15)]).toEqual(
|
||||
[0xff, 0xda, 0, 5, 0, 1, 2, 0x12, 0x34, 0xff, 0x00, 0x56, 0xff, 0xd0, 0x78]
|
||||
.concat([0xff, 0xd9])
|
||||
.slice(-15)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the orientation in a minimal EXIF so the photo is not shown sideways", () => {
|
||||
const out = scrubImageBytes(jpeg({ orientation: 6 }))!;
|
||||
expect(has(out, "GPS")).toBe(false);
|
||||
const at = indexOf(out, "Exif\0\0");
|
||||
expect(at).toBeGreaterThan(0);
|
||||
const dv = new DataView(out.buffer, out.byteOffset + at + 6);
|
||||
expect(dv.getUint16(2, true)).toBe(42); // "II*\0"
|
||||
expect(dv.getUint16(8, true)).toBe(1); // one entry …
|
||||
expect(dv.getUint16(10, true)).toBe(0x0112); // … the orientation …
|
||||
expect(dv.getUint16(18, true)).toBe(6); // … with its value
|
||||
});
|
||||
|
||||
it("cuts what a phone appends after the image (extra pictures, motion video)", () => {
|
||||
const out = scrubImageBytes(jpeg({ trailer: true }))!;
|
||||
expect(has(out, "MOTION VIDEO")).toBe(false);
|
||||
expect([...out.slice(-2)]).toEqual([0xff, 0xd9]);
|
||||
});
|
||||
|
||||
it("returns null for a JPEG with nothing to remove", () => {
|
||||
const clean = bytes(
|
||||
[0xff, 0xd8],
|
||||
seg(0xdb, bytes([0, 1])),
|
||||
seg(0xda, bytes([0])),
|
||||
[1, 2],
|
||||
[0xff, 0xd9]
|
||||
);
|
||||
expect(scrubImageBytes(clean)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a truncated JPEG instead of throwing", () => {
|
||||
expect(scrubImageBytes(jpeg().slice(0, 30))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function pngChunk(type: string, data: Uint8Array) {
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, data.length);
|
||||
return bytes(len, type, data, [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
describe("scrubImageBytes — PNG", () => {
|
||||
it("drops tEXt/iTXt/zTXt/eXIf/tIME and anything after IEND", () => {
|
||||
const png = bytes(
|
||||
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||
pngChunk("IHDR", new Uint8Array(13)),
|
||||
pngChunk("tEXt", bytes("Author\0Owner Name")),
|
||||
pngChunk("eXIf", bytes("MM GPS")),
|
||||
pngChunk("IDAT", bytes([1, 2, 3])),
|
||||
pngChunk("iTXt", bytes("Comment\0secret")),
|
||||
pngChunk("IEND", new Uint8Array(0)),
|
||||
"TRAILING"
|
||||
);
|
||||
const out = scrubImageBytes(png)!;
|
||||
expect(has(out, "Owner Name")).toBe(false);
|
||||
expect(has(out, "GPS")).toBe(false);
|
||||
expect(has(out, "secret")).toBe(false);
|
||||
expect(has(out, "TRAILING")).toBe(false);
|
||||
expect(has(out, "IHDR")).toBe(true);
|
||||
expect(has(out, "IDAT")).toBe(true);
|
||||
expect(has(out, "IEND")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrubImageBytes — WebP", () => {
|
||||
it("drops EXIF/XMP chunks, clears their VP8X flags and fixes the RIFF size", () => {
|
||||
const chunk = (tag: string, data: Uint8Array) => {
|
||||
const size = new Uint8Array(4);
|
||||
new DataView(size.buffer).setUint32(0, data.length, true);
|
||||
return bytes(tag, size, data, data.length & 1 ? [0] : []);
|
||||
};
|
||||
const body = bytes(
|
||||
"WEBP",
|
||||
chunk("VP8X", bytes([0x0c, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
|
||||
chunk("VP8 ", bytes([9, 9, 9, 9])),
|
||||
chunk("EXIF", bytes("GPS 10N")),
|
||||
chunk("XMP ", bytes("Owner Name"))
|
||||
);
|
||||
const size = new Uint8Array(4);
|
||||
new DataView(size.buffer).setUint32(0, body.length, true);
|
||||
const out = scrubImageBytes(bytes("RIFF", size, body))!;
|
||||
expect(has(out, "GPS")).toBe(false);
|
||||
expect(has(out, "Owner Name")).toBe(false);
|
||||
const dv = new DataView(out.buffer);
|
||||
expect(dv.getUint32(4, true)).toBe(out.length - 8);
|
||||
expect(out[20]! & 0x0c).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrubImage", () => {
|
||||
it("returns a File with the same name, type and date, minus the metadata", async () => {
|
||||
const f = new File([jpeg()], "IMG_0001.JPG", { type: "image/jpeg", lastModified: 1234 });
|
||||
const out = await scrubImage(f);
|
||||
expect(out).not.toBe(f);
|
||||
expect(out).toBeInstanceOf(File);
|
||||
expect(out.name).toBe("IMG_0001.JPG");
|
||||
expect(out.type).toBe("image/jpeg");
|
||||
expect(out.lastModified).toBe(1234);
|
||||
expect(has(await read(out), "GPS")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves non-images and clean images untouched (same object)", async () => {
|
||||
const txt = new File(["Exif GPS"], "notes.txt", { type: "text/plain" });
|
||||
expect(await scrubImage(txt)).toBe(txt);
|
||||
const fake = new File(["not a jpeg"], "x.jpg", { type: "image/jpeg" });
|
||||
expect(await scrubImage(fake)).toBe(fake);
|
||||
});
|
||||
|
||||
it("scrubs each file of a batch and keeps the other fields", async () => {
|
||||
const f = new File([jpeg()], "a.jpg", { type: "image/jpeg" });
|
||||
const [one] = await scrubImages([{ file: f, rel: "pics/a.jpg" }]);
|
||||
expect(one!.rel).toBe("pics/a.jpg");
|
||||
expect(has(await read(one!.file), "GPS")).toBe(false);
|
||||
});
|
||||
});
|
||||
+91
-74
@@ -452,6 +452,7 @@ import { activeSourcesParam } from "./dataScope";
|
||||
import i18n from "i18next";
|
||||
import { applyRange, type DateRange } from "./dateRange";
|
||||
import { emitGitChanged } from "./gitChanged";
|
||||
import { scrubImage, scrubImages } from "./imageMetadata";
|
||||
|
||||
// Root path all endpoint paths are appended to. Kept relative (no host) so the
|
||||
// same client bundle works behind the Vite dev proxy and in same-origin prod.
|
||||
@@ -1603,10 +1604,10 @@ export const api = {
|
||||
avatarUrl: (avatarAt?: string | null) =>
|
||||
avatarAt ? withToken(`${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}`) : null,
|
||||
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
|
||||
setAvatar: (file: Blob) =>
|
||||
setAvatar: async (file: Blob) =>
|
||||
request<MyAccount>("/me/avatar", {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
body: await scrubImage(file),
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/me/avatar/url — the server downloads the picture from an https link. */
|
||||
@@ -1641,10 +1642,10 @@ export const api = {
|
||||
`/users?tz_offset=${new Date().getTimezoneOffset()}`
|
||||
),
|
||||
/** PUT /api/users/quota-default — the default spend quota (null = none). */
|
||||
setQuotaDefault: (quota: UserQuota | null, currentPassword: string) =>
|
||||
setQuotaDefault: (quota: UserQuota | null) =>
|
||||
request<{ user: AuthUser; quotaDefault: UserQuota | null }>("/users/quota-default", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ quota, currentPassword }),
|
||||
body: JSON.stringify({ quota }),
|
||||
}),
|
||||
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||
details: (id: number) =>
|
||||
@@ -1658,65 +1659,61 @@ export const api = {
|
||||
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
|
||||
`/users/storage${refresh ? "?refresh=1" : ""}`
|
||||
),
|
||||
// Account changes (admin only). Each carries the admin's OWN password
|
||||
// again (`currentPassword`): the server refuses without it (EREAUTH).
|
||||
// Account changes (admin only — the server checks the role).
|
||||
/** POST /api/users — a new account. */
|
||||
create: (
|
||||
body: { username: string; password: string; role: AuthUser["role"] },
|
||||
currentPassword: string
|
||||
) =>
|
||||
create: (body: { username: string; password: string; role: AuthUser["role"] }) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>("/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
body: JSON.stringify({ ...body }),
|
||||
}),
|
||||
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
|
||||
tmux: () => request<MemberTmux>("/users/tmux"),
|
||||
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
|
||||
tmuxRestart: (id: number, currentPassword: string) =>
|
||||
tmuxRestart: (id: number) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
|
||||
tmuxRestartIdle: (currentPassword: string) =>
|
||||
tmuxRestartIdle: () =>
|
||||
request<{
|
||||
restarted: string[];
|
||||
skipped: string[];
|
||||
failed: { username: string; message: string }[];
|
||||
}>("/users/tmux-restart-idle", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
||||
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||
* sign-out in ONE change (never your own role or disabling yourself). */
|
||||
update: (id: number, body: UserPatch, currentPassword: string) =>
|
||||
update: (id: number, body: UserPatch) =>
|
||||
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
body: JSON.stringify({ ...body }),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/users/:id — delete the user for good: logins, their
|
||||
* workspaces' records and (one Linux user per account) their Linux
|
||||
* account and whole home. Never yourself or the last admin.
|
||||
*/
|
||||
remove: (id: number, currentPassword: string) =>
|
||||
remove: (id: number) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
||||
setPassword: (id: number, password: string, currentPassword: string) =>
|
||||
setPassword: (id: number, password: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, currentPassword }),
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
/** POST /api/users/:id/workspaces — give that user a workspace. */
|
||||
assignWorkspace: (id: number, laneId: number, currentPassword: string) =>
|
||||
assignWorkspace: (id: number, laneId: number) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/workspaces`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
body: JSON.stringify({ laneId }),
|
||||
}),
|
||||
},
|
||||
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
|
||||
@@ -1751,19 +1748,19 @@ export const api = {
|
||||
accountPool: {
|
||||
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** POST /api/accounts — admin. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: (tool: PoolTool = "claude") =>
|
||||
request<{ profiles: PoolProfile[] }>(
|
||||
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
|
||||
),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
add: (body: PoolAccountInput) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
body: JSON.stringify({ ...body }),
|
||||
}),
|
||||
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
|
||||
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
|
||||
setUsers: (tool: PoolTool, name: string, userIds: number[]) =>
|
||||
request<{
|
||||
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
|
||||
added: {
|
||||
@@ -1774,31 +1771,31 @@ export const api = {
|
||||
removed: { userId: number; username: string }[];
|
||||
}>(`/accounts/${encodeURIComponent(name)}/users`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tool, userIds, currentPassword }),
|
||||
body: JSON.stringify({ tool, userIds }),
|
||||
}),
|
||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
/** DELETE /api/accounts/:name — admin. */
|
||||
remove: (tool: PoolTool, name: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
body: JSON.stringify({ tool }),
|
||||
}),
|
||||
/** POST /api/accounts/groups — admin: a new, empty pool group. */
|
||||
createGroup: (name: string, currentPassword: string) =>
|
||||
createGroup: (name: string) =>
|
||||
request<{ group: { id: number; name: string } }>("/accounts/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, currentPassword }),
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
|
||||
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
|
||||
updateGroup: (id: number, body: PoolGroupPatch) =>
|
||||
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
body: JSON.stringify({ ...body }),
|
||||
}),
|
||||
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
|
||||
deleteGroup: (id: number, currentPassword: string) =>
|
||||
deleteGroup: (id: number) =>
|
||||
request<{ ok: true }>(`/accounts/groups/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
@@ -1996,7 +1993,9 @@ export const api = {
|
||||
/** GET /api/sessions/:id/image — an image the session's Read tool read,
|
||||
* wherever it lies (local mode only), for an `<img>`. */
|
||||
readImageUrl: (id: string, path: string) =>
|
||||
withToken(`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`),
|
||||
withToken(
|
||||
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`
|
||||
),
|
||||
/** GET /api/sessions/:id/file — a text file the session itself read or
|
||||
* wrote, wherever it lies (an output outside the workspace). */
|
||||
readFile: (id: string, path: string) =>
|
||||
@@ -2381,12 +2380,12 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
/** The daily database backup (`server/lib/db-backup.js`). */
|
||||
/** The scheduled database backup (`server/lib/db-backup.js`). */
|
||||
backup: {
|
||||
/** GET /api/settings/backup */
|
||||
get: () => request<BackupState>("/settings/backup"),
|
||||
/** PUT /api/settings/backup — any subset; an empty `dir` turns it off */
|
||||
save: (data: { dir?: string; hour?: number; keep?: number }) =>
|
||||
save: (data: { dir?: string; hour?: number; keep?: number; every?: number }) =>
|
||||
request<BackupState & { ok: boolean }>("/settings/backup", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
@@ -3507,11 +3506,13 @@ export const api = {
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress,
|
||||
signal
|
||||
scrubImage(file).then((clean) =>
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
clean,
|
||||
onProgress,
|
||||
signal
|
||||
)
|
||||
),
|
||||
/**
|
||||
* {@link files.upload}, resumable: sent in parts and written once whole,
|
||||
@@ -3524,11 +3525,13 @@ export const api = {
|
||||
conflict: FsConflict,
|
||||
opts: StagedSendOpts
|
||||
) =>
|
||||
sendStaged<{ path: string; skipped?: boolean }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
|
||||
file,
|
||||
opts
|
||||
scrubImage(file).then((clean) =>
|
||||
sendStaged<{ path: string; skipped?: boolean }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
|
||||
clean,
|
||||
opts
|
||||
)
|
||||
),
|
||||
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
|
||||
dropUpload: (t: FsPlace, upload: string) =>
|
||||
@@ -3548,11 +3551,13 @@ export const api = {
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
batchBody(files),
|
||||
onProgress,
|
||||
signal
|
||||
scrubImages(files).then((clean) =>
|
||||
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
batchBody(clean),
|
||||
onProgress,
|
||||
signal
|
||||
)
|
||||
),
|
||||
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
|
||||
uploadBatchStaged: (
|
||||
@@ -3562,11 +3567,13 @@ export const api = {
|
||||
conflict: FsConflict,
|
||||
opts: StagedSendOpts
|
||||
) =>
|
||||
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
|
||||
batchBody(files),
|
||||
opts
|
||||
scrubImages(files).then((clean) =>
|
||||
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
|
||||
t,
|
||||
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
|
||||
batchBody(clean),
|
||||
opts
|
||||
)
|
||||
),
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
@@ -3760,9 +3767,9 @@ export const api = {
|
||||
);
|
||||
},
|
||||
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
||||
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||
uploadFile: async (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||
const form = new FormData();
|
||||
form.append("files", file, file.name);
|
||||
form.append("files", await scrubImage(file), file.name);
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
@@ -3956,14 +3963,22 @@ export const api = {
|
||||
},
|
||||
/** The hub's remote-client views (server mode, server/hub/). */
|
||||
remoteHub: {
|
||||
/** GET /api/remote/policy - may laptops connect to this hub at all? */
|
||||
policy: () => request<{ allowDevices: boolean; changedAt: string | null }>("/remote/policy"),
|
||||
/** PUT /api/remote/policy - an admin turns laptop connections on/off. */
|
||||
setPolicy: (allowDevices: boolean) =>
|
||||
request<{ allowDevices: boolean; changedAt: string | null }>("/remote/policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ allowDevices }),
|
||||
}),
|
||||
/** GET /api/remote/devices - your machines; staff may name `user`. */
|
||||
devices: (user?: number) =>
|
||||
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
|
||||
/** POST /api/remote/devices/:id/revoke - your own password again. */
|
||||
revoke: (id: number, password: string) =>
|
||||
/** POST /api/remote/devices/:id/revoke - your own device, or (admin) anyone's. */
|
||||
revoke: (id: number) =>
|
||||
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
|
||||
sessions: (user?: number) =>
|
||||
@@ -4053,23 +4068,23 @@ export const api = {
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
* server's machine; the returned path is what gets pasted into the pane. */
|
||||
dropImage: (file: Blob) =>
|
||||
dropImage: async (file: Blob) =>
|
||||
request<{ path: string }>("/run/drop-image", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
body: await scrubImage(file),
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
|
||||
* run id) saves it where THAT workspace's panes can read it. */
|
||||
dropFile: (file: File, lane?: number | string | null) => {
|
||||
dropFile: async (file: File, lane?: number | string | null) => {
|
||||
const laneId =
|
||||
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
|
||||
const qs = new URLSearchParams({ name: file.name });
|
||||
if (laneId != null) qs.set("laneId", String(laneId));
|
||||
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
body: await scrubImage(file),
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
},
|
||||
@@ -4869,10 +4884,10 @@ export const api = {
|
||||
* ("" = the workspace root). Only creates: a taken name answers 409
|
||||
* EEXISTS; at most 25 MB (413 ETOOBIG).
|
||||
*/
|
||||
gitUpload: (id: GitTarget, dir: string, file: File) =>
|
||||
gitUpload: async (id: GitTarget, dir: string, file: File) =>
|
||||
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
|
||||
method: "POST",
|
||||
body: file,
|
||||
body: await scrubImage(file),
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
@@ -5155,10 +5170,10 @@ export const api = {
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
||||
addFile: (id: number, file: File) =>
|
||||
addFile: async (id: number, file: File) =>
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
body: await scrubImage(file),
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
@@ -6862,6 +6877,8 @@ export interface BackupState {
|
||||
dir: string;
|
||||
hour: number;
|
||||
keep: number;
|
||||
/** Days between backups (1 = daily, 7 = weekly). */
|
||||
every: number;
|
||||
running: boolean;
|
||||
last: {
|
||||
at: string;
|
||||
@@ -6873,7 +6890,7 @@ export interface BackupState {
|
||||
} | null;
|
||||
files: { name: string; size: number; mtime: string }[];
|
||||
dir_error: string | null;
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean };
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean; every?: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/retention. `days` 0 = automatic retention off;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @file imageMetadata.ts
|
||||
* @description Takes the hidden metadata out of a photo before the browser
|
||||
* uploads it: EXIF (GPS position, camera, date, owner), XMP, IPTC and
|
||||
* comments in JPEG, PNG and WebP. Lossless — only metadata segments are cut,
|
||||
* the pixels are never re-encoded. A JPEG keeps a minimal EXIF holding ONLY
|
||||
* its orientation (so a phone photo is not shown sideways), and the data a
|
||||
* phone appends after the image (extra pictures, motion-photo video) is cut
|
||||
* off. Any other file — or an image too large or not parseable — is returned
|
||||
* unchanged, so an upload never fails because of this step.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** Larger images go up untouched rather than be read into memory whole. */
|
||||
export const MAX_SCRUB_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const fourcc = (b: Uint8Array, at: number) =>
|
||||
String.fromCharCode(b[at] ?? 0, b[at + 1] ?? 0, b[at + 2] ?? 0, b[at + 3] ?? 0);
|
||||
|
||||
const ascii = (b: Uint8Array, at: number, s: string) => {
|
||||
if (at + s.length > b.length) return false;
|
||||
for (let i = 0; i < s.length; i++) if (b[at + i] !== s.charCodeAt(i)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** The EXIF orientation (1–8) in an APP1 "Exif\0\0" payload, or 0. */
|
||||
function exifOrientation(seg: Uint8Array): number {
|
||||
if (!ascii(seg, 0, "Exif\0\0")) return 0;
|
||||
const t = 6;
|
||||
if (seg.length < t + 8) return 0;
|
||||
const le = ascii(seg, t, "II");
|
||||
if (!le && !ascii(seg, t, "MM")) return 0;
|
||||
const dv = new DataView(seg.buffer, seg.byteOffset, seg.byteLength);
|
||||
const u16 = (o: number) => dv.getUint16(o, le);
|
||||
const u32 = (o: number) => dv.getUint32(o, le);
|
||||
const ifd = t + u32(t + 4);
|
||||
if (ifd + 2 > seg.length) return 0;
|
||||
const n = u16(ifd);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = ifd + 2 + i * 12;
|
||||
if (e + 12 > seg.length) return 0;
|
||||
if (u16(e) === 0x0112) {
|
||||
const v = u16(e + 8);
|
||||
return v >= 1 && v <= 8 ? v : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** An APP1 segment (marker included) carrying only `orientation`. */
|
||||
function orientationApp1(orientation: number): Uint8Array {
|
||||
const out = new Uint8Array(4 + 32);
|
||||
const dv = new DataView(out.buffer);
|
||||
out.set([0xff, 0xe1], 0);
|
||||
dv.setUint16(2, 2 + 32); // the length counts itself, not the marker
|
||||
out.set([0x45, 0x78, 0x69, 0x66, 0, 0], 4); // "Exif\0\0"
|
||||
out.set([0x49, 0x49, 0x2a, 0], 10); // "II*\0"
|
||||
dv.setUint32(14, 8, true); // IFD0 right after the header
|
||||
dv.setUint16(18, 1, true); // one entry
|
||||
dv.setUint16(20, 0x0112, true); // Orientation
|
||||
dv.setUint16(22, 3, true); // SHORT
|
||||
dv.setUint32(24, 1, true); // count
|
||||
dv.setUint16(28, orientation, true);
|
||||
dv.setUint32(32, 0, true); // no next IFD
|
||||
return out;
|
||||
}
|
||||
|
||||
/** JPEG: keep JFIF, ICC profile and Adobe; drop other APPn and comments. */
|
||||
function scrubJpeg(b: Uint8Array): Uint8Array[] | null {
|
||||
if (b[0] !== 0xff || b[1] !== 0xd8) return null;
|
||||
const parts: Uint8Array[] = [b.subarray(0, 2)];
|
||||
let orientation = 0;
|
||||
let i = 2;
|
||||
for (;;) {
|
||||
if (i + 4 > b.length || b[i] !== 0xff) return null;
|
||||
const m = b[i + 1] ?? 0;
|
||||
if (m === 0xff) {
|
||||
i++; // fill byte
|
||||
continue;
|
||||
}
|
||||
if (m === 0xda) break; // start of scan: image data follows
|
||||
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) {
|
||||
parts.push(b.subarray(i, i + 2));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const len = ((b[i + 2] ?? 0) << 8) | (b[i + 3] ?? 0);
|
||||
if (len < 2 || i + 2 + len > b.length) return null;
|
||||
const seg = b.subarray(i, i + 2 + len);
|
||||
const body = b.subarray(i + 4, i + 2 + len);
|
||||
const isApp = m >= 0xe0 && m <= 0xef;
|
||||
const keep =
|
||||
!isApp && m !== 0xfe
|
||||
? true
|
||||
: m === 0xe0
|
||||
? ascii(body, 0, "JFIF\0") || ascii(body, 0, "JFXX\0")
|
||||
: m === 0xe2
|
||||
? ascii(body, 0, "ICC_PROFILE\0")
|
||||
: m === 0xee
|
||||
? ascii(body, 0, "Adobe")
|
||||
: false;
|
||||
if (m === 0xe1 && !orientation) orientation = exifOrientation(body);
|
||||
if (keep) parts.push(seg);
|
||||
i += 2 + len;
|
||||
}
|
||||
// The image data runs to the first EOI; anything after it (a phone's extra
|
||||
// pictures or a motion-photo video, each with its own metadata) is dropped.
|
||||
let end = b.length;
|
||||
for (let j = i + 2; j + 1 < b.length; j++) {
|
||||
if (b[j] === 0xff && b[j + 1] === 0xd9) {
|
||||
end = j + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (orientation > 1) parts.splice(1, 0, orientationApp1(orientation));
|
||||
parts.push(b.subarray(i, end));
|
||||
return parts;
|
||||
}
|
||||
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const PNG_DROP = new Set(["eXIf", "tEXt", "zTXt", "iTXt", "tIME"]);
|
||||
|
||||
/** PNG: drop the text, time and EXIF chunks; stop at IEND. */
|
||||
function scrubPng(b: Uint8Array): Uint8Array[] | null {
|
||||
if (!PNG_SIG.every((v, k) => b[k] === v)) return null;
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
const parts: Uint8Array[] = [b.subarray(0, 8)];
|
||||
let i = 8;
|
||||
while (i + 12 <= b.length) {
|
||||
const len = dv.getUint32(i);
|
||||
const type = fourcc(b, i + 4);
|
||||
const next = i + 12 + len;
|
||||
if (next > b.length) return null;
|
||||
if (!PNG_DROP.has(type)) parts.push(b.subarray(i, next));
|
||||
i = next;
|
||||
if (type === "IEND") return parts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** WebP: drop the EXIF and XMP chunks and their VP8X flags. */
|
||||
function scrubWebp(b: Uint8Array): Uint8Array[] | null {
|
||||
if (!ascii(b, 0, "RIFF") || !ascii(b, 8, "WEBP")) return null;
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
const chunks: Uint8Array[] = [];
|
||||
let i = 12;
|
||||
const riffEnd = Math.min(b.length, 8 + dv.getUint32(4, true));
|
||||
while (i + 8 <= riffEnd) {
|
||||
const tag = fourcc(b, i);
|
||||
const size = dv.getUint32(i + 4, true);
|
||||
const next = i + 8 + size + (size & 1);
|
||||
if (i + 8 + size > b.length) return null;
|
||||
if (tag === "VP8X") {
|
||||
const c = b.slice(i, Math.min(next, b.length));
|
||||
c[8] = (c[8] ?? 0) & ~(0x08 | 0x04); // EXIF and XMP present
|
||||
chunks.push(c);
|
||||
} else if (tag !== "EXIF" && tag !== "XMP ") {
|
||||
chunks.push(b.subarray(i, Math.min(next, b.length)));
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
const body = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const head = new Uint8Array(12);
|
||||
head.set(b.subarray(0, 12));
|
||||
new DataView(head.buffer).setUint32(4, 4 + body, true);
|
||||
return [head, ...chunks];
|
||||
}
|
||||
|
||||
/** The bytes of `b` without metadata, or null when there is nothing to change. */
|
||||
export function scrubImageBytes(b: Uint8Array): Uint8Array<ArrayBuffer> | null {
|
||||
let parts: Uint8Array[] | null = null;
|
||||
try {
|
||||
parts = scrubJpeg(b) ?? scrubPng(b) ?? scrubWebp(b);
|
||||
} catch {
|
||||
parts = null;
|
||||
}
|
||||
if (!parts) return null;
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(new ArrayBuffer(total));
|
||||
let at = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, at);
|
||||
at += p.length;
|
||||
}
|
||||
if (out.length === b.length && out.every((v, k) => v === b[k])) return null;
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOOKS_LIKE_IMAGE = /^image\/(jpeg|pjpeg|png|webp)$/i;
|
||||
const IMAGE_NAME = /\.(jpe?g|jfif|png|webp)$/i;
|
||||
|
||||
async function bytesOf(blob: Blob): Promise<Uint8Array> {
|
||||
if (typeof blob.arrayBuffer === "function") return new Uint8Array(await blob.arrayBuffer());
|
||||
// jsdom's Blob has no arrayBuffer(); FileReader reads it everywhere.
|
||||
return new Promise((ok, no) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(new Uint8Array(r.result as ArrayBuffer));
|
||||
r.onerror = () => no(r.error);
|
||||
r.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `blob` without its photo metadata — a File keeps its name, type and date.
|
||||
* Returns the very same object when it is no JPEG/PNG/WebP, is larger than
|
||||
* {@link MAX_SCRUB_BYTES}, carries no metadata, or cannot be read.
|
||||
*/
|
||||
export async function scrubImage<T extends Blob>(blob: T): Promise<T> {
|
||||
const name = typeof File !== "undefined" && blob instanceof File ? blob.name : "";
|
||||
if (!LOOKS_LIKE_IMAGE.test(blob.type || "") && !IMAGE_NAME.test(name)) return blob;
|
||||
if (blob.size === 0 || blob.size > MAX_SCRUB_BYTES) return blob;
|
||||
try {
|
||||
const clean = scrubImageBytes(await bytesOf(blob));
|
||||
if (!clean) return blob;
|
||||
if (typeof File !== "undefined" && blob instanceof File) {
|
||||
return new File([clean], blob.name, {
|
||||
type: blob.type,
|
||||
lastModified: blob.lastModified,
|
||||
}) as unknown as T;
|
||||
}
|
||||
return new Blob([clean], { type: blob.type }) as unknown as T;
|
||||
} catch {
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link scrubImage} for each `{file}` of a batch, in order. */
|
||||
export async function scrubImages<F extends { file: Blob }>(files: F[]): Promise<F[]> {
|
||||
const out: F[] = [];
|
||||
for (const f of files) out.push({ ...f, file: await scrubImage(f.file) });
|
||||
return out;
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export const SETTINGS_INDEX: {
|
||||
adminOnly?: boolean;
|
||||
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
|
||||
serverOnly?: boolean;
|
||||
/** Server mode: moved to Administration → Server settings, so out of Settings' search. */
|
||||
serverAdmin?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "account",
|
||||
@@ -162,6 +164,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "autostart.label",
|
||||
serverAdmin: true,
|
||||
adminOnly: true,
|
||||
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
|
||||
to: "#autostart",
|
||||
@@ -169,6 +172,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "quit.label",
|
||||
serverAdmin: true,
|
||||
adminOnly: true,
|
||||
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
|
||||
to: "#autostart",
|
||||
@@ -176,6 +180,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "system",
|
||||
titleKey: "tray.label",
|
||||
serverAdmin: true,
|
||||
adminOnly: true,
|
||||
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
|
||||
to: "#autostart",
|
||||
@@ -205,6 +210,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "reports",
|
||||
titleKey: "reports.title",
|
||||
serverAdmin: true,
|
||||
adminOnly: true,
|
||||
descKey: "reports.description",
|
||||
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
|
||||
@@ -254,6 +260,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "artifacts:installsSwitch",
|
||||
serverAdmin: true,
|
||||
descKey: "artifacts:installsSwitchHint",
|
||||
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
|
||||
to: "#artifacts-hub",
|
||||
@@ -358,6 +365,7 @@ export const SETTINGS_INDEX: {
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitCreds.title",
|
||||
serverAdmin: true,
|
||||
adminOnly: true,
|
||||
serverOnly: true,
|
||||
keywords: ["login", "token"],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst — ONE
|
||||
* page, no tabs: ONE users table holding every per-user account fact (account,
|
||||
* redirects here), server mode only and only for an admin or analyst. Two
|
||||
* tabs (`?tab=`): **Users** for staff, and **Server settings** for an admin —
|
||||
* every setting that acts on the server for everyone, moved out of Settings
|
||||
* (`components/admin/ServerSettings.tsx`). Users is ONE users table holding every per-user account fact (account,
|
||||
* spend, home, tmux; each row expands in
|
||||
* place into that user's detail), the account pool, then the audit log — all
|
||||
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
|
||||
@@ -10,10 +12,12 @@
|
||||
* Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { ServerCog, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { ServerSettings } from "../components/admin/ServerSettings";
|
||||
import { useHostStats } from "../components/health/HostHealth";
|
||||
import { Users } from "./Users";
|
||||
|
||||
@@ -25,8 +29,11 @@ export function Administration() {
|
||||
// Read once, for each home's share of the homes disk. The machine and each
|
||||
// user's live CPU / RAM are on the Dashboard's System Health tab.
|
||||
const host = useHostStats(staff, 0);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
if (!staff) return <Navigate to="/" replace />;
|
||||
const tab = isAdmin && params.get("tab") === "server" ? "server" : "users";
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -39,7 +46,21 @@ export function Administration() {
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Users embedded host={host} />
|
||||
{isAdmin ? (
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={tab}
|
||||
onSelect={(id) => setParams(id === "server" ? { tab: "server" } : {}, { replace: true })}
|
||||
tabs={[
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: ServerCog },
|
||||
]}
|
||||
>
|
||||
{tab === "server" ? <ServerSettings /> : <Users embedded host={host} />}
|
||||
</PageTabs>
|
||||
) : (
|
||||
<Users embedded host={host} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the scheduled database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -198,7 +198,7 @@ export function Settings({
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, seesAll } = usePermissions();
|
||||
const { isAdmin, seesAll, local } = usePermissions();
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -1123,8 +1123,9 @@ export function Settings({
|
||||
|
||||
{section === "about" && (
|
||||
<div id="about" className="space-y-6 scroll-mt-24">
|
||||
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
|
||||
{isAdmin && <AutostartSettings />}
|
||||
{/* Start at login, the icon, Quit and Uninstall act on the host: an
|
||||
admin's (server mode: Administration → Server settings). */}
|
||||
{isAdmin && local && <AutostartSettings />}
|
||||
{sysInfo ? (
|
||||
<SystemDetails info={sysInfo} />
|
||||
) : (
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||
* while the tab is hidden behind a session.
|
||||
* Server mode moves every setting that acts on the whole server — laptop
|
||||
* connections, members' test runs, omp sharing, Artifacts installs, the
|
||||
* host's git logins, scheduled reports, start at login, Pricing and Data — to
|
||||
* Administration → Server settings (`components/admin/ServerSettings.tsx`);
|
||||
* `/settings/data` and `/settings/pricing` redirect there, and an admin gets
|
||||
* a link to it under the nav. Local mode keeps them all here.
|
||||
* Server mode hides every tool page from a non-admin, except Claude Code for a
|
||||
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
|
||||
* config", which they manage as themselves.
|
||||
@@ -62,7 +68,6 @@ import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiProxyConfig } from "./AiProxyConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AccountSettings } from "../components/settings/AccountSettings";
|
||||
@@ -71,12 +76,9 @@ import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
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 { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { AllPricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -313,14 +315,11 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
run on as rows of the same list: staff see the host's logins too,
|
||||
a developer only the pool rows (it hides itself with none). */}
|
||||
<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 />
|
||||
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||
<MemberPolicySettings />
|
||||
{/* The company hub: enroll this machine (local) / your machines (hub). */}
|
||||
<CompanyHubSettings />
|
||||
{/* The Hub's install switch, omp sharing and members' test runs are
|
||||
server-mode admin settings: Administration → Server settings. */}
|
||||
{/* The company hub: enroll this machine (local) / your own machines
|
||||
(a non-admin on the hub; an admin's is in Administration). */}
|
||||
{(local || !isAdmin) && <CompanyHubSettings />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -380,26 +379,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
group: "data",
|
||||
Icon: Coins,
|
||||
...s("pricing"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<PricingSettings
|
||||
vendor="claude"
|
||||
allowReset
|
||||
id="pricing-claude"
|
||||
heading={t("pricing.vendor.claude")}
|
||||
/>
|
||||
<PricingSettings
|
||||
vendor="openai"
|
||||
id="pricing-openai"
|
||||
heading={t("pricing.vendor.openai")}
|
||||
/>
|
||||
<PricingSettings
|
||||
vendor="google"
|
||||
id="pricing-google"
|
||||
heading={t("pricing.vendor.google")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
render: () => <AllPricingSettings />,
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
@@ -411,8 +391,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<div id="snapshots" className="scroll-mt-24">
|
||||
<SnapshotSettings />
|
||||
</div>
|
||||
{/* Scheduled reports read every user's sessions: an admin's. */}
|
||||
{isAdmin && <ReportSettings />}
|
||||
{/* Scheduled reports read every user's sessions: an admin's (server
|
||||
mode: Administration → Server settings). */}
|
||||
{isAdmin && local && <ReportSettings />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -423,15 +404,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
...s("git"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Git logins are the host's own: an admin's. Not in local mode,
|
||||
nor the identity below: git uses this machine's own config,
|
||||
SSH keys and credential helper there. */}
|
||||
{isAdmin && !local && (
|
||||
<section id="git-credentials" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</section>
|
||||
)}
|
||||
{/* The host's git logins are an admin's: Administration → Server
|
||||
settings. Not in local mode, nor the identity below: git uses
|
||||
this machine's own config, SSH keys and credential helper there. */}
|
||||
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
|
||||
{!local && (isAdmin || signedIn) && (
|
||||
<section id="git-identity" className="scroll-mt-24">
|
||||
@@ -453,12 +428,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
];
|
||||
// Server mode: only an admin changes the host's own configuration (the
|
||||
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
||||
const sections = isAdmin
|
||||
? allSections
|
||||
: allSections.filter(
|
||||
(x) =>
|
||||
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
|
||||
);
|
||||
const sections = (
|
||||
isAdmin
|
||||
? allSections
|
||||
: allSections.filter(
|
||||
(x) =>
|
||||
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
|
||||
)
|
||||
).filter((x) => local || !ADMIN_SECTIONS.has(x.id)); // server mode: in Administration
|
||||
const navSections = sections.filter((x) => !x.tool);
|
||||
const toolTabs = sections.filter((x) => x.tool);
|
||||
function toolHref(id: string) {
|
||||
@@ -472,6 +449,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
(e) =>
|
||||
(isAdmin || !e.adminOnly) &&
|
||||
(!local || !e.serverOnly) &&
|
||||
(local || !e.serverAdmin) &&
|
||||
(e.section === sec.id ||
|
||||
(e.section === "agents/*" &&
|
||||
sec.id.startsWith("agents/") &&
|
||||
@@ -500,6 +478,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
return <Navigate to={href(to)} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
|
||||
// Server mode: the server-wide pages live in Administration → Server settings.
|
||||
if (!local && ADMIN_SECTIONS.has(rest))
|
||||
return <Navigate to={`/admin?tab=server#${rest}`} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
@@ -627,6 +608,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{isAdmin && !local && (
|
||||
<p className="mt-5 px-2 text-[11px] leading-relaxed text-fg-muted">
|
||||
{t("settingsNav.movedToAdmin")}{" "}
|
||||
<Link to="/admin?tab=server" className="text-accent hover:underline">
|
||||
{t("admin:tabs.server")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 max-w-5xl flex-1 pb-10">
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
* on (`AccountPoolCard`). Each row's spend shows its spend quota (spent of the
|
||||
* limit, red once used up) and the default quota sits in its own card
|
||||
* (`UserQuota.tsx`); a user's own quota is part of Edit.
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* Only an admin changes anything (the server checks the role; no password
|
||||
* again since 2026-11-09); an analyst only reads. A row shows the display
|
||||
* 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. Clicking a
|
||||
@@ -665,7 +665,7 @@ function RowAction({
|
||||
);
|
||||
}
|
||||
|
||||
/** A new account: username, password, role, then the admin's own password. */
|
||||
/** A new account: username, password, role. */
|
||||
function AddUserDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
@@ -677,18 +677,17 @@ function AddUserDialog({
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>("developer");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
|
||||
const ready = username.trim().length > 0 && password.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||
const r = await api.users.create({ username: username.trim(), password, role });
|
||||
onDone(t("done.created", { name: r.user.displayName || r.user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
@@ -755,16 +754,6 @@ function AddUserDialog({
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Administration.test.tsx
|
||||
* @description The Administration page (`/admin`): local mode and a developer
|
||||
* are sent home; staff see ONE page, no tabs, and no machine section (that is
|
||||
* are sent home; an analyst sees the users with no tabs, an admin a Users tab
|
||||
* and a Server settings tab (`?tab=server`); no machine section (that is
|
||||
* the Dashboard's System Health tab now, each user's live CPU / RAM included)
|
||||
* — the users table carries each user's home share (`GET /api/host`) and tmux.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -97,6 +98,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The server-settings blocks have their own tests (ServerSettings.test.tsx).
|
||||
vi.mock("../../components/admin/ServerSettings", () => ({
|
||||
ServerSettings: () => <div data-testid="server-settings" />,
|
||||
}));
|
||||
|
||||
import { Administration } from "../Administration";
|
||||
|
||||
const renderAt = (url: string) =>
|
||||
@@ -154,13 +160,33 @@ describe("Administration", () => {
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the users on one page, with no tabs and no machine section", async () => {
|
||||
it("shows an analyst the users with no tabs and no machine section", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderAt("/admin?tab=server");
|
||||
expect(await screen.findByTestId("users-table")).toBeTruthy();
|
||||
expect(screen.queryByTestId("server-facts")).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: /server settings/i })).toBeNull();
|
||||
expect(screen.queryByTestId("server-settings")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives an admin a Users tab and a Server settings tab", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("users-table")).toBeTruthy();
|
||||
expect(screen.queryByTestId("server-facts")).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
|
||||
const server = screen.getByRole("tab", { name: /server settings|cài đặt máy chủ/i });
|
||||
expect(screen.getByRole("tab", { name: /^(users|người dùng)$/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true"
|
||||
);
|
||||
server.click();
|
||||
expect(await screen.findByTestId("server-settings")).toBeTruthy();
|
||||
expect(screen.queryByTestId("users-table")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens Server settings straight from ?tab=server", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin?tab=server");
|
||||
expect(await screen.findByTestId("server-settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts each user's home and tmux in the users table, but not their CPU", async () => {
|
||||
|
||||
@@ -71,6 +71,13 @@ vi.mock("../../components/settings/ArtifactsHubSettings", () => ({
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||
AllPricingSettings: () => (
|
||||
<>
|
||||
{["claude", "openai", "google"].map((v) => (
|
||||
<div key={v} data-testid={`pricing-${v}`} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
const tool = (id: string, over: Partial<AiTool> = {}) =>
|
||||
@@ -347,13 +354,28 @@ describe("SettingsHub", () => {
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("shows git logins and worktree suggestions on one Git page", async () => {
|
||||
it("server mode: the Git page keeps the user's own identity; the host's logins are in Administration", async () => {
|
||||
perms.local = false;
|
||||
renderAt("/settings/git");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("git-credentials")).toBeNull();
|
||||
expect(screen.getByTestId("git-identity")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /server settings/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/admin?tab=server"
|
||||
);
|
||||
});
|
||||
|
||||
it("server mode: Data and Pricing leave Settings for Administration → Server settings", async () => {
|
||||
perms.local = false;
|
||||
renderAt("/settings/appearance");
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" });
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).not.toContain("Data");
|
||||
expect(links).not.toContain("Pricing");
|
||||
});
|
||||
|
||||
it("keeps AgentHub's own git logins and identity off a local Git page", async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @file Users.test.tsx
|
||||
* @description The Users page: local mode and a member are sent home; an
|
||||
* analyst reads the table with no account controls; an admin adds a user only
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* analyst reads the table with no account controls; an admin adds a user with
|
||||
* no password of their own asked (the confirm waits only for a username and a
|
||||
* password), sees a server refusal reported in the edit dialog, which stays
|
||||
* open, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username; right-clicking a row opens that
|
||||
* user's home in Files (an admin) — an analyst's menu only copies; clicking a
|
||||
@@ -216,7 +217,7 @@ describe("Users page", () => {
|
||||
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a user only with the admin's own password", async () => {
|
||||
it("adds a user once a username and password are typed, asking for no admin password", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.create.mockResolvedValue({
|
||||
user: { id: 9, username: "neo", role: "developer", disabled: false },
|
||||
@@ -225,44 +226,45 @@ describe("Users page", () => {
|
||||
renderPage();
|
||||
await ue.click(await screen.findByRole("button", { name: "Add user" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await ue.type(screen.getByLabelText("Username"), "neo");
|
||||
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add user"
|
||||
)!;
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(screen.queryByLabelText("Your password")).toBeNull();
|
||||
await ue.type(screen.getByLabelText("Username"), "neo");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true); // no password yet
|
||||
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
// The pool board reads the user list too; count the page's own reload.
|
||||
const before = users.list.mock.calls.length;
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
{ username: "neo", password: "neo-pass-123", role: "developer" },
|
||||
"boss-pass"
|
||||
)
|
||||
expect(users.create).toHaveBeenCalledWith({
|
||||
username: "neo",
|
||||
password: "neo-pass-123",
|
||||
role: "developer",
|
||||
})
|
||||
);
|
||||
expect(users.create.mock.calls[0]).toHaveLength(1);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
it("reports a server refusal in the edit dialog and keeps it open", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.update.mockRejectedValue(
|
||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||
Object.assign(new Error("that is the last admin"), { code: "ELASTADMIN" })
|
||||
);
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
await ue.click(screen.getAllByRole("button", { name: "Edit user" })[1]!);
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Your password is wrong."
|
||||
"The last admin cannot be deleted, demoted or disabled."
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" }, "wrong");
|
||||
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" });
|
||||
});
|
||||
|
||||
it("offers one Edit per row and no separate account buttons", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.80 (rebuild client to match)
|
||||
UI build v2.8.82 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -9204,13 +9204,13 @@ exit 0
|
||||
d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16"
|
||||
/>
|
||||
</svg>
|
||||
Daily backup
|
||||
Database backup
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.
|
||||
A full copy of the database on a schedule — every day, every week… — into a folder you choose; best on another disk, so a failed drive does not take the backups with it.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
@@ -9236,6 +9236,49 @@ exit 0
|
||||
<div
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-40"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="How often"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="How often"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Every day
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-28"
|
||||
>
|
||||
|
||||
+8
-8
@@ -91,13 +91,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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). 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 `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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 (an admin's), 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?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
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 and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `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, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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 and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `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`). No password again: an admin's session is enough (the role is checked; a `currentPassword` still sent is ignored). `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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`, 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 Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex 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`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) 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 and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, …}` → `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 Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex 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`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool}` → `{ok: true}`. No answer carries the token. `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}` → `{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 admin-only check and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds}` → `{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 and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{}` → `{ok: true}` (its members go back to the shared pool). Same admin-only check and audit as the other pool changes (no password again).
|
||||
|
||||
**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`):
|
||||
|
||||
@@ -3571,7 +3571,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
|
||||
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub in local mode, Administration → Server settings in server mode (installs: server mode only)
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
|
||||
POST /api/artifacts/page {url, title?, description?}
|
||||
@@ -3656,15 +3656,15 @@ A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local
|
||||
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
|
||||
|
||||
|
||||
### Daily database backup
|
||||
### Scheduled database backup
|
||||
|
||||
```
|
||||
GET /api/settings/backup { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
|
||||
PUT /api/settings/backup { dir?, hour?, keep? } → the same state; dir "" turns backups off
|
||||
GET /api/settings/backup { dir, hour, keep, every, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep, every } }
|
||||
PUT /api/settings/backup { dir?, hour?, keep?, every? } → the same state; dir "" turns backups off
|
||||
POST /api/settings/backup/run 202 — started (or already running); poll GET while `running`
|
||||
```
|
||||
|
||||
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
|
||||
Settings → Data → **Database backup** (`server/lib/db-backup.js`). Every `every` days (1–30, default 1 = daily, 7 = weekly — due once the newest backup in `dir` is that many calendar days old), at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
|
||||
|
||||
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
|
||||
|
||||
|
||||
+2
-2
@@ -1006,9 +1006,9 @@ function validateSession(session) {
|
||||
|
||||
## Backup Strategies
|
||||
|
||||
### Built-in daily backup (Settings → Data → Daily backup)
|
||||
### Built-in scheduled backup (Settings → Data → Database backup)
|
||||
|
||||
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there once a day at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
|
||||
Set a folder — best on another disk — and the server writes `agenthub-YYYYMMDD-HHMMSS.db` there every day, every week or every N days (1–30) at a chosen local hour (default 02:00), keeping the newest 7 (`server/lib/db-backup.js`). It is the `VACUUM INTO` below, run on a read-only connection in a worker thread, so the dashboard keeps serving during the copy. Rotation deletes only those exact file names. To restore: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db`, `agenthub start`. Routes and settings: [API.md → Daily database backup](./API.md#daily-database-backup).
|
||||
|
||||
### Online Backup (Recommended)
|
||||
|
||||
|
||||
+49
-17
@@ -464,6 +464,14 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
|
||||
Pet uploads are admin-only.
|
||||
- The native file picker stays local-only (`EREMOTE`) and every page falls back
|
||||
to its folder browser.
|
||||
- Every `claude` pane the server starts gets `DISABLE_ERROR_REPORTING=1`: a
|
||||
crash report's stack trace can carry a user's paths and Linux account name,
|
||||
and turning it off costs no feature. Telemetry and feature flags stay on —
|
||||
Claude Code's telemetry carries no code, paths or commands, and turning it
|
||||
off (`DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`) would
|
||||
take away flag-gated features such as artifact comments, synced skills and
|
||||
plugins, the advisor and MCP tasks. Set `DISABLE_ERROR_REPORTING` in the
|
||||
dashboard's own environment to override. Local mode is unchanged.
|
||||
|
||||
## Accounts
|
||||
|
||||
@@ -473,9 +481,9 @@ that changes, in a single save, its display name and email, role,
|
||||
active/disabled, password (typed or generated), the workspaces it owns (give
|
||||
one, or take one back — it is then left with no owner) and "sign out
|
||||
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
|
||||
a workspace the user does not own changes nothing. Only an admin can make a
|
||||
change (the server checks the role; there is no password again — only admins
|
||||
reach Administration) — an admin can never demote or disable
|
||||
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
|
||||
@@ -584,7 +592,7 @@ home is kept and checked again on the next beat. A home still holding one of the
|
||||
account's earlier pairs (its Claude raced the server's refresh) stays the pool's
|
||||
and gets the newest pair, so its member is not asked to `/login`. Removing the account deletes it from
|
||||
the homes that still hold AgentHub's copy. Each
|
||||
change asks for the admin's own password again and is written to the audit log
|
||||
change is the admin's alone and is written to the audit log
|
||||
by account name. The token only goes in; the page, like `agenthub accounts list`,
|
||||
never shows it again. An analyst sees the card read-only; a developer does not
|
||||
see it. Every user shares the pool: a user created later needs no setup of their own.
|
||||
@@ -618,8 +626,8 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
- **Switching a user to one account.** An admin can take one user off the
|
||||
ranking: the pencil (**Edit**) on that account's row in the Account pool
|
||||
card — tick the users who run on it, untick to send one back to automatic (or
|
||||
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
|
||||
own password again, each change audited). A user ticked on one account
|
||||
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; each
|
||||
change audited). A user ticked on one account
|
||||
moves off any other. Every session in a
|
||||
workspace that user OWNS then runs on that account, even when it is full;
|
||||
other users are not affected, and the account is not reserved for them.
|
||||
@@ -649,6 +657,18 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||
> The AI Proxy gateway does not cover pool tokens.
|
||||
|
||||
## Administration → Server settings
|
||||
|
||||
Everything that acts on the whole server — not on one user — is an admin's
|
||||
and lives on **Administration → Server settings** (`/admin?tab=server`), not
|
||||
in Settings: laptop connections and every enrolled machine, **Tests on this
|
||||
server**, **Oh My Pi login sharing**, Artifacts Hub installs, AgentHub's git
|
||||
logins, scheduled reports, start at login / the tray icon / Quit / Uninstall,
|
||||
model pricing and the Data page (import, cleanup, export). `/settings/data`
|
||||
and `/settings/pricing` redirect there. An analyst sees Administration's
|
||||
users only; a developer never sees Administration. Local mode keeps all of
|
||||
these in Settings.
|
||||
|
||||
## Users' own machines (Company account)
|
||||
|
||||
A user with their own computer can run **AgentHub local** there and still use
|
||||
@@ -659,10 +679,21 @@ hour and when the session ends. A session on the user's own login sends
|
||||
nothing, and the server never reads or runs anything on their machine.
|
||||
Design and limits: `docs/plans/remote-clients/README.md`.
|
||||
|
||||
**It is OFF until an admin turns it on**: Administration → Server settings → Company hub →
|
||||
**Allow laptop connections** (asked once; audited). While
|
||||
off, a machine cannot enroll and an enrolled one can neither use the pool nor
|
||||
send anything (`403 EREMOTEOFF`) — every company session runs on this server,
|
||||
and nothing of a user's own machine reaches the server or Anthropic. While on,
|
||||
a laptop session still tells Anthropic the laptop's working path, OS and a
|
||||
random device id (Claude Code puts them in every request); the server itself
|
||||
keeps no host name, no IP and no folder path of the machine — only the
|
||||
project folder's name, with the account and folders above it shown as `~` in
|
||||
the uploaded conversations — and the pane sends no telemetry of its own.
|
||||
|
||||
On the user's machine (company LAN only, plain HTTP):
|
||||
|
||||
```bash
|
||||
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
|
||||
agenthub remote login http://10.1.13.7:4820 --user mia # hub password, asked hidden; --name only if you want one
|
||||
agenthub remote status # hub, device, what was sent and when
|
||||
agenthub remote upload # send now instead of waiting for the hour
|
||||
agenthub remote logout # the token stops working on the server at once
|
||||
@@ -674,9 +705,10 @@ a resume. The device token lasts 90 days; after it expires or is revoked, log
|
||||
in again and resume any session that was open (a running pane keeps the token
|
||||
it started with).
|
||||
|
||||
On the server: Settings → AI tools → Company hub lists the user's own
|
||||
machines (Revoke, their password again) and who opened their sessions; an
|
||||
admin sees and revokes any user's machines on the **Users** page. Their
|
||||
On the server: Settings → AI tools → Company hub lists a user's own
|
||||
machines (Revoke) and who opened their sessions; an
|
||||
admin sees every machine, with the switch, under Administration → Server
|
||||
settings → Company hub, and one user's on their row of the **Users** table. Their
|
||||
sessions are listed device › folder, with what the gateway counted; one used
|
||||
with the device token outside AgentHub is flagged **unmonitored**, and an
|
||||
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
|
||||
@@ -778,7 +810,8 @@ by team (Users page → **Account pool**, where a group is called a pool, server
|
||||
group.
|
||||
- A login already live in a member's home stays until their panes end (one
|
||||
`.credentials.json` / `auth.json` per home), even after a group change.
|
||||
- Each change needs the admin's own password again and is audited. Removing
|
||||
- Each change is an admin's and is audited; the board saves every drag by
|
||||
itself (no Save button). Removing
|
||||
an account takes it out of every group; deleting a group sends its members
|
||||
back to the shared pool and removes no account.
|
||||
|
||||
@@ -842,7 +875,7 @@ switcher, as with **Move to pool** in Settings).
|
||||
|
||||
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**, or the
|
||||
logins instead: **Administration → Server settings → Oh My Pi login sharing**, or the
|
||||
**Oh My Pi** tab of the Users page's Account pool (off by default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
broker and gateway (`server/lib/omp-share.js`):
|
||||
|
||||
@@ -896,8 +929,7 @@ An admin limits what each user may spend, in dollars, on the Users page
|
||||
- A period is `day` (from local midnight of the zone the admin's browser was
|
||||
in when they saved it) or `5h` (sliding: the current UTC hour and the four
|
||||
before it — spend booked in hour H counts until H + 5 h).
|
||||
- Both writes need the admin's own password again and are audited, like
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
- Both writes are an admin's and are audited, like every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS, priced by `calculateCost`. A session is owned through its workspace;
|
||||
@@ -1231,8 +1263,8 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
installed* badge), its sessions and whether an agent of theirs is working.
|
||||
The row's **Restart** button ends one member's server — every pane of theirs closes, their
|
||||
Claude sessions resume from the workspace — and **Restart N idle members**
|
||||
(above the table) does it for every outdated member with no agent working; both take the
|
||||
admin's password again and are audited per member. A server that will not
|
||||
(above the table) does it for every outdated member with no agent working; both are an
|
||||
admin's and are audited per member. A server that will not
|
||||
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
||||
@@ -1343,7 +1375,7 @@ tests run on each member's own machine:
|
||||
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||
An admin can allow them again with **Administration → Server settings → Tests on this
|
||||
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||
at members' next Bash call, no restart.
|
||||
|
||||
@@ -171,7 +171,7 @@ fails if anything outside the folder requires it):
|
||||
| File | Does |
|
||||
|---|---|
|
||||
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
|
||||
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
|
||||
| `remote-sessions.js` | registration (UUID, folder name only since 11-09, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
|
||||
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
|
||||
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
|
||||
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
|
||||
@@ -208,6 +208,40 @@ Known limits:
|
||||
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
|
||||
- A shared AI Proxy as a company account, and Codex, are not built.
|
||||
|
||||
## Privacy hardening (2026-11-09)
|
||||
|
||||
Measured with a capture server and Claude Code 2.1.319 (the build members
|
||||
run): every `/v1/messages` call carries `metadata.user_id` =
|
||||
`{"device_id": <random 64-hex, made at the config dir's first run, fixed>,
|
||||
"account_uuid": "", "session_id"}`; the headers carry only the CLI/Node
|
||||
versions, `Linux`/`x64` and the session id — no host name, account or path;
|
||||
the system prompt carries `Primary working directory: <path>`, `Platform` and
|
||||
`OS Version`. Outside the base URL Claude Code also connected to
|
||||
`api.anthropic.com` itself (telemetry with the device id, distro, kernel,
|
||||
terminal — no host name or path — queued on disk when it fails);
|
||||
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` stopped all of it.
|
||||
|
||||
So a laptop session always tells Anthropic the laptop's working path, OS and
|
||||
device id (the prompt has to keep the real path or the agent breaks), and the
|
||||
hub stored the machine's host name (device name), its last IP, each session's
|
||||
whole path and transcripts full of `/home/<account>/…`. Decided (user,
|
||||
11-09): the company works on the hub (server mode, S3); laptops are an
|
||||
exception an admin turns on.
|
||||
|
||||
| Change | Where |
|
||||
|---|---|
|
||||
| **Admin switch, OFF by default**: while off, device login, every `/api/remote/device/*` route and the gateway answer `403 EREMOTEOFF`; the signed-in views keep working. `GET/PUT /api/remote/policy` (PUT: admin + own password, audited), Settings → AI tools → Company hub | `server/hub/remote-policy.js` (`<data>/remote-hub.json`), `index.js`, `remote-gateway.js`, `client/src/components/remote/RemotePolicy.tsx` |
|
||||
| No address: `devices.last_ip` is never written (`lastIp` always null) | `devices.js` |
|
||||
| No host name: a client enrolls under the name the user gives, else none (hub: `device`; the UI shows `device #<id>`) | `remote-client.js` `login` |
|
||||
| No path: a session registers its folder's last segment; the hub cuts an older client's full path the same way | `remote-client.js`, `remote-sessions.js` `cleanCwd` |
|
||||
| Transcripts: before upload, the folders above the project and the home become `~` (also as Claude's `-home-<account>-…` folder names), the host name `device`, the account in `ls -l` columns `user` | `server/lib/path-privacy.js`, `remote-client.js` `redactLine` |
|
||||
| No direct traffic: a Company-account pane gets `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` | `remote-client.js` `claudeEnv` |
|
||||
| Stored data cleaned ONCE per hub (marker `<data>/remote/.private-v1`): device names → `device`, IPs dropped, folder → label, the upload copy (folder renamed) and the importer snapshot `<data>/transcripts/<sid>…` rewritten, every text column of the session's rows (not `transcript_path`, whose project segment is moved), audit lines naming a device; token rows untouched | `server/hub/remote-privacy.js`, run from `startTimers` |
|
||||
|
||||
Still reaching Anthropic from a laptop while the switch is on: the working
|
||||
path and OS in the system prompt, the random device id, the message
|
||||
content. Only the hub-only setup hides those.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should an admin be able to mark a workspace "company account required"?
|
||||
|
||||
+111
-180
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.77
|
||||
version: 2.8.81
|
||||
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ĩ
|
||||
@@ -91,7 +91,7 @@ tags:
|
||||
- name: Query
|
||||
description: 'Query Explorer: filtered, sorted pages and exports'
|
||||
- name: Remote clients
|
||||
description: 'Company hub ↔ local AgentHub. On the hub (server mode): a machine enrolls with its user''s password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account''s OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine''s enrollment; the device token is never returned.'
|
||||
description: 'Company hub ↔ local AgentHub. Laptops connect only while the hub admin''s switch (`/api/remote/policy`) is on — off by default, and while off every `/api/remote/device/*` route and the gateway answer 403 EREMOTEOFF. The hub keeps no address and no host name of a machine, and only the last segment of a session''s folder. On the hub (server mode): a machine enrolls with its user''s password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account''s OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine''s enrollment; the device token is never returned.'
|
||||
components:
|
||||
parameters:
|
||||
SessionIdPath:
|
||||
@@ -5189,6 +5189,7 @@ components:
|
||||
lastIp:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Always null: the hub keeps no address of a user''s machine.'
|
||||
revokedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -14136,6 +14137,11 @@ paths:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
every:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 30
|
||||
description: Days between backups (1 = daily, 7 = weekly)
|
||||
running:
|
||||
type: boolean
|
||||
last:
|
||||
@@ -14167,11 +14173,13 @@ paths:
|
||||
type: boolean
|
||||
keep:
|
||||
type: boolean
|
||||
every:
|
||||
type: boolean
|
||||
put:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Set the backup folder, hour and number kept
|
||||
description: Any subset of the three fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP. An empty `dir` turns backups off. Same-origin only.
|
||||
summary: Set the backup folder, how often, the hour and number kept
|
||||
description: Any subset of the fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP / AGENTHUB_BACKUP_EVERY. An empty `dir` turns backups off. Same-origin only.
|
||||
operationId: setBackup
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -14190,6 +14198,10 @@ paths:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
every:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 30
|
||||
responses:
|
||||
'200':
|
||||
description: The new state
|
||||
@@ -14217,6 +14229,11 @@ paths:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
every:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 30
|
||||
description: Days between backups (1 = daily, 7 = weekly)
|
||||
running:
|
||||
type: boolean
|
||||
last:
|
||||
@@ -14248,6 +14265,8 @@ paths:
|
||||
type: boolean
|
||||
keep:
|
||||
type: boolean
|
||||
every:
|
||||
type: boolean
|
||||
'400':
|
||||
description: 'INVALID_PATH: the folder is relative, missing, not a directory or not writable; INVALID_INPUT: hour/keep out of range'
|
||||
'403':
|
||||
@@ -15731,7 +15750,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: createServerUser
|
||||
summary: 'Admin: add an account. Needs the admin''s own password again (currentPassword); audited'
|
||||
summary: 'Admin: add an account; audited'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -15741,7 +15760,6 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
- currentPassword
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
@@ -15755,8 +15773,6 @@ paths:
|
||||
- analyst
|
||||
- developer
|
||||
default: developer
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
@@ -15774,7 +15790,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -15785,12 +15801,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/tmux:
|
||||
get:
|
||||
tags:
|
||||
@@ -15811,7 +15821,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: restartMemberTmux
|
||||
summary: End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)
|
||||
summary: End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, audited)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -15824,16 +15834,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
properties: {}
|
||||
responses:
|
||||
'200':
|
||||
description: '{user}'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -15855,23 +15861,19 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: restartIdleMemberTmux
|
||||
summary: Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)
|
||||
summary: Restart every member server older than the installed tmux while no agent of theirs works (admin, audited per member)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
properties: {}
|
||||
responses:
|
||||
'200':
|
||||
description: '{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}'
|
||||
'403':
|
||||
description: EREAUTH / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16018,8 +16020,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
required: []
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
@@ -16057,8 +16058,6 @@ paths:
|
||||
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
@@ -16076,7 +16075,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16093,12 +16092,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
@@ -16116,11 +16109,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
required: []
|
||||
properties: {}
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted — the account as it was
|
||||
@@ -16138,7 +16128,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16155,12 +16145,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: EPROVISION — the Linux account or home could not be removed; nothing deleted
|
||||
content:
|
||||
@@ -16181,14 +16165,11 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- quota
|
||||
- currentPassword
|
||||
properties:
|
||||
quota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserQuota'
|
||||
nullable: true
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
@@ -16210,7 +16191,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16227,12 +16208,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
@@ -16253,13 +16228,10 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- password
|
||||
- currentPassword
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
@@ -16277,7 +16249,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16294,12 +16266,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/workspaces:
|
||||
post:
|
||||
tags:
|
||||
@@ -16320,12 +16286,9 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- laneId
|
||||
- currentPassword
|
||||
properties:
|
||||
laneId:
|
||||
type: integer
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Changed
|
||||
@@ -16343,7 +16306,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
description: EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16360,12 +16323,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/audit:
|
||||
get:
|
||||
tags:
|
||||
@@ -16549,7 +16506,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: addPoolAccount
|
||||
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back'
|
||||
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool. The secret is never answered back'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -16558,7 +16515,6 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
- currentPassword
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
@@ -16578,8 +16534,6 @@ paths:
|
||||
token:
|
||||
type: string
|
||||
description: 'Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key'
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: '`{account: {name, addedAt, …}}` — no token'
|
||||
@@ -16590,7 +16544,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16601,12 +16555,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: EUPSTREAM — Anthropic could not be reached
|
||||
content:
|
||||
@@ -16618,7 +16566,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: pinPoolAccount
|
||||
summary: 'Admin: switch one user''s sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart'
|
||||
summary: 'Admin: switch one user''s sessions of a tool to one pool account (`account: null` = automatic). A Claude login is written into their home at once; running panes take it without a restart'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -16628,7 +16576,6 @@ paths:
|
||||
required:
|
||||
- tool
|
||||
- userId
|
||||
- currentPassword
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
@@ -16641,8 +16588,6 @@ paths:
|
||||
account:
|
||||
type: string
|
||||
nullable: true
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null'
|
||||
@@ -16653,7 +16598,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16664,12 +16609,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/profiles:
|
||||
get:
|
||||
tags:
|
||||
@@ -16684,7 +16623,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setPoolAccountUsers
|
||||
summary: 'Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user''s home at once'
|
||||
summary: 'Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer). A Claude login is written into each added user''s home at once'
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
@@ -16700,7 +16639,6 @@ paths:
|
||||
required:
|
||||
- tool
|
||||
- userIds
|
||||
- currentPassword
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
@@ -16712,8 +16650,6 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session'
|
||||
@@ -16724,7 +16660,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16735,18 +16671,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/groups:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: createPoolGroup
|
||||
summary: 'Admin: a new, empty pool group, with their own password again'
|
||||
summary: 'Admin: a new, empty pool group'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -16755,12 +16685,9 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- currentPassword
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: '`{group: {id, name}}`'
|
||||
@@ -16771,7 +16698,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16782,18 +16709,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/groups/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: updatePoolGroup
|
||||
summary: 'Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again'
|
||||
summary: 'Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -16806,8 +16727,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
@@ -16822,8 +16741,6 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{group}`'
|
||||
@@ -16841,7 +16758,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16858,17 +16775,11 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: deletePoolGroup
|
||||
summary: 'Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again'
|
||||
summary: 'Admin: delete a pool group — its members go back to the shared pool, no account is removed'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -16881,16 +16792,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
properties: {}
|
||||
responses:
|
||||
'200':
|
||||
description: '`{ok: true}`'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16901,18 +16808,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/accounts/{name}:
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: removePoolAccount
|
||||
summary: 'Admin: remove an account from the pool, with their own password again'
|
||||
summary: 'Admin: remove an account from the pool'
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
@@ -16927,7 +16828,6 @@ paths:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
- currentPassword
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
@@ -16935,13 +16835,11 @@ paths:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '`{ok: true}`'
|
||||
'403':
|
||||
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||
description: EADMIN — not an admin; ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -16952,12 +16850,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — too many wrong passwords
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/auth/me:
|
||||
get:
|
||||
tags:
|
||||
@@ -24722,6 +24614,7 @@ paths:
|
||||
format: uuid
|
||||
cwd:
|
||||
type: string
|
||||
description: The session's folder; the hub keeps only its last segment.
|
||||
tool:
|
||||
type: string
|
||||
enum:
|
||||
@@ -24847,6 +24740,61 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/remote/policy:
|
||||
get:
|
||||
tags:
|
||||
- Remote clients
|
||||
operationId: remoteGetPolicy
|
||||
summary: May laptops connect to this hub at all (off by default)?
|
||||
responses:
|
||||
'200':
|
||||
description: The switch
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
allowDevices:
|
||||
type: boolean
|
||||
changedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
put:
|
||||
tags:
|
||||
- Remote clients
|
||||
operationId: remoteSetPolicy
|
||||
summary: Turn laptop connections on or off — an admin (audited)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- allowDevices
|
||||
properties:
|
||||
allowDevices:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The switch as it now stands
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'400':
|
||||
description: EBADFIELD — allowDevices must be true or false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EADMIN — admins only
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/remote/devices:
|
||||
get:
|
||||
tags:
|
||||
@@ -24875,24 +24823,13 @@ paths:
|
||||
tags:
|
||||
- Remote clients
|
||||
operationId: remoteRevokeDevice
|
||||
summary: Revoke a device — its owner or an admin, with their own password again (audited)
|
||||
summary: Revoke a device — its owner or an admin (audited)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- password
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Revoked
|
||||
@@ -24900,12 +24837,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'403':
|
||||
description: Wrong password
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: No such device of yours
|
||||
content:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.80",
|
||||
"version": "2.8.82",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.80",
|
||||
"version": "2.8.82",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.80",
|
||||
"version": "2.8.82",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+4
-3
@@ -739,8 +739,8 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
|
||||
| `GET` | `/api/settings/privacy` | `{redact_secrets, ignore_cwd[], env_owned: {redact_secrets, ignore_cwd}}` (`server/lib/privacy.js`) |
|
||||
| `PUT` | `/api/settings/privacy` | `{redact_secrets?: boolean, ignore_cwd?: string[]}` → persisted to the settings file as `AGENTHUB_REDACT_SECRETS` / `AGENTHUB_IGNORE_CWD` (`sameOriginGuard`). 400 `EBADPATH` (relative, a root, a control char or the list separator), 409 `EENVOWNED`, 500 `ESAVE` |
|
||||
| `GET` | `/api/settings/backup` | `{dir, hour, keep, running, last, files[], dir_error, env_owned: {dir, hour, keep}}` — the daily database backup (`server/lib/db-backup.js`); `files` are AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` in `dir`, newest first; `last` is this process's last run |
|
||||
| `PUT` | `/api/settings/backup` | `{dir?: string, hour?: 0–23, keep?: 1–365}` → persisted as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP` (`sameOriginGuard`); an empty `dir` turns backups off. 400 `INVALID_PATH` (relative, missing, not a directory, not writable) / `INVALID_INPUT`, 409 `EENVOWNED`, 500 `ESAVE` |
|
||||
| `GET` | `/api/settings/backup` | `{dir, hour, keep, every, running, last, files[], dir_error, env_owned: {dir, hour, keep, every}}` — the scheduled database backup (`server/lib/db-backup.js`); `files` are AgentHub's own `agenthub-YYYYMMDD-HHMMSS.db` in `dir`, newest first; `last` is this process's last run |
|
||||
| `PUT` | `/api/settings/backup` | `{dir?: string, hour?: 0–23, keep?: 1–365, every?: 1–30}` → persisted as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP` / `AGENTHUB_BACKUP_EVERY` (`sameOriginGuard`); an empty `dir` turns backups off. 400 `INVALID_PATH` (relative, missing, not a directory, not writable) / `INVALID_INPUT`, 409 `EENVOWNED`, 500 `ESAVE` |
|
||||
| `POST` | `/api/settings/backup/run` | Start a backup now and answer `202` at once (poll `GET` while `running`); 400 `ENOBACKUPDIR` (`sameOriginGuard`) |
|
||||
| `GET` | `/api/settings/push-token` | `{enabled, env_owned}` — never the token |
|
||||
| `POST` | `/api/settings/push-token` | Make a new push-ingest token (the old one stops working) and return it this ONCE as `token`; only its SHA-256 is stored (`AGENTHUB_PUSH_TOKEN_SHA256`). `sameOriginGuard`; 409 `EENVOWNED` |
|
||||
@@ -1633,10 +1633,11 @@ DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone whil
|
||||
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
|
||||
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
|
||||
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
|
||||
DASHBOARD_BACKUP_TICK_MS=60000 # Daily-backup scheduler beat (ms); 0 disables it. The folder/hour/count are set in Settings → Data → Backup:
|
||||
DASHBOARD_BACKUP_TICK_MS=60000 # Backup scheduler beat (ms); 0 disables it. The folder/hour/count are set in Settings → Data → Backup:
|
||||
AGENTHUB_BACKUP_DIR= # folder for agenthub-YYYYMMDD-HHMMSS.db (empty = off); only those files are ever rotated out
|
||||
AGENTHUB_BACKUP_HOUR=2 # server local hour (0–23); a day missed while the server was down runs at the next start
|
||||
AGENTHUB_BACKUP_KEEP=7 # backups kept (1–365)
|
||||
AGENTHUB_BACKUP_EVERY=1 # days between backups (1 = daily, 7 = weekly; 1–30)
|
||||
|
||||
# Remote Data Sources (SSH pull; see the Remote Data Sources section)
|
||||
DASHBOARD_REMOTE_SYNC_MS=15000 # Remote-source sync poll interval (ms); 0 disables the poller
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* complete, openable copy of the live database under a server-made name, no
|
||||
* `.partial` is left behind, rotation deletes only AgentHub's own
|
||||
* `agenthub-YYYYMMDD-HHMMSS.db` files (never a look-alike, a symlink or anything
|
||||
* else in the folder), the once-a-day schedule decision, and the folder check.
|
||||
* else in the folder), the schedule decision (daily, or every N days), and the
|
||||
* folder check.
|
||||
* The settings file and the folders are temp paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -18,7 +19,12 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-backup-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
for (const k of ["AGENTHUB_BACKUP_DIR", "AGENTHUB_BACKUP_HOUR", "AGENTHUB_BACKUP_KEEP"]) {
|
||||
for (const k of [
|
||||
"AGENTHUB_BACKUP_DIR",
|
||||
"AGENTHUB_BACKUP_HOUR",
|
||||
"AGENTHUB_BACKUP_KEEP",
|
||||
"AGENTHUB_BACKUP_EVERY",
|
||||
]) {
|
||||
delete process.env[k];
|
||||
}
|
||||
|
||||
@@ -140,6 +146,16 @@ describe("dueNow", () => {
|
||||
assert.equal(backup.dueNow(at(9), cfg, [], "20261008"), false));
|
||||
it("does nothing with no folder", () =>
|
||||
assert.equal(backup.dueNow(at(9), { ...cfg, dir: "" }, [], null), false));
|
||||
it("weekly: waits until the newest backup is 7 calendar days old", () => {
|
||||
const weekly = { ...cfg, every: 7 };
|
||||
const sixAgo = [{ name: "agenthub-20261002-020000.db" }];
|
||||
const sevenAgo = [{ name: "agenthub-20261001-020000.db" }];
|
||||
assert.equal(backup.dueNow(at(9), weekly, sixAgo, null), false);
|
||||
assert.equal(backup.dueNow(at(9), weekly, [...sevenAgo, ...sixAgo], null), false);
|
||||
assert.equal(backup.dueNow(at(9), weekly, sevenAgo, null), true);
|
||||
assert.equal(backup.dueNow(at(9), weekly, [], null), true);
|
||||
assert.equal(backup.dueNow(at(1), weekly, sevenAgo, null), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings routes", () => {
|
||||
@@ -150,6 +166,7 @@ describe("Settings routes", () => {
|
||||
assert.equal(body.dir, "");
|
||||
assert.equal(body.hour, 2);
|
||||
assert.equal(body.keep, 7);
|
||||
assert.equal(body.every, 1);
|
||||
assert.deepEqual(body.files, []);
|
||||
});
|
||||
|
||||
@@ -160,6 +177,8 @@ describe("Settings routes", () => {
|
||||
{ hour: 1.5 },
|
||||
{ keep: 0 },
|
||||
{ keep: "7" },
|
||||
{ every: 0 },
|
||||
{ every: 31 },
|
||||
]) {
|
||||
const { status } = await call("PUT", "/api/settings/backup", bad);
|
||||
assert.equal(status, 400, JSON.stringify(bad));
|
||||
@@ -168,13 +187,14 @@ describe("Settings routes", () => {
|
||||
|
||||
it("PUT persists to the settings file; POST /run starts a backup", async () => {
|
||||
const dir = folder("route");
|
||||
const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5 });
|
||||
const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5, every: 7 });
|
||||
assert.equal(put.status, 200);
|
||||
assert.equal(put.body.dir, dir);
|
||||
const saved = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8");
|
||||
assert.match(saved, new RegExp(`^AGENTHUB_BACKUP_DIR=${dir}$`, "m"));
|
||||
assert.match(saved, /^AGENTHUB_BACKUP_HOUR=3$/m);
|
||||
assert.match(saved, /^AGENTHUB_BACKUP_KEEP=5$/m);
|
||||
assert.match(saved, /^AGENTHUB_BACKUP_EVERY=7$/m);
|
||||
|
||||
const run = await call("POST", "/api/settings/backup/run");
|
||||
assert.equal(run.status, 202);
|
||||
|
||||
@@ -211,6 +211,8 @@ describe("lane-sources instructions and launch", () => {
|
||||
// One wheel notch reaches claude as one report; it scrolls this many lines.
|
||||
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
|
||||
// Local mode keeps Claude's error reports (server mode turns them off).
|
||||
assert.ok(!newSession.some((a) => String(a).startsWith("DISABLE_ERROR_REPORTING")));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Tests for each member's own tmux server on the Users page (S3,
|
||||
* server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the
|
||||
* installed one, an admin's restart of one member (password again, audited,
|
||||
* installed one, an admin's restart of one member (admin role only, audited,
|
||||
* ETMUXSTUCK when it will not stop) and of every idle outdated member — a
|
||||
* member whose agent is working is skipped. Member tmux calls are faked.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -137,17 +137,12 @@ describe("member tmux servers on the Users page", () => {
|
||||
assert.equal(servers["ah-ann"].alive, true);
|
||||
});
|
||||
|
||||
it("restarts one member's server with the admin's password, audited", async () => {
|
||||
it("restarts one member's server for an admin (no password again), audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
assert.equal(servers["ah-ann"].alive, true);
|
||||
const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(servers["ah-ann"].alive, false);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file path-privacy.test.js
|
||||
* @description Unit tests for server/lib/path-privacy.js: a session folder is
|
||||
* cut to its name; the folders above it and the home become `~` (in plain
|
||||
* paths, in Claude Code's project-folder names and on Windows); the host name
|
||||
* becomes `device`; the account name is hidden in `ls -l` columns; nothing
|
||||
* that merely starts like a hidden path, or an ordinary word, is touched.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const p = require("../lib/path-privacy");
|
||||
|
||||
describe("folderLabel / homeOf / parentOf", () => {
|
||||
it("keeps the folder's last segment only", () => {
|
||||
assert.equal(p.folderLabel("/home/a/Documents/shop/api"), "api");
|
||||
assert.equal(p.folderLabel("/home/a/api/"), "api");
|
||||
assert.equal(p.folderLabel("C:\\Users\\Ann\\code\\api"), "api");
|
||||
assert.equal(p.folderLabel("api"), "api");
|
||||
assert.equal(p.folderLabel("/"), "");
|
||||
assert.equal(p.folderLabel("C:\\"), "");
|
||||
});
|
||||
|
||||
it("finds the home on Linux, macOS, Windows and WSL", () => {
|
||||
assert.equal(p.homeOf("/home/alice/x"), "/home/alice");
|
||||
assert.equal(p.homeOf("/Users/bob/x"), "/Users/bob");
|
||||
assert.equal(p.homeOf("/root/x"), "/root");
|
||||
assert.equal(p.homeOf("C:\\Users\\Ann\\x"), "C:\\Users\\Ann");
|
||||
assert.equal(p.homeOf("/mnt/c/Users/Ann/x"), "/mnt/c/Users/Ann");
|
||||
assert.equal(p.homeOf("/srv/app"), "");
|
||||
});
|
||||
|
||||
it("parentOf stops at a root", () => {
|
||||
assert.equal(p.parentOf("/home/a/api"), "/home/a");
|
||||
assert.equal(p.parentOf("/api"), "");
|
||||
assert.equal(p.parentOf("C:\\api"), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rulesFor + scrubText", () => {
|
||||
const cwd = "/home/alice/Documents/Clients/acme/api";
|
||||
const rules = p.rulesFor({ cwd, hostnames: ["alice-ThinkPad-X1"] });
|
||||
const s = (t) => p.scrubText(t, rules);
|
||||
|
||||
it("hides the folders above the project and the home", () => {
|
||||
assert.equal(s(`${cwd}/src/a.js`), "~/api/src/a.js");
|
||||
assert.equal(s("/home/alice/.ssh/config"), "~/.ssh/config");
|
||||
assert.equal(s(`cd ${cwd} && ls`), "cd ~/api && ls");
|
||||
});
|
||||
|
||||
it("hides them in Claude Code's project-folder names", () => {
|
||||
assert.equal(
|
||||
s("~/.claude/projects/-home-alice-Documents-Clients-acme-api/memory/m.md"),
|
||||
"~/.claude/projects/~-api/memory/m.md"
|
||||
);
|
||||
assert.equal(s("/tmp/claude-1000/-home-alice-Documents/x"), "/tmp/claude-1000/~-Documents/x");
|
||||
});
|
||||
|
||||
it("hides the host name and the account in ls -l columns", () => {
|
||||
assert.equal(s("ssh alice-ThinkPad-X1"), "ssh device");
|
||||
assert.equal(
|
||||
s("drwxr-xr-x 2 alice alice 4096 Nov 6 src"),
|
||||
"drwxr-xr-x 2 user user 4096 Nov 6 src"
|
||||
);
|
||||
assert.equal(s("-rw-r--r-- 1 alice staff 12 Nov 6 a"), "-rw-r--r-- 1 user staff 12 Nov 6 a");
|
||||
});
|
||||
|
||||
it("leaves look-alikes and ordinary words alone", () => {
|
||||
assert.equal(s("/home/alice2/x"), "/home/alice2/x");
|
||||
assert.equal(s("/home/alice/Documents/Clients/acme-old/y"), "~/Documents/Clients/acme-old/y");
|
||||
assert.equal(s("alice wrote this"), "alice wrote this");
|
||||
assert.equal(s("/srv/data"), "/srv/data");
|
||||
});
|
||||
|
||||
it("never hides an ancestor of the home (a session run in the home itself)", () => {
|
||||
const r = p.rulesFor({ cwd: "/home/alice" });
|
||||
assert.equal(p.scrubText("/home/bob/x /home/alice/y", r), "/home/bob/x ~/y");
|
||||
});
|
||||
|
||||
it("works on Windows paths", () => {
|
||||
const r = p.rulesFor({ cwd: "C:\\Users\\Ann\\code\\api" });
|
||||
assert.equal(p.scrubText("C:\\Users\\Ann\\code\\api\\a.js", r), "~\\api\\a.js");
|
||||
assert.equal(p.scrubText("C:\\Users\\Ann\\.npmrc", r), "~\\.npmrc");
|
||||
});
|
||||
|
||||
it("ignores host names too short to be safe", () => {
|
||||
const r = p.rulesFor({ cwd: "/home/a/p", hostnames: ["pc"] });
|
||||
assert.equal(p.scrubText("my pc", r), "my pc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrubValue / scrubLine", () => {
|
||||
const rules = p.rulesFor({ cwd: "/home/alice/w/api" });
|
||||
|
||||
it("scrubs every string of a JSON line, keys untouched", () => {
|
||||
const line = JSON.stringify({
|
||||
cwd: "/home/alice/w/api",
|
||||
message: { content: [{ type: "text", text: "see /home/alice/w/api/a.js" }] },
|
||||
n: 3,
|
||||
});
|
||||
assert.deepEqual(JSON.parse(p.scrubLine(line, rules)), {
|
||||
cwd: "~/api",
|
||||
message: { content: [{ type: "text", text: "see ~/api/a.js" }] },
|
||||
n: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("scrubs a line that is not JSON as text, and does nothing without rules", () => {
|
||||
assert.equal(p.scrubLine("raw /home/alice/x", rules), "raw ~/x");
|
||||
assert.equal(p.scrubLine('{"a":"/home/alice"}', []), '{"a":"/home/alice"}');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* accounts of each tool (none → no pool account), a user in no group only
|
||||
* the accounts no group lists, a pin wins over the group; one group per user;
|
||||
* a removed account or deleted group leaves no rows behind; and the routes
|
||||
* need an admin's own password again, are audited and refuse in local mode.
|
||||
* are an admin's only (no password again), audited and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -145,16 +145,11 @@ describe("pool groups: who may take which account", () => {
|
||||
});
|
||||
|
||||
describe("pool groups: routes", () => {
|
||||
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
|
||||
it("creates, edits and deletes a group as an admin (no password again), audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const bad = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EREAUTH");
|
||||
const made = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: BOSS_PASS },
|
||||
body: { name: "Team A" },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
const id = made.body.group.id;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file pty-run.test.js
|
||||
* @description Unit tests for the tmux-backed run lifecycle. Injects a fake
|
||||
* tmux exec implementation (via tmux.js's test seam) so no real tmux binary
|
||||
* is invoked.
|
||||
* is invoked. Also: server mode turns off Claude's error reports in a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, beforeEach, before, after } = require("node:test");
|
||||
@@ -1058,3 +1058,41 @@ describe("pty-run", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorReportingEnv", () => {
|
||||
const withEnv = (vars, fn) => {
|
||||
const old = Object.fromEntries(Object.keys(vars).map((k) => [k, process.env[k]]));
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(old)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
};
|
||||
it("turns Claude's error reports off in server mode only", () => {
|
||||
withEnv({ AGENTHUB_MODE: "server", DISABLE_ERROR_REPORTING: undefined }, () => {
|
||||
assert.deepEqual(pty.errorReportingEnv("claude"), { DISABLE_ERROR_REPORTING: "1" });
|
||||
assert.deepEqual(pty.errorReportingEnv("codex"), {});
|
||||
});
|
||||
withEnv({ AGENTHUB_MODE: undefined, DISABLE_ERROR_REPORTING: undefined }, () =>
|
||||
assert.deepEqual(pty.errorReportingEnv("claude"), {})
|
||||
);
|
||||
});
|
||||
it("leaves telemetry alone and lets the dashboard's own setting win", () => {
|
||||
withEnv({ AGENTHUB_MODE: "server", DISABLE_ERROR_REPORTING: "" }, () =>
|
||||
assert.deepEqual(pty.errorReportingEnv("claude"), {})
|
||||
);
|
||||
withEnv({ AGENTHUB_MODE: "server", DISABLE_ERROR_REPORTING: undefined }, () => {
|
||||
const env = pty.errorReportingEnv("claude");
|
||||
for (const k of ["DISABLE_TELEMETRY", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) {
|
||||
assert.equal(env[k], undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,11 @@ describe("enrollment", () => {
|
||||
const st = await local("GET", "/api/remote-client");
|
||||
assert.ok(!JSON.stringify(st.body).includes(TOKEN));
|
||||
});
|
||||
it("never names the device after this machine's host name", async () => {
|
||||
const st = await remote.login({ hub: HUB, username: "mia", password: "right-pass" });
|
||||
assert.equal(st.device.name, "", "no name given → none sent");
|
||||
assert.notEqual(st.device.name, require("node:os").hostname());
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Company account", () => {
|
||||
@@ -200,6 +205,11 @@ describe("the Company account", () => {
|
||||
assert.equal(s.env.ANTHROPIC_BASE_URL, `${HUB}/api/remote/gw`);
|
||||
assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, TOKEN);
|
||||
assert.equal(s.env.ENABLE_PROMPT_CACHING_1H, "1");
|
||||
assert.equal(
|
||||
s.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC,
|
||||
"1",
|
||||
"nothing goes from this machine straight to Anthropic"
|
||||
);
|
||||
});
|
||||
|
||||
it("a resume stays on it", () => {
|
||||
@@ -265,6 +275,25 @@ describe("the upload", () => {
|
||||
assert.equal(next[0].files[0].data, '{"partial":"done"}\n');
|
||||
});
|
||||
|
||||
it("hides this machine's home, folders above the project and host name", async () => {
|
||||
hubSeen.uploads = [];
|
||||
const home = require("node:os").homedir();
|
||||
const host = require("node:os").hostname();
|
||||
const line = JSON.stringify({
|
||||
type: "user",
|
||||
cwd: "/w/p",
|
||||
message: { content: `read /w/p/src/a.js, ${home}/.ssh/config on ${host}` },
|
||||
});
|
||||
const company = makeSession({ company: true, lines: `${line}\n` });
|
||||
await remote.uploadAll();
|
||||
const sent = hubSeen.uploads.filter((u) => u.sessionId === company.id && u.files.length);
|
||||
const data = sent[0].files[0].data;
|
||||
assert.ok(data.includes('"cwd":"~/p"'), data);
|
||||
assert.ok(data.includes("~/p/src/a.js") && data.includes("~/.ssh/config"), data);
|
||||
assert.ok(!data.includes(home), "the home folder (account name) never leaves");
|
||||
if (host.length >= 4) assert.ok(!data.includes(host), "nor the machine's name");
|
||||
});
|
||||
|
||||
it("resyncs from where the hub stands after a 409", async () => {
|
||||
const line = JSON.stringify({ type: "user", message: { content: "hi" } });
|
||||
const s = makeSession({ company: true, lines: `${line}\n${line}\n` });
|
||||
@@ -323,7 +352,7 @@ describe("upload edges", () => {
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(hubSeen.registered.filter((b) => b.sessionId === s.id).length, 1);
|
||||
assert.equal(hubSeen.registered[0].cwd, "/w/p");
|
||||
assert.equal(hubSeen.registered[0].cwd, "p", "the folder's name only, never its path");
|
||||
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), 8);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* JSON answers; one account per session), the hourly upload (offset protocol,
|
||||
* fixed file shapes only, the SAME importer so tokens land, owner_id/source
|
||||
* stamped, scope: a developer sees only their own), retention purge, the
|
||||
* signed-in views (devices, revoke with the password again + audit, "who
|
||||
* signed-in views (devices, revoke by owner or admin + audit, "who
|
||||
* opened my sessions").
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -31,6 +31,10 @@ process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.AGENTHUB_REMOTE_REGISTER_GRACE_MS = "300";
|
||||
process.env.AGENTHUB_REMOTE_UNMONITORED_ALERT_MS = "250";
|
||||
process.env.AGENTHUB_REMOTE_FLUSH_MS = "250";
|
||||
// Laptops are refused unless the admin turned them on (remote-policy.js);
|
||||
// the suites below run with them on, the "admin switch" suite turns it off.
|
||||
fs.mkdirSync(path.join(TMP, "data"), { recursive: true });
|
||||
fs.writeFileSync(path.join(TMP, "data", "remote-hub.json"), '{"allowDevices":true}\n');
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
@@ -237,17 +241,21 @@ describe("session registration", () => {
|
||||
tom = (await enroll("tom", "member-pass-2")).body.token;
|
||||
});
|
||||
|
||||
it("takes a UUID + absolute folder, refuses anything else", async () => {
|
||||
it("takes a UUID + folder and keeps only the folder's NAME, refuses anything else", async () => {
|
||||
const id = sid();
|
||||
const ok = await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/home/mia/proj" },
|
||||
body: { sessionId: id, cwd: "/home/mia/Documents/shop/proj" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.body.sessionId, id);
|
||||
// An older client sends the whole path: the hub keeps the last segment only.
|
||||
const row = db.prepare("SELECT cwd FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(row.cwd, "proj");
|
||||
for (const body of [
|
||||
{ sessionId: "not-a-uuid", cwd: "/x" },
|
||||
{ sessionId: sid(), cwd: "relative/path" },
|
||||
{ sessionId: sid(), cwd: "" },
|
||||
{ sessionId: sid(), cwd: "/" },
|
||||
{ sessionId: sid(), cwd: "/x", tool: "codex" },
|
||||
]) {
|
||||
const r = await call("POST", "/api/remote/device/sessions", { token: mia, body });
|
||||
@@ -321,7 +329,7 @@ describe("enrollment and registration edges", () => {
|
||||
});
|
||||
assert.equal(
|
||||
db.prepare("SELECT cwd FROM remote_sessions WHERE session_id = ?").get(id).cwd,
|
||||
"/a"
|
||||
"a"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -852,7 +860,7 @@ describe("the hourly upload", () => {
|
||||
});
|
||||
|
||||
describe("signed-in views", () => {
|
||||
it("a developer lists only their own devices and revokes one with their password", async () => {
|
||||
it("a developer lists only their own devices and revokes one (no password needed)", async () => {
|
||||
const d = (await enroll("mia", "member-pass-1", "to-revoke")).body;
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const tomCookie = await login("tom", "member-pass-2");
|
||||
@@ -870,14 +878,9 @@ describe("signed-in views", () => {
|
||||
body: { password: "member-pass-2" },
|
||||
});
|
||||
assert.equal(steal.status, 404);
|
||||
const wrong = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: miaCookie,
|
||||
body: { password: "nope" },
|
||||
});
|
||||
assert.equal(wrong.status, 403);
|
||||
const ok = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: miaCookie,
|
||||
body: { password: "member-pass-1" },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.ok(ok.body.device.revokedAt);
|
||||
@@ -892,7 +895,7 @@ describe("signed-in views", () => {
|
||||
assert.ok(list.body.devices.every((x) => !("token_hash" in x)));
|
||||
const r = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: boss,
|
||||
body: { password: "admin-pass-1" },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const a = db
|
||||
@@ -939,3 +942,160 @@ describe("signed-in views", () => {
|
||||
assert.ok(r.body.sessions.total >= 2, JSON.stringify(r.body.sessions));
|
||||
});
|
||||
});
|
||||
|
||||
describe("privacy: the admin's switch, no address kept, stored details cleaned", () => {
|
||||
it("keeps no IP of a device and lists none", async () => {
|
||||
const r = await enroll("mia", "member-pass-1", "");
|
||||
assert.equal(r.body.device.name, "device", "no name given → no machine name");
|
||||
await call("GET", "/api/remote/device/me", { token: r.body.token });
|
||||
const row = db.prepare("SELECT last_ip, last_seen_at FROM devices WHERE id = ?").get(r.body.device.id);
|
||||
assert.equal(row.last_ip, null);
|
||||
assert.ok(row.last_seen_at, "when it was seen is still kept");
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const list = await call("GET", "/api/remote/devices", { cookie: miaCookie });
|
||||
assert.ok(list.body.devices.every((d) => d.lastIp === null));
|
||||
});
|
||||
|
||||
it("only an admin flips the switch (no password again); off refuses login, routes and gateway", async () => {
|
||||
const token = (await enroll("mia", "member-pass-1")).body.token;
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", { token, body: { sessionId: id, cwd: "/m" } });
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
|
||||
assert.equal((await call("GET", "/api/remote/policy", { cookie: miaCookie })).body.allowDevices, true);
|
||||
const notAdmin = await call("PUT", "/api/remote/policy", {
|
||||
cookie: miaCookie,
|
||||
body: { allowDevices: false },
|
||||
});
|
||||
assert.equal(notAdmin.status, 403);
|
||||
const bad = await call("PUT", "/api/remote/policy", {
|
||||
cookie: boss,
|
||||
body: { allowDevices: "no" },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const off = await call("PUT", "/api/remote/policy", {
|
||||
cookie: boss,
|
||||
body: { allowDevices: false },
|
||||
});
|
||||
assert.equal(off.status, 200);
|
||||
assert.equal(off.body.allowDevices, false);
|
||||
try {
|
||||
const login2 = await enroll("mia", "member-pass-1");
|
||||
assert.equal(login2.status, 403);
|
||||
assert.equal(login2.body.error.code, "EREMOTEOFF");
|
||||
const me = await call("GET", "/api/remote/device/me", { token });
|
||||
assert.equal(me.status, 403);
|
||||
seen = [];
|
||||
const gw = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: { model: "m", messages: [] },
|
||||
});
|
||||
assert.equal(gw.status, 403);
|
||||
assert.equal(gw.body.error.code, "EREMOTEOFF");
|
||||
assert.equal(seen.length, 0, "nothing relayed upstream");
|
||||
// The signed-in views still work while it is off.
|
||||
assert.equal((await call("GET", "/api/remote/devices", { cookie: miaCookie })).status, 200);
|
||||
const audit = db
|
||||
.prepare("SELECT path FROM audit_log WHERE path = 'remote devices turned off'")
|
||||
.get();
|
||||
assert.ok(audit, "the flip is audited");
|
||||
} finally {
|
||||
await call("PUT", "/api/remote/policy", {
|
||||
cookie: boss,
|
||||
body: { allowDevices: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("is off when nothing was ever chosen", () => {
|
||||
const policy = require("../hub/remote-policy");
|
||||
const file = path.join(TMP, "data", "remote-hub.json");
|
||||
const saved = fs.readFileSync(file, "utf8");
|
||||
try {
|
||||
fs.rmSync(file);
|
||||
policy.__reset();
|
||||
assert.equal(policy.devicesAllowed(), false);
|
||||
} finally {
|
||||
fs.writeFileSync(file, saved);
|
||||
policy.__reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans, once, the machine details an older client already sent", async () => {
|
||||
const token = (await enroll("tom", "member-pass-2", "tom-ThinkPad-X1")).body.token;
|
||||
const id = sid();
|
||||
const cwd = "/home/tom/Documents/Clients/acme/api";
|
||||
// As an older hub stored it: the whole path, and the device's address.
|
||||
await call("POST", "/api/remote/device/sessions", { token, body: { sessionId: id, cwd } });
|
||||
db.prepare("UPDATE remote_sessions SET cwd = ? WHERE session_id = ?").run(cwd, id);
|
||||
const dev = db.prepare("SELECT device_id FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
db.prepare("UPDATE devices SET last_ip = '10.1.2.3' WHERE id = ?").run(dev.device_id);
|
||||
const ingest = require("../hub/remote-ingest");
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
const t =
|
||||
transcript(id, cwd, { text: `see ${cwd}/src/a.js and /home/tom/.ssh/config on tom-ThinkPad-X1` }) +
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: id,
|
||||
timestamp: "2026-11-04T01:00:02.000Z",
|
||||
cwd,
|
||||
message: {
|
||||
content: "memory at ~/.claude/projects/-home-tom-Documents-Clients-acme-api/m.md\n" +
|
||||
"drwxr-xr-x 2 tom tom 4096 Nov 6 a",
|
||||
},
|
||||
}) +
|
||||
"\n";
|
||||
const dir = ingest.projectDir(row);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `${id}.jsonl`), t);
|
||||
const snap = path.join(TMP, "data", "transcripts", `${id}.jsonl`);
|
||||
fs.mkdirSync(path.dirname(snap), { recursive: true });
|
||||
fs.writeFileSync(snap, t);
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO sessions (id, cwd, transcript_path, started_at, status) VALUES (?, ?, ?, '2026-11-04T01:00:00.000Z', 'completed')"
|
||||
).run(id, cwd, path.join(dir, `${id}.jsonl`));
|
||||
db.prepare(
|
||||
"INSERT INTO events (session_id, event_type, data, created_at) VALUES (?, 'PreToolUse', ?, '2026-11-04T01:00:00.000Z')"
|
||||
).run(id, JSON.stringify({ tool_input: { file_path: `${cwd}/src/a.js` } }));
|
||||
const tokensBefore = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ?")
|
||||
.get(id).n;
|
||||
|
||||
const marker = path.join(TMP, "data", "remote", ".private-v1");
|
||||
fs.rmSync(marker, { force: true });
|
||||
const privacy = require("../hub/remote-privacy");
|
||||
const done = privacy.scrubStoredOnce();
|
||||
assert.ok(done && done.sessions >= 1, JSON.stringify(done));
|
||||
assert.equal(privacy.scrubStoredOnce(), null, "runs once");
|
||||
|
||||
const after = db.prepare("SELECT cwd FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(after.cwd, "api");
|
||||
const d = db.prepare("SELECT name, last_ip FROM devices WHERE id = ?").get(dev.device_id);
|
||||
assert.deepEqual(d, { name: "device", last_ip: null });
|
||||
const s = db.prepare("SELECT cwd, transcript_path FROM sessions WHERE id = ?").get(id);
|
||||
assert.equal(s.cwd, "~/api");
|
||||
assert.ok(fs.existsSync(s.transcript_path), "the session still finds its transcript");
|
||||
assert.ok(!s.transcript_path.includes("home-tom"));
|
||||
const ev = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
|
||||
assert.ok(ev.length && ev.every((e) => !e.data.includes("/home/tom")), JSON.stringify(ev));
|
||||
for (const f of [s.transcript_path, snap]) {
|
||||
const text = fs.readFileSync(f, "utf8");
|
||||
for (const leak of ["/home/tom", "home-tom", "ThinkPad", "Clients", " tom tom "]) {
|
||||
assert.ok(!text.includes(leak), `${path.basename(f)} still holds ${leak}`);
|
||||
}
|
||||
assert.ok(text.includes("~/api/src/a.js") && text.includes("~/.ssh/config"));
|
||||
for (const l of text.trim().split("\n")) JSON.parse(l);
|
||||
}
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = ?").get(id).n,
|
||||
tokensBefore,
|
||||
"token rows untouched"
|
||||
);
|
||||
const enrolled = db
|
||||
.prepare("SELECT path FROM audit_log WHERE path LIKE 'remote device enrolled:%ThinkPad%'")
|
||||
.get();
|
||||
assert.equal(enrolled, undefined, "the audit entry no longer names the machine");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file server-mode-account-pool.test.js
|
||||
* @description The Claude/Codex/Cursor account pool from the Users page: only an
|
||||
* admin may add or remove an account, each change needs the admin's OWN
|
||||
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||
* admin may add or remove an account (no password again — the admin role is
|
||||
* the gate), the token only goes in — no answer (the add, the
|
||||
* list) ever carries it — the audit log names the account, not its secret, and
|
||||
* in local mode the routes refuse. An admin pins a user to one account
|
||||
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
|
||||
@@ -139,14 +139,13 @@ describe("the account pool from the Users page", () => {
|
||||
assert.equal(cursorPool.__read("cu-1"), null);
|
||||
});
|
||||
|
||||
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||
it("refuses a bad token, a taken name and an unknown tool", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = (body) =>
|
||||
call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-2", token: TOKEN, currentPassword: BOSS_PASS, ...body },
|
||||
});
|
||||
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||
assert.equal((await add({ tool: "antigravity" })).body.error.code, "EBADTOOL");
|
||||
@@ -206,7 +205,7 @@ describe("pinning a user to one account", () => {
|
||||
claudePool.removeAccount("team-z");
|
||||
});
|
||||
|
||||
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
|
||||
it("refuses an unknown account or user, and a non-admin", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||
assert.equal(
|
||||
@@ -214,10 +213,6 @@ describe("pinning a user to one account", () => {
|
||||
"ENOACCOUNT"
|
||||
);
|
||||
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
|
||||
assert.equal(
|
||||
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
|
||||
"EREAUTH"
|
||||
);
|
||||
const ana = await login("ana", "analyst-pass-1");
|
||||
const w = await call("PUT", "/api/accounts/pins", {
|
||||
cookie: ana,
|
||||
@@ -257,7 +252,6 @@ describe("pinning a user to one account", () => {
|
||||
assert.equal((await edit("team-y", [9999])).body.error.code, "ENOUSER");
|
||||
assert.equal((await edit("team-y", "x")).body.error.code, "EBADUSER");
|
||||
assert.equal((await edit("nope", [])).body.error.code, "ENOACCOUNT");
|
||||
assert.equal((await edit("team-y", [], { currentPassword: "x" })).body.error.code, "EREAUTH");
|
||||
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y", "a refused edit changes nothing");
|
||||
await edit("team-y", []);
|
||||
claudePool.removeAccount("team-y");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @file server-mode-user-admin.test.js
|
||||
* @description Managing accounts from the dashboard (Users page): only an
|
||||
* admin may create a user, change a role, disable/enable, reset a password or
|
||||
* give a workspace, every change needs the admin's OWN password again
|
||||
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
|
||||
* answer carries a password hash, each change lands in the audit log, and in
|
||||
* give a workspace (admin role only — no password again; an account's own
|
||||
* password change still confirms the old one, throttled), an admin can never
|
||||
* demote or disable themselves, no answer carries a password hash, each change lands in the audit log, and in
|
||||
* local mode — no login at all — the routes refuse.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -69,7 +69,7 @@ after(() => {
|
||||
});
|
||||
|
||||
describe("managing accounts from the dashboard", () => {
|
||||
it("lets an admin create a user, with their own password again", async () => {
|
||||
it("lets an admin create a user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
@@ -108,17 +108,19 @@ describe("managing accounts from the dashboard", () => {
|
||||
assert.equal(short.status, 201);
|
||||
});
|
||||
|
||||
it("refuses without the admin's password, or with a wrong one", async () => {
|
||||
it("needs no admin password again; a stale currentPassword is ignored", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const currentPassword of [undefined, "not-the-password"]) {
|
||||
for (const [username, currentPassword] of [
|
||||
["kai", undefined],
|
||||
["kim", "not-the-password"],
|
||||
]) {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword },
|
||||
body: { username, password: "kai-pass-123", currentPassword },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREAUTH");
|
||||
assert.equal(r.status, 201);
|
||||
assert.ok(auth.findUser(username));
|
||||
}
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
|
||||
@@ -234,18 +236,18 @@ describe("managing accounts from the dashboard", () => {
|
||||
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
|
||||
});
|
||||
|
||||
it("throttles wrong confirmation passwords", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
it("throttles wrong current passwords on an account's own password change", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
let last;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
last = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: "guess-" + i },
|
||||
last = await call("POST", "/api/me/password", {
|
||||
cookie: mia,
|
||||
body: { currentPassword: "guess-" + i, newPassword: "Mia-New-Pass-2026" },
|
||||
});
|
||||
}
|
||||
assert.equal(last.status, 429);
|
||||
assert.equal(last.body.error.code, "ETOOMANY");
|
||||
assert.equal(auth.findUser("mia").role, "developer");
|
||||
assert.ok(await login("mia", "member-pass-1"), "the password did not change");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Tests for deleting a user (`DELETE /api/users/:id`, auth.deleteUser):
|
||||
* the admin's own password again, never yourself (ESELF) or the last admin
|
||||
* an admin's only (no password again), never yourself (ESELF) or the last admin
|
||||
* (ELASTADMIN); the user's logins end, their workspaces' records, prefs and
|
||||
* folder go, the audit log keeps the deed — and under S3 the root helper is
|
||||
* asked to `delete` the Linux account and home FIRST, so its refusal leaves
|
||||
@@ -77,14 +77,8 @@ after(() => {
|
||||
});
|
||||
|
||||
describe("Delete user (DELETE /api/users/:id)", () => {
|
||||
it("needs the admin's own password, and never deletes yourself", async () => {
|
||||
it("never deletes yourself", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const wrong = await call("DELETE", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const self = await call("DELETE", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* and Settings → Account (`/api/me/account`, `/api/me/password`,
|
||||
* `/api/me/sign-out-others`): one PATCH validates every field before any is
|
||||
* written, sets display name/email/role/password/workspaces/sign-out together
|
||||
* with the admin's own password, never takes a workspace from a third user; a
|
||||
* (an admin's only, no password again), never takes a workspace from a third user; a
|
||||
* user edits their own profile, changes their own password only with the old
|
||||
* one (keeping the login they changed it from), signs out other devices, and a
|
||||
* pane token may not do any of it.
|
||||
@@ -153,13 +153,14 @@ describe("Edit user (PATCH /api/users/:id)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("needs the admin's own password, and keeps ESELF", async () => {
|
||||
it("needs no admin password again, and keeps ESELF", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||
cookie: boss,
|
||||
body: { displayName: "x" },
|
||||
});
|
||||
assert.equal(noPass.body.error.code, "EREAUTH");
|
||||
assert.equal(noPass.status, 200);
|
||||
assert.equal(noPass.body.user.displayName, "x");
|
||||
for (const body of [{ role: "developer" }, { disabled: true }]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file user-quota.test.js
|
||||
* @description Spend quotas per user (server/lib/user-quota.js): the default
|
||||
* and a user's own setting, spend over a day and a sliding 5 h window, the
|
||||
* admin routes (password again), `/api/me/quota`, the EQUOTA refusal of a new
|
||||
* admin routes (admin role only), `/api/me/quota`, the EQUOTA refusal of a new
|
||||
* pane, the sweep's flag files and the panes it ends, the omp gateway keys it
|
||||
* leaves out, and the hook handler refusing a prompt from the flag alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -173,13 +173,8 @@ describe("spend windows", () => {
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
it("an admin sets the default with their password; the list shows each user's quota", async () => {
|
||||
it("an admin sets the default (no password again); the list shows each user's quota", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const noPass = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" } },
|
||||
});
|
||||
assert.equal(noPass.status, 403);
|
||||
const bad = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: -1, period: "day" }, currentPassword: BOSS_PASS },
|
||||
@@ -188,7 +183,7 @@ describe("routes", () => {
|
||||
assert.equal(bad.body.error.code, "EBADQUOTA");
|
||||
const ok = await call("PUT", "/api/users/quota-default", {
|
||||
cookie: boss,
|
||||
body: { quota: { limit_usd: 10, period: "day" }, currentPassword: BOSS_PASS },
|
||||
body: { quota: { limit_usd: 10, period: "day" } },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.quotaDefault.limit_usd, 10);
|
||||
|
||||
@@ -55,7 +55,8 @@ function shape(row) {
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
lastIp: row.last_ip,
|
||||
// Never stored any more (remote-policy.js); kept in the shape for old clients.
|
||||
lastIp: null,
|
||||
revokedAt: row.revoked_at,
|
||||
active: !row.revoked_at && Date.parse(row.expires_at) > now,
|
||||
};
|
||||
@@ -105,14 +106,11 @@ function deviceFromRequest(req) {
|
||||
).get(sha256(token));
|
||||
if (!row || row.revoked_at || row.disabled) return null;
|
||||
if (!(Date.parse(row.expires_at) > Date.now())) return null;
|
||||
// When it was last seen — but never from where: the hub keeps no address
|
||||
// of a user's own machine.
|
||||
const seen = row.last_seen_at ? Date.parse(row.last_seen_at) : 0;
|
||||
const ip = req.socket?.remoteAddress || null;
|
||||
if (Date.now() - seen > SEEN_EVERY_MS || ip !== row.last_ip) {
|
||||
sql("UPDATE devices SET last_seen_at = ?, last_ip = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
ip,
|
||||
row.id
|
||||
);
|
||||
if (Date.now() - seen > SEEN_EVERY_MS) {
|
||||
sql("UPDATE devices SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), row.id);
|
||||
}
|
||||
return {
|
||||
device: shape(row),
|
||||
|
||||
+49
-3
@@ -12,7 +12,10 @@
|
||||
* (`/api/remote/device/*`, their own JSON parser with room for an upload).
|
||||
* - `mountRoutes(app)` — with the other routers, behind authGuard/scopeGuard:
|
||||
* the signed-in views (devices, remote sessions, "who opened my sessions").
|
||||
* - `startTimers()` — the retention sweep.
|
||||
* - `startTimers()` — the retention sweep, and once per hub the clean-up of
|
||||
* machine details older clients sent (remote-privacy.js).
|
||||
* Laptops connect only while the admin's switch is on (`remote-policy.js`,
|
||||
* off by default): otherwise every device route answers 403 EREMOTEOFF.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,6 +25,7 @@ const devices = require("./devices");
|
||||
const sessions = require("./remote-sessions");
|
||||
const ingest = require("./remote-ingest");
|
||||
const { remoteGateway } = require("./remote-gateway");
|
||||
const policy = require("./remote-policy");
|
||||
|
||||
/** An upload chunk is at most 4 MB of text; JSON escaping can grow it. */
|
||||
const UPLOAD_LIMIT = "12mb";
|
||||
@@ -38,6 +42,12 @@ const send = (res, e) => {
|
||||
});
|
||||
};
|
||||
|
||||
/** 403 EREMOTEOFF while the admin's switch keeps laptops out. */
|
||||
function requireAllowed(_req, res, next) {
|
||||
if (policy.devicesAllowed()) return next();
|
||||
res.status(403).json({ error: { code: "EREMOTEOFF", message: policy.OFF_MESSAGE } });
|
||||
}
|
||||
|
||||
/** Device-token middleware: sets `req.device` / `req.deviceUser`, else 401. */
|
||||
function requireDevice(req, res, next) {
|
||||
const who = devices.deviceFromRequest(req);
|
||||
@@ -53,6 +63,7 @@ function requireDevice(req, res, next) {
|
||||
|
||||
function deviceRouter() {
|
||||
const r = express.Router();
|
||||
r.use(requireAllowed);
|
||||
r.use(express.json({ limit: UPLOAD_LIMIT }));
|
||||
|
||||
// Enroll this machine: the user's own hub password, throttled like a login.
|
||||
@@ -145,11 +156,38 @@ function signedInRouter() {
|
||||
req.user ? next() : res.status(401).json({ error: { code: "EAUTH", message: "log in first" } })
|
||||
);
|
||||
|
||||
// The admin's switch: may laptops connect at all (remote-policy.js)?
|
||||
r.get("/policy", (_req, res) => {
|
||||
res.json(policy.state());
|
||||
});
|
||||
r.put("/policy", async (req, res) => {
|
||||
try {
|
||||
if (req.user.role !== "admin") {
|
||||
return res.status(403).json({ error: { code: "EADMIN", message: "admins only" } });
|
||||
}
|
||||
if (typeof req.body?.allowDevices !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "allowDevices must be true or false" } });
|
||||
}
|
||||
const next = policy.setAllowDevices(req.body.allowDevices);
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
req.user.id,
|
||||
"PUT",
|
||||
`remote devices ${next.allowDevices ? "allowed" : "turned off"}`
|
||||
);
|
||||
res.json(next);
|
||||
} catch (e) {
|
||||
send(res, e.code === "EREAUTH" ? Object.assign(e, { status: 403 }) : e);
|
||||
}
|
||||
});
|
||||
|
||||
r.get("/devices", (req, res) => {
|
||||
res.json({ devices: devices.listDevices(targetUser(req)) });
|
||||
});
|
||||
|
||||
// Revoke a device: its owner or an admin, with their own password again.
|
||||
// Revoke a device: its owner or an admin (audited).
|
||||
r.post("/devices/:id/revoke", async (req, res) => {
|
||||
try {
|
||||
const d = devices.getDevice(req.params.id);
|
||||
@@ -157,7 +195,6 @@ function signedInRouter() {
|
||||
if (!d || (!mine && req.user.role !== "admin")) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such device" } });
|
||||
}
|
||||
await require("../lib/auth").confirmPassword(req.user, req.body?.password);
|
||||
devices.revokeDevice(d.id);
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
@@ -216,6 +253,15 @@ function startTimers() {
|
||||
};
|
||||
setTimeout(run, 30_000).unref();
|
||||
timer = setInterval(run, every);
|
||||
// Once per hub: forget the machine details older clients sent (remote-privacy.js).
|
||||
setImmediate(() => {
|
||||
try {
|
||||
const done = require("./remote-privacy").scrubStoredOnce();
|
||||
if (done) console.log(`[remote] stored client details cleaned: ${JSON.stringify(done)}`);
|
||||
} catch (e) {
|
||||
console.warn(`[remote] clean-up of stored client details failed: ${e.message}`);
|
||||
}
|
||||
});
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,8 @@ function decoderFor(encoding) {
|
||||
|
||||
/** Express handler, mounted at `/api/remote/gw` BEFORE the body parsers. */
|
||||
async function remoteGateway(req, res) {
|
||||
const policy = require("./remote-policy");
|
||||
if (!policy.devicesAllowed()) return fail(res, 403, "EREMOTEOFF", policy.OFF_MESSAGE);
|
||||
const who = devices.deviceFromRequest(req);
|
||||
if (!who) return fail(res, 401, "EAUTH", "not a valid AgentHub device token");
|
||||
const m = /^(\/v1\/[^?]*)(\?.*)?$/.exec(req.url);
|
||||
|
||||
@@ -295,6 +295,7 @@ module.exports = {
|
||||
moveSession,
|
||||
purgeOld,
|
||||
sessionRoot,
|
||||
projectDir,
|
||||
__pending: () => [...pending],
|
||||
__fileFor: fileFor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file server/hub/remote-policy.js
|
||||
* @description The admin's switch for remote clients (laptops running
|
||||
* AgentHub local and enrolled with this hub): whether they may connect at
|
||||
* all. OFF by default — the company's sessions then run only on the hub, and
|
||||
* nothing of a user's own machine (its name, folders, OS, device id) reaches
|
||||
* the hub or, through its gateway, Anthropic. While off, device login, every
|
||||
* device-token route and the gateway answer 403 EREMOTEOFF; the signed-in
|
||||
* views (devices, sessions, revoke) keep working. Kept in
|
||||
* `<data dir>/remote-hub.json`; a missing or unreadable file means off.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("../lib/claude-home");
|
||||
|
||||
const FILE = "remote-hub.json";
|
||||
/** Re-read at most this often: the gateway asks on every relayed request. */
|
||||
const TTL_MS = 2000;
|
||||
|
||||
const file = () => path.join(getDataDir(), FILE);
|
||||
|
||||
let cache = null;
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(file(), "utf8"));
|
||||
return { allowDevices: j?.allowDevices === true, changedAt: j?.changedAt || null };
|
||||
} catch {
|
||||
return { allowDevices: false, changedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** The switch as it stands: `{allowDevices, changedAt}`. */
|
||||
function state() {
|
||||
if (!cache || Date.now() - cache.at > TTL_MS) cache = { at: Date.now(), value: read() };
|
||||
return { ...cache.value };
|
||||
}
|
||||
|
||||
const devicesAllowed = () => state().allowDevices;
|
||||
|
||||
/** Turn remote clients on or off (atomic write, 0600). */
|
||||
function setAllowDevices(on) {
|
||||
const next = { allowDevices: on === true, changedAt: new Date().toISOString() };
|
||||
fs.mkdirSync(path.dirname(file()), { recursive: true });
|
||||
const tmp = `${file()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
||||
fs.renameSync(tmp, file());
|
||||
cache = { at: Date.now(), value: next };
|
||||
return { ...next };
|
||||
}
|
||||
|
||||
const OFF_MESSAGE =
|
||||
"the hub's admin has turned off laptop connections; company accounts are used only on the hub";
|
||||
|
||||
module.exports = {
|
||||
FILE,
|
||||
OFF_MESSAGE,
|
||||
state,
|
||||
devicesAllowed,
|
||||
setAllowDevices,
|
||||
__reset: () => (cache = null),
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @file server/hub/remote-privacy.js
|
||||
* @description One-time clean-up of what the hub stored from remote clients
|
||||
* BEFORE they stopped sending their machine's details (path-privacy.js):
|
||||
* device names (the machines' host names) become `device` and their last IP
|
||||
* is forgotten; each remote session's folder is cut to its last segment; and
|
||||
* in its transcripts — the upload copy under `<data dir>/remote/…` and the
|
||||
* importer's snapshot under `<data dir>/transcripts/` — and in its database
|
||||
* rows (every text column of every table keyed by the session), the folders
|
||||
* above the project and the user's home become `~` and the host names
|
||||
* `device`. Token and cost numbers are not touched. The audit entries that
|
||||
* named a device keep their wording with the name replaced.
|
||||
*
|
||||
* Runs once per hub (marker `<data dir>/remote/.private-v1`), session by
|
||||
* session, each file rewritten through a temp file and renamed, so a run cut
|
||||
* short is simply finished by the next start (a done session's folder is
|
||||
* already its label and is skipped).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getDataDir } = require("../lib/claude-home");
|
||||
const privacy = require("../lib/path-privacy");
|
||||
|
||||
const MARKER = ".private-v1";
|
||||
const DEVICE = "device";
|
||||
|
||||
const markerPath = () => path.join(getDataDir(), "remote", MARKER);
|
||||
|
||||
/** Every regular file under `dir` (no links followed). */
|
||||
function filesUnder(dir) {
|
||||
const out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const abs = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...filesUnder(abs));
|
||||
else if (e.isFile()) out.push(abs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rewrite a JSONL file line by line; true when it changed. */
|
||||
function scrubFile(abs, rules) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const next = text
|
||||
.split("\n")
|
||||
.map((l) => (l.trim() ? privacy.scrubLine(l, rules) : l))
|
||||
.join("\n");
|
||||
if (next === text) return false;
|
||||
const tmp = `${abs}.${process.pid}.scrub`;
|
||||
const mode = fs.statSync(abs).mode & 0o777;
|
||||
fs.writeFileSync(tmp, next, { mode });
|
||||
fs.renameSync(tmp, abs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Text columns of every table that has a `session_id` (and `sessions` itself). */
|
||||
function sessionColumns(db) {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
const out = [];
|
||||
for (const t of tables) {
|
||||
const cols = db.prepare(`PRAGMA table_info("${t.replace(/"/g, '""')}")`).all();
|
||||
const key =
|
||||
t === "sessions" ? "id" : cols.some((c) => c.name === "session_id") ? "session_id" : null;
|
||||
if (!key || t === "remote_sessions" || t === "remote_files") continue;
|
||||
const text = cols
|
||||
// transcript_path is the HUB's own file (moved below, never scrubbed:
|
||||
// a hub account named like the laptop's would otherwise be rewritten).
|
||||
.filter(
|
||||
(c) =>
|
||||
!/INT|REAL|NUM|BLOB/i.test(c.type || "") && c.name !== key && c.name !== "transcript_path"
|
||||
)
|
||||
.map((c) => c.name);
|
||||
if (text.length) out.push({ table: t, key, text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Scrub the rows of one session in every table; returns rows changed. */
|
||||
function scrubRows(db, columns, sessionId, needles, rules) {
|
||||
let changed = 0;
|
||||
for (const { table, key, text } of columns) {
|
||||
const q = (s) => `"${s.replace(/"/g, '""')}"`;
|
||||
const like = text
|
||||
.map((c) => needles.map(() => `CAST(${q(c)} AS TEXT) LIKE ?`).join(" OR "))
|
||||
.join(" OR ");
|
||||
const args = text.flatMap(() => needles.map((n) => `%${n}%`));
|
||||
let rows;
|
||||
try {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT rowid AS _rid, ${text.map(q).join(", ")} FROM ${q(table)} WHERE ${q(key)} = ? AND (${like})`
|
||||
)
|
||||
.all(sessionId, ...args);
|
||||
} catch {
|
||||
continue; // a WITHOUT ROWID table: none hold paths today
|
||||
}
|
||||
for (const row of rows) {
|
||||
const set = [];
|
||||
const vals = [];
|
||||
for (const c of text) {
|
||||
if (typeof row[c] !== "string") continue;
|
||||
const next = privacy.scrubText(row[c], rules);
|
||||
if (next !== row[c]) {
|
||||
set.push(`${q(c)} = ?`);
|
||||
vals.push(next);
|
||||
}
|
||||
}
|
||||
if (set.length) {
|
||||
db.prepare(`UPDATE ${q(table)} SET ${set.join(", ")} WHERE rowid = ?`).run(
|
||||
...vals,
|
||||
row._rid
|
||||
);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean what the hub holds of remote clients, once. Returns a summary, or
|
||||
* null when it already ran.
|
||||
*/
|
||||
function scrubStoredOnce({ force = false } = {}) {
|
||||
if (!force && fs.existsSync(markerPath())) return null;
|
||||
const { db } = require("../db");
|
||||
const ingest = require("./remote-ingest");
|
||||
const summary = { devices: 0, sessions: 0, files: 0, rows: 0 };
|
||||
|
||||
const devices = db.prepare("SELECT id, name FROM devices").all();
|
||||
const hostnames = devices.map((d) => d.name).filter((n) => n && n !== DEVICE);
|
||||
db.transaction(() => {
|
||||
for (const d of devices) {
|
||||
if (d.name !== DEVICE) {
|
||||
db.prepare(
|
||||
"UPDATE audit_log SET path = replace(path, ?, ?) WHERE path LIKE 'remote device %'"
|
||||
).run(d.name, DEVICE);
|
||||
summary.devices++;
|
||||
}
|
||||
}
|
||||
db.prepare("UPDATE devices SET name = ?, last_ip = NULL").run(DEVICE);
|
||||
})();
|
||||
|
||||
const columns = sessionColumns(db);
|
||||
const snapshots = path.join(getDataDir(), "transcripts");
|
||||
for (const row of db.prepare("SELECT * FROM remote_sessions").all()) {
|
||||
const label = privacy.folderLabel(row.cwd);
|
||||
const rules = privacy.rulesFor({ cwd: row.cwd, hostnames });
|
||||
const home = privacy.homeOf(row.cwd);
|
||||
const parent = privacy.parentOf(row.cwd);
|
||||
// What picks the rows worth reading; the rules decide what changes.
|
||||
const needles = [
|
||||
parent,
|
||||
home,
|
||||
parent && privacy.claudeSlug(parent),
|
||||
home && privacy.claudeSlug(home),
|
||||
home && home.split(/[\\/]/).pop(),
|
||||
...hostnames,
|
||||
].filter((n) => n && n.length >= 3);
|
||||
if (!label || label === row.cwd || !rules.length) continue;
|
||||
|
||||
// Files first: the upload copy (then its folder renamed to the label's)
|
||||
// and the importer's snapshot of it.
|
||||
const oldDir = ingest.projectDir(row);
|
||||
const newDir = ingest.projectDir({ ...row, cwd: label });
|
||||
for (const f of filesUnder(oldDir)) if (scrubFile(f, rules)) summary.files++;
|
||||
const sid = row.session_id;
|
||||
for (const f of [
|
||||
path.join(snapshots, `${sid}.jsonl`),
|
||||
...filesUnder(path.join(snapshots, sid)),
|
||||
]) {
|
||||
if (fs.existsSync(f) && scrubFile(f, rules)) summary.files++;
|
||||
}
|
||||
if (oldDir !== newDir && fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
|
||||
fs.renameSync(oldDir, newDir);
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
// The project folder's segment only (`/<old slug>/` → `/<label slug>/`),
|
||||
// whatever data dir the stored path was written under.
|
||||
const seg = (d) => `${path.sep}${path.basename(d)}${path.sep}`;
|
||||
db.prepare(
|
||||
"UPDATE sessions SET transcript_path = replace(transcript_path, ?, ?) WHERE id = ?"
|
||||
).run(seg(oldDir), seg(newDir), sid);
|
||||
summary.rows += scrubRows(db, columns, sid, needles, rules);
|
||||
db.prepare("UPDATE remote_sessions SET cwd = ? WHERE session_id = ?").run(label, sid);
|
||||
})();
|
||||
summary.sessions++;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(markerPath()), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
markerPath(),
|
||||
JSON.stringify({ at: new Date().toISOString(), ...summary }) + "\n",
|
||||
{
|
||||
mode: 0o600,
|
||||
}
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = { MARKER, scrubStoredOnce, __sessionColumns: sessionColumns };
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description The company-account sessions remote clients registered with the
|
||||
* hub (server mode only). A client registers a session ONCE, when it starts it
|
||||
* on the "Company account" (`POST /api/remote/device/sessions`), with its id,
|
||||
* folder and tool — no content. The gateway (remote-gateway.js) relays a
|
||||
* folder's last segment and tool — no content, no path. The gateway (remote-gateway.js) relays a
|
||||
* request only for a session THAT device registered; everything a session
|
||||
* says arrives later in the hourly upload (remote-ingest.js).
|
||||
*
|
||||
@@ -16,20 +16,24 @@
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { folderLabel } = require("../lib/path-privacy");
|
||||
const err = (code, message, status) => Object.assign(new Error(message), { code, status });
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TOOLS = new Set(["claude"]);
|
||||
const MAX_CWD = 1024;
|
||||
|
||||
/** A client-sent folder, as text only: absolute-looking, printable, capped. */
|
||||
/**
|
||||
* A client-sent folder reduced to its last segment (the project's name): the
|
||||
* hub never keeps the path above it, which names the user's account and
|
||||
* folder layout. An older client still sends the whole path; it is cut here.
|
||||
*/
|
||||
function cleanCwd(cwd) {
|
||||
const s = String(cwd || "").replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
const s = String(cwd || "");
|
||||
if (!s || s.length > MAX_CWD) throw err("EBADCWD", "cwd must be a folder path", 400);
|
||||
if (!s.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(s)) {
|
||||
throw err("EBADCWD", "cwd must be absolute", 400);
|
||||
}
|
||||
return s;
|
||||
const label = folderLabel(s);
|
||||
if (!label) throw err("EBADCWD", "cwd must name a folder", 400);
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-2
@@ -17,8 +17,9 @@
|
||||
* all AI tools and Update 9router); an `analyst` and a `developer` write
|
||||
* only their own rows (`scope.js`).
|
||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||
* admin on the Users page (`server/routes/users.js`; admin role only, no
|
||||
* password again). `confirmPassword` (throttled) remains for an account's
|
||||
* own password change.
|
||||
* An account also carries an optional display name and email
|
||||
* (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
|
||||
* ENAMETAKEN), which its owner edits in Settings → Account without
|
||||
|
||||
+19
-4
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Scheduled database backups (Settings → Data → Backup). Once a day, at
|
||||
* a chosen local hour of the server, the dashboard's SQLite database is copied
|
||||
* @file Scheduled database backups (Settings → Data → Backup). Every
|
||||
* `every` days (1 = daily, 7 = weekly), at a chosen local hour of the server, the dashboard's SQLite database is copied
|
||||
* into a folder the admin picked: `VACUUM INTO` on a READ-ONLY connection in a
|
||||
* worker thread — a consistent snapshot that writes from other connections
|
||||
* never restart, compacted (1.25 GB → ~0.7 GB), while the event loop keeps
|
||||
@@ -10,7 +10,8 @@
|
||||
* first beyond `keep` — nothing else in the folder, never through a symlink.
|
||||
* Settings live in the settings file: AGENTHUB_BACKUP_DIR (empty = off),
|
||||
* AGENTHUB_BACKUP_HOUR (0–23, default 2), AGENTHUB_BACKUP_KEEP (1–365, default
|
||||
* 7). A missed day (server down at the hour) runs at the next start; a failed
|
||||
* 7), AGENTHUB_BACKUP_EVERY (days between backups, 1–30, default 1: a backup is
|
||||
* due once the newest one in the folder is that many calendar days old). A missed day (server down at the hour) runs at the next start; a failed
|
||||
* one is retried the next day or by "Back up now", never every minute. The
|
||||
* scheduler ticks every DASHBOARD_BACKUP_TICK_MS (default 60 s, 0 = off).
|
||||
* Restoring is manual: stop AgentHub, copy a backup over the database, start.
|
||||
@@ -29,6 +30,7 @@ const KEYS = {
|
||||
dir: "AGENTHUB_BACKUP_DIR",
|
||||
hour: "AGENTHUB_BACKUP_HOUR",
|
||||
keep: "AGENTHUB_BACKUP_KEEP",
|
||||
every: "AGENTHUB_BACKUP_EVERY",
|
||||
};
|
||||
const NAME_RE = /^agenthub-(\d{8})-(\d{6})\.db$/;
|
||||
|
||||
@@ -46,6 +48,7 @@ function backupConfig(env = process.env) {
|
||||
dir: env[KEYS.dir] || "",
|
||||
hour: int(env[KEYS.hour], 0, 23, 2),
|
||||
keep: int(env[KEYS.keep], 1, 365, 7),
|
||||
every: int(env[KEYS.every], 1, 30, 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +108,19 @@ function dueNow(now, cfg, files, attemptedDay) {
|
||||
if (!cfg.dir || now.getHours() < cfg.hour) return false;
|
||||
const today = stamp(now).slice(0, 8);
|
||||
if (attemptedDay === today) return false;
|
||||
return !files.some((f) => NAME_RE.exec(f.name)?.[1] === today);
|
||||
const days = files
|
||||
.map((f) => NAME_RE.exec(f.name)?.[1])
|
||||
.filter((d) => d !== undefined)
|
||||
.sort();
|
||||
const newest = days[days.length - 1];
|
||||
if (!newest) return true;
|
||||
return calendarDays(newest, today) >= (cfg.every ?? 1);
|
||||
}
|
||||
|
||||
/** Whole calendar days from one YYYYMMDD to another (local dates, DST-proof). */
|
||||
function calendarDays(from, to) {
|
||||
const utc = (d) => Date.UTC(+d.slice(0, 4), +d.slice(4, 6) - 1, +d.slice(6, 8));
|
||||
return Math.round((utc(to) - utc(from)) / 864e5);
|
||||
}
|
||||
|
||||
function vacuumInto(dbPath, out) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file path-privacy.js
|
||||
* @description Keeps a user's own machine out of what a remote client sends
|
||||
* the hub (server/lib/remote-client.js) and of what the hub already stored
|
||||
* (server/hub/remote-privacy.js): a session's folder is reduced to its last
|
||||
* segment (the project's name), the folders above it and the user's home
|
||||
* become `~`, and the machine's host name becomes `device`. So
|
||||
* `/home/alice/Documents/Projects/shop/api/src/a.js` is stored as
|
||||
* `~/api/src/a.js` when the session ran in `…/shop/api`, and the hub never
|
||||
* learns the account name, the folder layout or the machine's name. The same
|
||||
* folders are hidden in the form Claude Code names its project folders by
|
||||
* (`-home-alice-Documents-…`), and the account name in `ls -l` owner/group
|
||||
* columns. Pure functions, no I/O.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** A folder's last segment — what the hub keeps of a session's folder. */
|
||||
function folderLabel(cwd) {
|
||||
const parts = String(cwd || "")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean);
|
||||
const last = parts.length ? parts[parts.length - 1] : "";
|
||||
return /^[A-Za-z]:$/.test(last) ? "" : last.slice(0, 200);
|
||||
}
|
||||
|
||||
/** The home folder at the start of `p` (Linux, macOS, Windows, WSL), else "". */
|
||||
function homeOf(p) {
|
||||
const m =
|
||||
/^(\/home\/[^/]+|\/Users\/[^/]+|\/root|\/mnt\/[a-z]\/Users\/[^/]+|[A-Za-z]:[\\/]Users[\\/][^\\/]+)(?=$|[\\/])/i.exec(
|
||||
String(p || "")
|
||||
);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
/** `cwd` without its last segment ("" when that would be a root). */
|
||||
function parentOf(cwd) {
|
||||
const s = String(cwd || "").replace(/[\\/]+$/, "");
|
||||
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
||||
if (i <= 0) return "";
|
||||
const parent = s.slice(0, i);
|
||||
return /^[A-Za-z]:$/.test(parent) ? "" : parent;
|
||||
}
|
||||
|
||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** A folder as Claude Code names its project folder: every non-alnum → "-". */
|
||||
const claudeSlug = (p) => String(p).replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
/**
|
||||
* The replacements for one session: `[RegExp, replacement]`, longest first.
|
||||
* `cwd` — the session's folder; `home` — the machine's home (else read from
|
||||
* `cwd`); `hostnames` — names of the machine to hide (shorter than 4
|
||||
* characters are left alone: they would match ordinary words).
|
||||
*/
|
||||
function rulesFor({ cwd = "", home = "", hostnames = [] } = {}) {
|
||||
const h = home || homeOf(cwd);
|
||||
const pairs = [];
|
||||
const p = parentOf(cwd);
|
||||
// The folders above the project — but never an ancestor of the home
|
||||
// (`/home` for a session run in the home itself): that would hide others.
|
||||
const parent = p && !(h && (h === p || h.startsWith(p + "/") || h.startsWith(p + "\\"))) ? p : "";
|
||||
if (parent) pairs.push([parent, "~"]);
|
||||
if (h) pairs.push([h, "~"]);
|
||||
for (const n of hostnames) {
|
||||
const name = String(n || "").trim();
|
||||
if (name.length >= 4) pairs.push([name, "device"]);
|
||||
}
|
||||
const seen = new Set();
|
||||
const rules = pairs
|
||||
.filter(([needle]) => !seen.has(needle) && seen.add(needle))
|
||||
.sort((a, b) => b[0].length - a[0].length)
|
||||
.map(([needle, rep]) => [
|
||||
// A path ends at a separator, a quote, a space or the end — never in
|
||||
// the middle of a longer name (`/home/al` must not eat `/home/alice`).
|
||||
new RegExp(`${escapeRe(needle)}(?![\\w.\\-])`, "g"),
|
||||
rep,
|
||||
]);
|
||||
// The same folders as Claude Code's project-folder names, which go on
|
||||
// with "-" + the project (`-home-alice-Documents-shop-api`).
|
||||
const slugs = [parent, h].filter((p) => p && p.length > 1).map(claudeSlug);
|
||||
for (const slug of [...new Set(slugs)].sort((a, b) => b.length - a.length)) {
|
||||
rules.push([new RegExp(`${escapeRe(slug)}(?![A-Za-z0-9])`, "g"), "~"]);
|
||||
}
|
||||
// The account name as `ls -l` prints it: owner (and a same-named group).
|
||||
const user = h ? h.split(/[\\/]/).pop() : "";
|
||||
if (user && user.length >= 3) {
|
||||
const u = escapeRe(user);
|
||||
rules.push([
|
||||
new RegExp(`(?<=[-dlcbps][-rwxsStT]{9}[.+@]?\\s+\\d+\\s+)${u}(\\s+)(${u}(?=\\s))?`, "g"),
|
||||
(_m, gap, group) => `user${gap}${group ? "user" : ""}`,
|
||||
]);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** `s` with every rule applied. */
|
||||
function scrubText(s, rules) {
|
||||
if (typeof s !== "string" || !rules.length) return s;
|
||||
let out = s;
|
||||
for (const [re, rep] of rules) out = out.replace(re, rep);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A parsed JSON value with every string scrubbed (keys left alone). */
|
||||
function scrubValue(v, rules) {
|
||||
if (!rules.length) return v;
|
||||
if (typeof v === "string") return scrubText(v, rules);
|
||||
if (Array.isArray(v)) return v.map((x) => scrubValue(x, rules));
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = scrubValue(x, rules);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** One transcript line (JSON, else text) scrubbed. */
|
||||
function scrubLine(line, rules) {
|
||||
if (!rules.length) return line;
|
||||
try {
|
||||
return JSON.stringify(scrubValue(JSON.parse(line), rules));
|
||||
} catch {
|
||||
return scrubText(line, rules);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
folderLabel,
|
||||
homeOf,
|
||||
parentOf,
|
||||
claudeSlug,
|
||||
rulesFor,
|
||||
scrubText,
|
||||
scrubValue,
|
||||
scrubLine,
|
||||
};
|
||||
@@ -754,6 +754,7 @@ function spawnRun(args) {
|
||||
...(entry.launcher === "claude" && !process.env.CLAUDE_CODE_SCROLL_SPEED
|
||||
? { CLAUDE_CODE_SCROLL_SPEED: CLAUDE_SCROLL_SPEED }
|
||||
: {}),
|
||||
...errorReportingEnv(entry.launcher),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
@@ -966,7 +967,24 @@ function listRuns() {
|
||||
return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server mode: a claude pane sends Anthropic no error reports — a crash
|
||||
* report's stack trace can carry a member's paths and Linux account name.
|
||||
* Only DISABLE_ERROR_REPORTING: telemetry and feature flags stay on, because
|
||||
* turning those off (DISABLE_TELEMETRY / CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)
|
||||
* takes away flag-gated features (artifact comments, synced skills and
|
||||
* plugins, the advisor, MCP tasks, pasted-text marking), and telemetry
|
||||
* carries no code, paths or commands. Local mode is untouched; the
|
||||
* dashboard's own environment setting it wins.
|
||||
*/
|
||||
function errorReportingEnv(launcher) {
|
||||
if (launcher !== "claude" || !require("./auth").isServerMode()) return {};
|
||||
if (process.env.DISABLE_ERROR_REPORTING !== undefined) return {};
|
||||
return { DISABLE_ERROR_REPORTING: "1" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorReportingEnv,
|
||||
spawnRun,
|
||||
spawnShell,
|
||||
killRun,
|
||||
|
||||
+34
-10
@@ -16,9 +16,16 @@
|
||||
* - Upload: every `DASHBOARD_REMOTE_UPLOAD_MS` (an hour) and when such a
|
||||
* session ends, the NEW whole lines of its transcript and its sub-agents'
|
||||
* are sent, each line's secret-shaped strings replaced first
|
||||
* (privacy.js `redactValue`). How far each file was sent is kept in
|
||||
* (privacy.js `redactValue`) and this machine's own paths and name hidden
|
||||
* (path-privacy.js: the folders above the project and the home become
|
||||
* `~`, the host name `device`). How far each file was sent is kept in
|
||||
* `remote_uploads`; the transcript files themselves are the queue, so an
|
||||
* offline machine simply sends the rest next time.
|
||||
* - The hub learns nothing of the machine itself: the device is enrolled
|
||||
* under the name the user gives (else none — never the host name), a
|
||||
* session is registered under its folder's last segment only, and a
|
||||
* Company-account pane sends no telemetry, error reports or update checks
|
||||
* of its own (`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -27,6 +34,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const privacy = require("./path-privacy");
|
||||
|
||||
/** = ai-proxies.js `COMPANY_NAME`, the Company account's proxy name. */
|
||||
const HUB_PROXY = "agenthub.company";
|
||||
@@ -153,7 +161,8 @@ async function login({ hub, username, password, deviceName }) {
|
||||
body: {
|
||||
username: String(username),
|
||||
password: String(password),
|
||||
deviceName: String(deviceName || os.hostname()),
|
||||
// Never the host name: the hub's admin would see the machine's name.
|
||||
deviceName: String(deviceName || ""),
|
||||
},
|
||||
});
|
||||
if (!r || typeof r.token !== "string") throw err("EHUB", "the hub gave no device token", 502);
|
||||
@@ -209,7 +218,8 @@ async function registerSession({ sessionId, cwd }) {
|
||||
return hubFetch(s.hub, "/api/remote/device/sessions", {
|
||||
method: "POST",
|
||||
token: s.token,
|
||||
body: { sessionId, cwd, tool: "claude" },
|
||||
// The project's folder name only — never the path above it.
|
||||
body: { sessionId, cwd: privacy.folderLabel(cwd), tool: "claude" },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
@@ -255,21 +265,34 @@ function claudeEnv() {
|
||||
// Through a base URL Claude Code writes 5-minute cache entries; the
|
||||
// subscription's 1-hour ones are kept by asking (verified 2026-10-09).
|
||||
ENABLE_PROMPT_CACHING_1H: "1",
|
||||
// Nothing goes from this machine straight to Anthropic (telemetry, error
|
||||
// reports, update checks — measured 2026-11-09, Claude Code 2.1.319):
|
||||
// only the messages, through the hub.
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One transcript line with its secret-shaped strings replaced. */
|
||||
function redactLine(line) {
|
||||
/**
|
||||
* One transcript line with its secret-shaped strings replaced and, given
|
||||
* `rules` (path-privacy.js `rulesFor`), this machine's paths and name hidden.
|
||||
*/
|
||||
function redactLine(line, rules = []) {
|
||||
const { redactValue, redactText } = require("./privacy");
|
||||
try {
|
||||
return JSON.stringify(redactValue(JSON.parse(line)));
|
||||
return JSON.stringify(privacy.scrubValue(redactValue(JSON.parse(line)), rules));
|
||||
} catch {
|
||||
return redactText(line);
|
||||
return privacy.scrubText(redactText(line), rules);
|
||||
}
|
||||
}
|
||||
|
||||
/** The path/name rules for one session of this machine. */
|
||||
function rulesForSession(sessionId) {
|
||||
const cwd = sql("SELECT cwd FROM sessions WHERE id = ?").get(sessionId)?.cwd || "";
|
||||
return privacy.rulesFor({ cwd, home: os.homedir(), hostnames: [os.hostname()] });
|
||||
}
|
||||
|
||||
const REGISTERED = "#registered";
|
||||
const ENDED = "#ended";
|
||||
|
||||
@@ -353,7 +376,7 @@ function setSent(sid, rel, sent) {
|
||||
* The next chunk of `abs` from byte `from`: whole lines only, at most
|
||||
* CHUNK_BYTES (a single longer line goes alone). Null when nothing complete is new.
|
||||
*/
|
||||
function readChunk(abs, from) {
|
||||
function readChunk(abs, from, rules = []) {
|
||||
let size;
|
||||
try {
|
||||
size = fs.statSync(abs).size;
|
||||
@@ -392,7 +415,7 @@ function readChunk(abs, from) {
|
||||
Buffer.byteLength(l) > MAX_LINE_BYTES
|
||||
? JSON.stringify({ type: "agenthub-omitted", bytes: Buffer.byteLength(l) })
|
||||
: l.trim()
|
||||
? redactLine(l)
|
||||
? redactLine(l, rules)
|
||||
: l
|
||||
)
|
||||
.join("\n") + "\n";
|
||||
@@ -429,6 +452,7 @@ async function uploadSession(s, { sessionId, live }, budget) {
|
||||
return post(body, timeoutMs);
|
||||
}
|
||||
};
|
||||
const rules = rulesForSession(sessionId);
|
||||
try {
|
||||
const files = sessionFiles(sessionId);
|
||||
// Every chunk says `more: true`; the round's closing request (below) tells
|
||||
@@ -438,7 +462,7 @@ async function uploadSession(s, { sessionId, live }, budget) {
|
||||
files: for (const f of files) {
|
||||
for (;;) {
|
||||
if (sent >= budget) break files;
|
||||
const chunk = readChunk(f.abs, sentOf(sessionId, f.rel));
|
||||
const chunk = readChunk(f.abs, sentOf(sessionId, f.rel), rules);
|
||||
if (!chunk) break;
|
||||
try {
|
||||
const r = await post(
|
||||
|
||||
@@ -168,8 +168,8 @@ const err = (description) => ({
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
|
||||
});
|
||||
|
||||
// The Users page's account changes: every body carries the admin's own
|
||||
// password again (`currentPassword`).
|
||||
// The Users page's account changes: an admin's, with no password again
|
||||
// (a `currentPassword` still sent is ignored).
|
||||
const userBody = {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { user: { $ref: "#/components/schemas/AuthUser" } } },
|
||||
@@ -182,8 +182,8 @@ const changeBody = (properties, required = []) => ({
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: [...required, "currentPassword"],
|
||||
properties: { ...properties, currentPassword: { type: "string" } },
|
||||
required,
|
||||
properties,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -191,10 +191,9 @@ const changeBody = (properties, required = []) => ({
|
||||
const changeResponses = {
|
||||
200: { description: "Changed", content: userBody },
|
||||
400: err("EBADPASSWORD / EBADROLE / EBADFIELD"),
|
||||
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
|
||||
403: err("EADMIN / ELOCALMODE"),
|
||||
404: err("ENOUSER / ENOLANE"),
|
||||
409: err("ESELF — your own role or disable"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
};
|
||||
|
||||
const accountBody = {
|
||||
@@ -598,15 +597,14 @@ const paths = {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "createServerUser",
|
||||
summary:
|
||||
"Admin: add an account. Needs the admin's own password again (currentPassword); audited",
|
||||
summary: "Admin: add an account; audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["username", "password", "currentPassword"],
|
||||
required: ["username", "password"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string", minLength: 1 },
|
||||
@@ -615,7 +613,6 @@ const paths = {
|
||||
enum: ["admin", "analyst", "developer"],
|
||||
default: "developer",
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -624,9 +621,8 @@ const paths = {
|
||||
responses: {
|
||||
201: { description: "Created", content: userBody },
|
||||
400: err("EBADUSER / EBADPASSWORD / EBADROLE"),
|
||||
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
|
||||
403: err("EADMIN / ELOCALMODE"),
|
||||
409: err("EEXISTS"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -650,7 +646,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "restartMemberTmux",
|
||||
summary:
|
||||
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)",
|
||||
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, audited)",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -658,15 +654,14 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "{user}" },
|
||||
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
|
||||
403: err("EADMIN / ELOCALMODE"),
|
||||
404: err("ENOUSER"),
|
||||
409: err(
|
||||
"ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3"
|
||||
@@ -679,15 +674,14 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "restartIdleMemberTmux",
|
||||
summary:
|
||||
"Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)",
|
||||
"Restart every member server older than the installed tmux while no agent of theirs works (admin, audited per member)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -697,7 +691,7 @@ const paths = {
|
||||
description:
|
||||
"{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}",
|
||||
},
|
||||
403: err("EREAUTH / EADMIN / ELOCALMODE"),
|
||||
403: err("EADMIN / ELOCALMODE"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -731,7 +725,8 @@ const paths = {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getUserAvatar",
|
||||
summary: "One account's picture, for owner groups and System Health (every signed-in account)",
|
||||
summary:
|
||||
"One account's picture, for owner groups and System Health (every signed-in account)",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
@@ -1020,14 +1015,14 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "addPoolAccount",
|
||||
summary:
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back",
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool. The secret is never answered back",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
required: ["tool"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
name: { type: "string", description: "Defaults to the profile's name" },
|
||||
@@ -1044,7 +1039,6 @@ const paths = {
|
||||
description:
|
||||
"Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key",
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1053,12 +1047,11 @@ const paths = {
|
||||
responses: {
|
||||
201: { description: "`{account: {name, addedAt, …}}` — no token" },
|
||||
400: err("EBADTOOL, EBADNAME, EBADTOKEN"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
409: err(
|
||||
"EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile"
|
||||
),
|
||||
502: err("EUPSTREAM — Anthropic could not be reached"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1067,19 +1060,18 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "pinPoolAccount",
|
||||
summary:
|
||||
"Admin: switch one user's sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart",
|
||||
"Admin: switch one user's sessions of a tool to one pool account (`account: null` = automatic). A Claude login is written into their home at once; running panes take it without a restart",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool", "userId", "currentPassword"],
|
||||
required: ["tool", "userId"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userId: { type: "integer" },
|
||||
account: { type: "string", nullable: true },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1091,9 +1083,8 @@ const paths = {
|
||||
"`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null",
|
||||
},
|
||||
400: err("EBADTOOL"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOUSER, ENOACCOUNT"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1112,7 +1103,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "setPoolAccountUsers",
|
||||
summary:
|
||||
"Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user's home at once",
|
||||
"Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer). A Claude login is written into each added user's home at once",
|
||||
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1120,11 +1111,10 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool", "userIds", "currentPassword"],
|
||||
required: ["tool", "userIds"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1136,9 +1126,8 @@ const paths = {
|
||||
"`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session",
|
||||
},
|
||||
400: err("EBADTOOL, EBADUSER"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOUSER, ENOACCOUNT"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1146,15 +1135,15 @@ const paths = {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "createPoolGroup",
|
||||
summary: "Admin: a new, empty pool group, with their own password again",
|
||||
summary: "Admin: a new, empty pool group",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "currentPassword"],
|
||||
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1162,9 +1151,8 @@ const paths = {
|
||||
responses: {
|
||||
201: { description: "`{group: {id, name}}`" },
|
||||
400: err("EBADNAME"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1173,7 +1161,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "updatePoolGroup",
|
||||
summary:
|
||||
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
|
||||
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1181,7 +1169,6 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
@@ -1190,7 +1177,6 @@ const paths = {
|
||||
description: "`{tool: [account names]}` for claude, codex, cursor",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1209,17 +1195,16 @@ const paths = {
|
||||
},
|
||||
},
|
||||
400: err("EBADNAME, EBADUSER, EBADTOOL"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
operationId: "deletePoolGroup",
|
||||
summary:
|
||||
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
|
||||
"Admin: delete a pool group — its members go back to the shared pool, no account is removed",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1227,17 +1212,15 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{ok: true}`" },
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1245,7 +1228,7 @@ const paths = {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
operationId: "removePoolAccount",
|
||||
summary: "Admin: remove an account from the pool, with their own password again",
|
||||
summary: "Admin: remove an account from the pool",
|
||||
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1253,10 +1236,9 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
required: ["tool"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1264,9 +1246,8 @@ const paths = {
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{ok: true}`" },
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
403: err("EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOACCOUNT"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1138,6 +1138,12 @@ const paths = {
|
||||
dir: { type: "string", description: "Backup folder; empty = backups off" },
|
||||
hour: { type: "integer", minimum: 0, maximum: 23, description: "Server local hour" },
|
||||
keep: { type: "integer", minimum: 1, maximum: 365 },
|
||||
every: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 30,
|
||||
description: "Days between backups (1 = daily, 7 = weekly)",
|
||||
},
|
||||
running: { type: "boolean" },
|
||||
last: {
|
||||
type: "object",
|
||||
@@ -1164,6 +1170,7 @@ const paths = {
|
||||
dir: { type: "boolean" },
|
||||
hour: { type: "boolean" },
|
||||
keep: { type: "boolean" },
|
||||
every: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1180,9 +1187,9 @@ const paths = {
|
||||
},
|
||||
put: {
|
||||
tags: ["Settings"],
|
||||
summary: "Set the backup folder, hour and number kept",
|
||||
summary: "Set the backup folder, how often, the hour and number kept",
|
||||
description:
|
||||
"Any subset of the three fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP. An empty `dir` turns backups off. Same-origin only.",
|
||||
"Any subset of the fields, persisted to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP / AGENTHUB_BACKUP_EVERY. An empty `dir` turns backups off. Same-origin only.",
|
||||
operationId: "setBackup",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1194,6 +1201,7 @@ const paths = {
|
||||
dir: { type: "string" },
|
||||
hour: { type: "integer", minimum: 0, maximum: 23 },
|
||||
keep: { type: "integer", minimum: 1, maximum: 365 },
|
||||
every: { type: "integer", minimum: 1, maximum: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ const tags = [
|
||||
{
|
||||
name: "Remote clients",
|
||||
description:
|
||||
"Company hub ↔ local AgentHub. On the hub (server mode): a machine enrolls with its user's password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account's OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine's enrollment; the device token is never returned.",
|
||||
"Company hub ↔ local AgentHub. Laptops connect only while the hub admin's switch (`/api/remote/policy`) is on — off by default, and while off every `/api/remote/device/*` route and the gateway answer 403 EREMOTEOFF. The hub keeps no address and no host name of a machine, and only the last segment of a session's folder. On the hub (server mode): a machine enrolls with its user's password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account's OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine's enrollment; the device token is never returned.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,7 +34,11 @@ const schemas = {
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
expiresAt: { type: "string", format: "date-time" },
|
||||
lastSeenAt: { type: "string", format: "date-time", nullable: true },
|
||||
lastIp: { type: "string", nullable: true },
|
||||
lastIp: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Always null: the hub keeps no address of a user's machine.",
|
||||
},
|
||||
revokedAt: { type: "string", format: "date-time", nullable: true },
|
||||
active: { type: "boolean" },
|
||||
},
|
||||
@@ -144,7 +148,10 @@ const paths = {
|
||||
required: ["sessionId", "cwd"],
|
||||
properties: {
|
||||
sessionId: { type: "string", format: "uuid" },
|
||||
cwd: { type: "string" },
|
||||
cwd: {
|
||||
type: "string",
|
||||
description: "The session's folder; the hub keeps only its last segment.",
|
||||
},
|
||||
tool: { type: "string", enum: ["claude"] },
|
||||
},
|
||||
}),
|
||||
@@ -205,6 +212,40 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/policy": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteGetPolicy",
|
||||
summary: "May laptops connect to this hub at all (off by default)?",
|
||||
responses: {
|
||||
200: ok("The switch", {
|
||||
type: "object",
|
||||
properties: {
|
||||
allowDevices: { type: "boolean" },
|
||||
changedAt: { type: "string", format: "date-time", nullable: true },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: T,
|
||||
operationId: "remoteSetPolicy",
|
||||
summary: "Turn laptop connections on or off — an admin (audited)",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["allowDevices"],
|
||||
properties: { allowDevices: { type: "boolean" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("The switch as it now stands"),
|
||||
400: fail("EBADFIELD — allowDevices must be true or false"),
|
||||
403: fail("EADMIN — admins only"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/devices": {
|
||||
get: {
|
||||
tags: T,
|
||||
@@ -225,19 +266,10 @@ const paths = {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteRevokeDevice",
|
||||
summary: "Revoke a device — its owner or an admin, with their own password again (audited)",
|
||||
summary: "Revoke a device — its owner or an admin (audited)",
|
||||
parameters: [did],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["password"],
|
||||
properties: { password: { type: "string" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Revoked"),
|
||||
403: fail("Wrong password"),
|
||||
404: fail("No such device of yours"),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -115,7 +115,7 @@ const STATUS = {
|
||||
EUPSTREAM: 502,
|
||||
};
|
||||
|
||||
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||
/** One pool change: server mode, an admin's (no password again), audited. */
|
||||
function poolChange(handler, { anyTool = false } = {}) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
@@ -128,7 +128,6 @@ function poolChange(handler, { anyTool = false } = {}) {
|
||||
if (!anyTool && !pins.TOOLS.includes(body.tool)) {
|
||||
throw Object.assign(new Error(`tool: ${pins.TOOLS.join(", ")}`), { code: "EBADTOOL" });
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
for (const a of out.audits || [{ target: out.target, action: out.action }]) {
|
||||
recordAccountChange(req.user.id, a.target ?? null, req.method, a.action);
|
||||
|
||||
@@ -608,8 +608,9 @@ router.put("/privacy", sameOriginGuard, (req, res) => {
|
||||
res.json({ ok: true, ...privacyState() });
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily
|
||||
// database backup (server/lib/db-backup.js). The folder, hour and count persist
|
||||
// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the scheduled
|
||||
// database backup (server/lib/db-backup.js). The folder, hour, count and the
|
||||
// days between backups persist
|
||||
// to the settings file; one the environment set is `env_owned` and refused. A
|
||||
// run answers 202 at once (a large database takes tens of seconds) and the
|
||||
// page polls GET while `running`.
|
||||
@@ -655,6 +656,7 @@ router.put("/backup", sameOriginGuard, (req, res) => {
|
||||
for (const [field, lo, hi] of [
|
||||
["hour", 0, 23],
|
||||
["keep", 1, 365],
|
||||
["every", 1, 30],
|
||||
]) {
|
||||
if (body[field] === undefined) continue;
|
||||
const n = body[field];
|
||||
|
||||
+8
-10
@@ -30,9 +30,8 @@
|
||||
* user form: display name, email, role, disabled, a new password, workspaces
|
||||
* given and taken back, sign out everywhere — every field validated before any
|
||||
* is applied), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
|
||||
* 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
|
||||
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES; no
|
||||
* password again — only an admin reaches them), an admin never demotes or disables themselves (ESELF), every change is
|
||||
* 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
|
||||
@@ -200,8 +199,10 @@ const STATUS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One account change: server mode only, the admin's password confirmed, the
|
||||
* change made, then audited. `fn(target)` returns the audit text.
|
||||
* One account change: server mode only (the route is an admin's — ADMIN_WRITE
|
||||
* + the role check), the change made, then audited. No password again: only
|
||||
* an admin reaches these routes (user's call, 2026-11-09). `fn(target)`
|
||||
* returns the audit text.
|
||||
*/
|
||||
function accountChange(handler) {
|
||||
return async (req, res) => {
|
||||
@@ -212,7 +213,6 @@ function accountChange(handler) {
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
||||
res
|
||||
@@ -717,8 +717,8 @@ router.post(
|
||||
|
||||
/**
|
||||
* Restart the tmux server of every member running an older tmux than the
|
||||
* installed one while no agent of theirs works. One password, one audit
|
||||
* entry per member restarted.
|
||||
* installed one while no agent of theirs works. One audit entry per member
|
||||
* restarted.
|
||||
*/
|
||||
router.post("/tmux-restart-idle", async (req, res) => {
|
||||
try {
|
||||
@@ -727,8 +727,6 @@ router.post("/tmux-restart-idle", async (req, res) => {
|
||||
code: "ELOCALMODE",
|
||||
});
|
||||
}
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const mt = require("../lib/member-tmux");
|
||||
const all = db.prepare("SELECT * FROM users ORDER BY id").all();
|
||||
const rows = mt.overview(all.map(auth.publicUser)).users;
|
||||
|
||||
Reference in New Issue
Block a user