feat(settings): admin switch to allow or block members' test runs
Settings → AI tools → Tests on this server (admin, S3 only). Stored in /opt/agenthub/app/member-policy.json, which member-guard.js reads as the member at each Bash call — no restart; no file = blocked. GET/PUT /api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||
* running test runners and container commands on this box
|
||||
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
|
||||
* with per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type MemberPolicyState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function MemberPolicySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.memberPolicy()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
return (
|
||||
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("memberPolicy.switch")}
|
||||
description={t("memberPolicy.hint")}
|
||||
checked={state.allowTests}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setMemberPolicy(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
|
||||
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { MemberPolicySettings } from "../MemberPolicySettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||
|
||||
describe("MemberPolicySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin allow tests, off by default", async () => {
|
||||
get.mockResolvedValue({ available: true, allowTests: false });
|
||||
set.mockResolvedValue({ available: true, allowTests: true });
|
||||
render(<MemberPolicySettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
|
||||
it("hides outside S3", async () => {
|
||||
get.mockResolvedValue({ available: false, allowTests: false });
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("asks nothing for a non-admin or in local mode", () => {
|
||||
perms.isAdmin = false;
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
perms.isAdmin = true;
|
||||
perms.local = true;
|
||||
render(<MemberPolicySettings />);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user