chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* admin's server-wide switches: installing a shared skill or plugin from the
|
||||
* Hub into a user's own account, and asking Claude to save a viewable copy of
|
||||
* a Doc / Slides / Design it publishes (`GET/PUT /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* or for anyone the server does not let manage it (`canManage`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -24,17 +24,32 @@ export function ArtifactsHubSettings() {
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
if (!settings?.serverMode || !settings.canManage) return null;
|
||||
if (!settings?.canManage) return null;
|
||||
return (
|
||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||
{settings.serverMode && (
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
label={t("exportsSwitch")}
|
||||
description={t("exportsSwitchHint")}
|
||||
checked={settings.exportsEnabled !== false}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
const r = await api.artifacts.setExportsEnabled(v);
|
||||
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.tsx
|
||||
* @description Settings → AI tools → Company hub (docs/plans/remote-clients).
|
||||
* On a LOCAL install: enroll this machine with the company hub (hub address +
|
||||
* the user's hub password, which goes to the hub once and is not kept — the
|
||||
* same as `agenthub remote login`), what is and is not sent, the
|
||||
* Company-account sessions sent and when, Send now and Log out
|
||||
* (`/api/remote-client`). On the HUB (server mode, signed in): the user's own
|
||||
* enrolled machines with Revoke, their Company-account sessions, and who
|
||||
* (admin/analyst) opened their sessions (`/api/remote/*`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Building2, LogOut, Upload } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteClientStatus } from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function CompanyHubSettings() {
|
||||
const { local } = usePermissions();
|
||||
return local ? <LocalHub /> : <HubSide />;
|
||||
}
|
||||
|
||||
function LocalHub() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [st, setSt] = useState<RemoteClientStatus | null>(null);
|
||||
const [hub, setHub] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteClient
|
||||
.status()
|
||||
.then(setSt)
|
||||
.catch(() => setSt(null));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const enroll = () =>
|
||||
run(async () => {
|
||||
setSt(await api.remoteClient.login({ hub, username, password }));
|
||||
setPassword("");
|
||||
toast.success(t("companyHub.enrolledToast"));
|
||||
load();
|
||||
});
|
||||
const logout = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("companyHub.logoutTitle"),
|
||||
message: t("companyHub.logoutHint"),
|
||||
confirmLabel: t("companyHub.logout"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(async () => {
|
||||
await api.remoteClient.logout();
|
||||
load();
|
||||
});
|
||||
};
|
||||
const sendNow = () =>
|
||||
run(async () => {
|
||||
const r = await api.remoteClient.upload();
|
||||
toast.success(
|
||||
t("companyHub.sentToast", { bytes: formatBytes(r.bytes), sessions: r.sessions })
|
||||
);
|
||||
if (r.errors.length) setError(r.errors.map((e) => e.message).join("\n"));
|
||||
load();
|
||||
});
|
||||
|
||||
if (!st) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.title")}
|
||||
description={t("companyHub.description")}
|
||||
>
|
||||
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-[11px] text-fg-muted">
|
||||
<li>{t("companyHub.ruleSent")}</li>
|
||||
<li>{t("companyHub.ruleNotSent")}</li>
|
||||
<li>{t("companyHub.ruleKey")}</li>
|
||||
<li>{t("companyHub.ruleOffice")}</li>
|
||||
</ul>
|
||||
{st.enrolled ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Building2 className="h-4 w-4 text-accent" />
|
||||
<span className="text-fg-primary">
|
||||
{t("companyHub.enrolledAs", { hub: st.hub, user: st.user?.username || "?" })}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {st.device?.name} ·{" "}
|
||||
{t("companyHub.validUntil", { time: formatDateTime(st.device?.expiresAt || "") })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void sendNow()}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("companyHub.sendNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t("companyHub.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">
|
||||
{t("companyHub.sentTitle")}
|
||||
</h4>
|
||||
{st.uploads && st.uploads.length > 0 ? (
|
||||
<ul
|
||||
className="divide-y divide-border rounded-lg border border-border"
|
||||
data-testid="company-uploads"
|
||||
>
|
||||
{st.uploads.map((u) => (
|
||||
<li
|
||||
key={u.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<Link to={`/sessions/${u.sessionId}`} className="text-accent hover:underline">
|
||||
{u.title || u.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="truncate font-mono text-fg-muted">{u.cwd}</span>
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{u.lastSentAt
|
||||
? t("companyHub.sentLine", {
|
||||
bytes: formatBytes(u.bytes || 0),
|
||||
time: formatDateTime(u.lastSentAt),
|
||||
})
|
||||
: t("companyHub.notSentYet")}
|
||||
{u.ended ? ` · ${t("companyHub.ended")}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nothingSent")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid max-w-md gap-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && hub && username && password) {
|
||||
e.preventDefault();
|
||||
void enroll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{st.expired && (
|
||||
<p className="text-xs text-status-warning">{t("companyHub.expiredHint")}</p>
|
||||
)}
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder="http://10.1.13.7:4820"
|
||||
value={hub}
|
||||
onChange={(e) => setHub(e.target.value)}
|
||||
aria-label={t("companyHub.hubAddress")}
|
||||
/>
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
aria-label={t("companyHub.username")}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 w-fit text-xs"
|
||||
disabled={busy || !hub || !username || !password}
|
||||
onClick={() => void enroll()}
|
||||
>
|
||||
{t("companyHub.enroll")}
|
||||
</button>
|
||||
<p className="text-[11px] text-fg-muted">{t("companyHub.cliHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HubSide() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [audit, setAudit] = useState<
|
||||
| {
|
||||
id: number;
|
||||
at: string;
|
||||
sessionId: string;
|
||||
reader: string | null;
|
||||
readerDisplay: string | null;
|
||||
}[]
|
||||
| null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
api.remoteHub
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit(null));
|
||||
}, []);
|
||||
// A hub without the remote routes (an older server) answers 404: show nothing.
|
||||
if (audit === null) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.hubTitle")}
|
||||
description={t("companyHub.hubDescription")}
|
||||
>
|
||||
<RemoteDevices canRevoke />
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">{t("companyHub.whoRead")}</h4>
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nobodyRead")}</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-[11px]" data-testid="company-audit">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="text-fg-muted">
|
||||
{formatDateTime(a.at)} ·{" "}
|
||||
<span className="text-fg-primary">{a.readerDisplay || a.reader || "?"}</span> ·{" "}
|
||||
<Link to={`/sessions/${a.sessionId}`} className="text-accent hover:underline">
|
||||
{a.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
profileKey,
|
||||
readHiddenProfiles,
|
||||
toggleHidden,
|
||||
writeHiddenProfiles,
|
||||
} from "../../lib/hiddenProfilesStorage";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -129,7 +135,7 @@ export function ToolProfilesSettings() {
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
@@ -475,7 +481,7 @@ function ActiveBadge() {
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
@@ -492,6 +498,34 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
/** Local mode only: profiles this browser leaves out (Alt+Shift+click a row);
|
||||
* Alt+Shift+H shows them, dimmed, until pressed again. */
|
||||
const [hidden, setHidden] = useState<string[]>(() => (local ? readHiddenProfiles() : []));
|
||||
const [reveal, setReveal] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey && e.shiftKey && e.code === "KeyH") {
|
||||
e.preventDefault();
|
||||
setReveal((r) => !r);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [local]);
|
||||
const isHidden = useCallback(
|
||||
(p: { accountUuid: string | null; name?: string }) => local && hidden.includes(profileKey(p)),
|
||||
[local, hidden]
|
||||
);
|
||||
const toggleRow = (e: MouseEvent, p: CcProfile) => {
|
||||
if (!local || !e.altKey || !e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = toggleHidden(hidden, profileKey(p));
|
||||
setHidden(next);
|
||||
writeHiddenProfiles(next);
|
||||
};
|
||||
const activeHidden = !!active && !reveal && isHidden(active);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -540,10 +574,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
if (!reveal && isHidden(p)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
}, [items, checkUsage, isHidden, reveal]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -587,7 +622,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
@@ -603,7 +638,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
{active && activeHidden ? (
|
||||
<div className="text-sm text-fg-muted">—</div>
|
||||
) : active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
@@ -631,17 +668,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
|
||||
@@ -12,7 +12,9 @@ import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
|
||||
api: {
|
||||
artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn(), setExportsEnabled: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const settings = vi.mocked(api.artifacts.settings);
|
||||
@@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => {
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager or in local mode", async () => {
|
||||
it("renders nothing for a non-manager, and only the copy switch in local mode", async () => {
|
||||
settings.mockResolvedValue({ ...base, canManage: false });
|
||||
const { container, unmount } = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false });
|
||||
const r = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true });
|
||||
vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({
|
||||
installsEnabled: true,
|
||||
exportsEnabled: false,
|
||||
});
|
||||
render(<ArtifactsHubSettings />);
|
||||
const sw = await screen.findByRole("switch", {
|
||||
name: "Keep a viewable copy of Docs and Slides",
|
||||
});
|
||||
expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.test.tsx
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { CompanyHubSettings } from "../CompanyHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: true, isAdmin: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...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() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const enrolled = {
|
||||
enrolled: true,
|
||||
hub: "http://10.1.13.7:4820",
|
||||
user: { id: 3, username: "mia", role: "developer" },
|
||||
device: { id: 7, name: "mia-pc", expiresAt: "2027-02-01T00:00:00.000Z" },
|
||||
uploads: [
|
||||
{
|
||||
sessionId: "11111111-2222-3333-4444-555555555555",
|
||||
bytes: 2048,
|
||||
lastSentAt: "2026-11-04T02:00:00.000Z",
|
||||
ended: 0,
|
||||
title: "fix the cart",
|
||||
cwd: "/home/mia/shop",
|
||||
startedAt: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const show = async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CompanyHubSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — local", () => {
|
||||
it("enrolls with the hub address, username and password, then clears the password", async () => {
|
||||
vi.mocked(api.remoteClient.status)
|
||||
.mockResolvedValueOnce({ enrolled: false })
|
||||
.mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.login).mockResolvedValue(enrolled);
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.type(screen.getByLabelText(/hub address|địa chỉ hub/i), "http://10.1.13.7:4820");
|
||||
await user.type(screen.getByLabelText(/hub username|tên đăng nhập hub/i), "mia");
|
||||
const pw = screen.getByLabelText(/^(hub password|mật khẩu hub)$/i);
|
||||
await user.type(pw, "secret-1");
|
||||
await user.click(screen.getByRole("button", { name: /enroll this machine|đăng ký máy này/i }));
|
||||
expect(api.remoteClient.login).toHaveBeenCalledWith({
|
||||
hub: "http://10.1.13.7:4820",
|
||||
username: "mia",
|
||||
password: "secret-1",
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("company-uploads")).toBeInTheDocument());
|
||||
expect(pw).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists what was sent and logs out only after confirming", async () => {
|
||||
vi.mocked(api.remoteClient.status).mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.logout).mockResolvedValue({ ok: true, wasEnrolled: true });
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
expect(screen.getByText("fix the cart")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/ahdev_/);
|
||||
await user.click(screen.getByRole("button", { name: /log out|đăng xuất/i }));
|
||||
expect(api.remoteClient.logout).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await waitFor(() => expect(api.remoteClient.logout).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — hub", () => {
|
||||
it("shows the user's machines, revokes with their password, and who read their sessions", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: 1,
|
||||
at: "2026-11-04T03:00:00.000Z",
|
||||
sessionId: "abcdef12-0000",
|
||||
reader: "boss",
|
||||
readerDisplay: "Boss",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.devices).mockResolvedValue({
|
||||
devices: [
|
||||
{
|
||||
id: 7,
|
||||
userId: 3,
|
||||
name: "mia-pc",
|
||||
createdAt: "2026-11-01T00:00:00.000Z",
|
||||
expiresAt: "2027-02-01T00:00:00.000Z",
|
||||
lastSeenAt: null,
|
||||
lastIp: null,
|
||||
revokedAt: null,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await waitFor(() => expect(screen.getByText("mia-pc")).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");
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ async function show() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
perms.local = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
@@ -137,6 +138,34 @@ describe("ToolProfilesSettings — Claude", () => {
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
it("leaves a profile this browser hid out of the list and asks nothing about it", async () => {
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
await settle();
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(screen.queryByText("d@e.f")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.keyboard("{Alt>}{Shift>}");
|
||||
await user.click(screen.getByTestId("profile-row-spare"));
|
||||
await user.keyboard("{/Shift}{/Alt}");
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]);
|
||||
await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}");
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not hide anything in server mode", async () => {
|
||||
perms.local = false;
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
|
||||
Reference in New Issue
Block a user