feat: operator-configurable settings visibility via PAPERCLIP_HIDDEN_SETTINGS (#11823)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The instance settings surface (Access, Plugins, Adapters, General,
Experimental) assumes the person at the keyboard operates the whole
instance
> - Operators who host Paperclip for others — a managed cloud or an
internal shared server — expose settings pages and toggles that do not
apply to their deployment, and the related mutation APIs stay open
> - A hosted tenant can open Plugins or Adapters, try an action, and hit
a confusing failure, because only a few hardcoded platform floors exist
> - This pull request adds a generic, operator-configured visibility
mechanism: one env var hides declared settings surfaces in the UI and
floors their mutation routes with a stable 403 code
> - The benefit is a clean hosted-tenant settings surface for any
operator, with zero behavior change for normal self-hosted instances

## Linked Issues or Issue Description

**Subsystem affected**

Instance settings (server routes and UI), the shared settings registry
in `packages/shared`, and the `/api/health` bootstrap payload.

**Problem or motivation**

An operator who hosts Paperclip for other people cannot hide settings
surfaces that the platform manages. Tenants see Access, Plugins, and
Adapters pages, backup retention, and host-level experimental toggles
that do nothing useful for them. The mutation APIs behind these surfaces
also stay open, so a tenant admin can attempt actions the platform must
control. ROADMAP.md names a cleaner shared deployment story as a goal
("Teams should be able to run the same product in hosted or semi-hosted
environments without changing the mental model").

**Proposed solution**

Add a declarative registry of hideable settings surfaces and one env
var, `PAPERCLIP_HIDDEN_SETTINGS`. The server parses the list at boot,
reports it on `/api/health`, and rejects value-changing writes to hidden
surfaces with a stable `settings_operator_managed` 403 code. The UI
reads the list from the health payload and removes the hidden pages,
sections, and toggles from navigation, routes, and page content. Unknown
keys warn and are ignored, so one list can roll across a fleet with
mixed app versions. With the variable unset, behavior is byte-identical
to today.

**Alternatives considered**

- Hardcode the hidden set for cloud instances in this repo: rejected,
because each hosting operator needs a different policy, and policy does
not belong in shared code.
- Deliver the hidden set through the managed-config document: rejected,
because that channel is cloud-specific and fail-closed on unknown
fields; a plain env var works for any operator, including self-hosted
shared servers.
- Lock the controls with a badge instead of hiding them: rejected for
these surfaces, because they are meaningless to tenants, not merely
platform-controlled; the existing managed-overlay lock stays the right
tool for controlled flags.

**Roadmap alignment**

Supports the "shared deployment story" item in ROADMAP.md: hosted and
semi-hosted deployments keep the same product with a settings surface
that matches what the tenant can actually do.

## What Changed

- New `packages/shared/src/settings-visibility.ts`: registry of hideable
surfaces (every instance settings page — profile, environments, access,
heartbeats, experimental, plugins, adapters; every Instance → General
section; every experimental flag as `instance.experimental.<key>`), the
`PAPERCLIP_HIDDEN_SETTINGS` parser, and the `settings_operator_managed`
error code. The General page stays visible as the settings root and
redirect target.
- New `server/src/services/settings-visibility.ts`: parse-once accessor;
unknown keys log one warning and are ignored.
- `/api/health` reports `hiddenSettings` on every response shape; the
field is omitted when nothing is hidden.
- Server floors on hidden surfaces, with same-value echo tolerance (the
`executionMode` precedent): field-backed general sections and
experimental keys reject value-changing PATCHes, and hiding the whole
Experimental page floors every toggle; plugin lifecycle and config
writes, adapter management writes, and the Access admin routes (reads
included) return 403 `settings_operator_managed`. Reads the app itself
needs (plugin `ui-contributions`, adapter metadata, plugin job trigger)
stay open. Pages without instance-scoped mutation routes are hidden in
the UI only.
- UI: new `useHiddenSettings` hook and `HiddenSettingsPageGate` route
gate (hidden pages redirect to the settings root); the settings sidebar
and tab bar drop hidden entries; remembered settings paths remap to the
default page; `InstanceGeneralSettings` skips hidden sections; every
`ExperimentalToggleCard` now carries its flag key and renders nothing
when hidden.
- Removed the dead `InstanceSidebar` component (referenced only by its
own test).
- Docs: `docs/deploy/environment-variables.md` documents the variable
and the key registry.

## Verification

- `pnpm vitest run` over the new and extended suites: shared registry
and parser, representative floor tests per route class (changed-value
403, same-value echo 200, unset env 200, page-level Experimental
hiding), the health field, the route gate, nav filtering, and
section/card hiding with one hidden example per surface kind — 168 tests
pass.
- Full root `pnpm typecheck` passes.
- Manual: booted a server with the variable set. `/api/health` lists the
keys; an unknown key logs one warning and the server boots; hidden pages
redirect; hidden sections and cards do not render; hidden-field PATCH
returns 403 with `details.code = "settings_operator_managed"`; a
same-value echo returns 200. Unset the variable: the full settings
surface returns and responses are byte-identical to master.

## Risks

- Low risk for self-hosted instances: with the variable unset, the
hidden set is empty, the health field is omitted, and no floor
activates.
- Flooring plugin config writes assumes hosted deployments configure
plugins through the platform. If a future bundled plugin needs
tenant-entered config, the floor needs a narrow carve-out.
- Hidden-key floors tolerate same-value echoes, so API clients that
round-trip full GET responses keep working.
- Hiding a toggle does not change its value; operators pair hiding with
the desired default where the value matters.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`) with extended thinking and
agentic tool use, driven through the Claude Code CLI (file edits, test
execution, and live-server verification loops).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-08-20 17:54:44 -07:00 committed by GitHub
parent 0fa318b8da
commit db4defdfbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1328 additions and 414 deletions

View File

@ -19,6 +19,33 @@ All environment variables that Paperclip uses for server configuration.
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. |
| `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). |
### Hiding settings surfaces
`PAPERCLIP_HIDDEN_SETTINGS` takes keys from the registry in
`packages/shared/src/settings-visibility.ts`:
- Any instance settings page: `instance.profile`, `instance.environments`,
`instance.access`, `instance.heartbeats`, `instance.experimental`,
`instance.plugins`, `instance.adapters` — removed from navigation and
routing (the General page is the settings root and stays visible). Hiding
`instance.access`, `instance.plugins`, or `instance.adapters` also floors
their management endpoints with `403 settings_operator_managed`; hiding
`instance.experimental` floors every experimental toggle write.
- Any Instance → General section: `instance.general.censorUsernameInLogs`,
`instance.general.keyboardShortcuts`, `instance.general.backupRetention`,
`instance.general.feedbackDataSharingPreference` (each also rejects
value-changing writes via `PATCH /api/instance/settings/general`), plus the
UI-only `instance.general.deploymentStatus` and `instance.general.signOut`.
- Any experimental toggle: `instance.experimental.<flagKey>` (e.g.
`instance.experimental.enableSmokeLab`) — the card disappears and
value-changing writes are rejected.
Unknown keys are logged and ignored, so one list can be rolled across a fleet
of mixed app versions. With the variable unset nothing is hidden and behavior
is identical to earlier releases. Hiding a toggle does not change its value;
pair hiding with the desired default where it matters.
## Secrets

View File

@ -2485,6 +2485,23 @@ export {
type FeatureTier,
type InstanceFeatureKey,
} from "./feature-catalog.js";
export {
HIDEABLE_GENERAL_SECTIONS,
HIDEABLE_INSTANCE_PAGES,
HIDEABLE_SETTING_KEYS,
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
UI_ONLY_GENERAL_SECTIONS,
experimentalSettingKey,
hidesExperimentalSetting,
hidesGeneralSection,
hidesInstancePage,
parseHiddenSettingsList,
type HideableExperimentalSetting,
type HideableGeneralSection,
type HideableInstancePage,
type HideableSettingKey,
type ParsedHiddenSettings,
} from "./settings-visibility.js";
// --- Runtime exposure (opt-in Tailscale HTTPS for managed branch runtimes) ---
// PAP-17049 plan, PAP-17050 threat-model verdict. Contract shared across DB,

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js";
import {
HIDEABLE_GENERAL_SECTIONS,
HIDEABLE_SETTING_KEYS,
UI_ONLY_GENERAL_SECTIONS,
experimentalSettingKey,
hidesExperimentalSetting,
hidesGeneralSection,
hidesInstancePage,
parseHiddenSettingsList,
} from "./settings-visibility.js";
import { instanceGeneralSettingsSchema } from "./validators/instance.js";
describe("hideable setting keys", () => {
it("derives one key per experimental flag and has no duplicates", () => {
const experimental = HIDEABLE_SETTING_KEYS.filter(
(key) => key.startsWith("instance.experimental."),
);
expect(experimental).toEqual(INSTANCE_FEATURE_KEYS.map(experimentalSettingKey));
expect(new Set(HIDEABLE_SETTING_KEYS).size).toBe(HIDEABLE_SETTING_KEYS.length);
});
it("maps field-backed general sections onto real general-settings fields", () => {
const generalFields = Object.keys(instanceGeneralSettingsSchema.shape);
const uiOnly = new Set<string>(UI_ONLY_GENERAL_SECTIONS);
for (const section of HIDEABLE_GENERAL_SECTIONS) {
if (uiOnly.has(section)) continue;
expect(generalFields).toContain(section.slice("instance.general.".length));
}
});
});
describe("parseHiddenSettingsList", () => {
it("returns nothing hidden for undefined or empty input", () => {
expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] });
expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] });
});
it("splits known from unknown keys, trims, and deduplicates", () => {
const parsed = parseHiddenSettingsList(
"instance.plugins, instance.general.backupRetention ,instance.bogus,instance.plugins,instance.experimental.enableEnvironments",
);
expect(parsed.hidden).toEqual([
"instance.plugins",
"instance.general.backupRetention",
"instance.experimental.enableEnvironments",
]);
expect(parsed.unknown).toEqual(["instance.bogus"]);
});
});
describe("membership helpers", () => {
const hidden = new Set(
parseHiddenSettingsList(
"instance.plugins,instance.general.censorUsernameInLogs,instance.experimental.enableEnvironments",
).hidden,
);
it("answers page, section, and experimental membership", () => {
expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true);
expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false);
expect(hidesGeneralSection(hidden, "instance.general.censorUsernameInLogs")).toBe(true);
expect(hidesGeneralSection(hidden, "instance.general.backupRetention")).toBe(false);
expect(hidesExperimentalSetting(hidden, "enableEnvironments")).toBe(true);
expect(hidesExperimentalSetting(hidden, "enableIsolatedWorkspaces")).toBe(false);
});
it("treats a hidden Experimental page as hiding every toggle", () => {
const pageHidden = new Set(parseHiddenSettingsList("instance.experimental").hidden);
expect(hidesExperimentalSetting(pageHidden, "enableEnvironments")).toBe(true);
});
});

View File

@ -0,0 +1,136 @@
import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalog.js";
/**
* Operator-configurable settings visibility.
*
* A hosting operator (a managed cloud, an internal shared server) can hide
* instance-settings surfaces that do not apply to their deployment by setting
* the `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated
* list of keys from this registry. Hiding a surface removes it from the UI
* (nav, routes, page sections). Surfaces backed by instance-level mutation
* routes are also floored with a 403 carrying
* `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters
* pages, every field-backed General section, and every experimental toggle
* (individually or via the whole Experimental page).
*
* Nothing is hidden by default: with the variable unset, UI and API behave
* exactly as before this mechanism existed.
*
* Unknown keys are ignored (with a server-side warning) rather than rejected,
* so an operator may roll one list across a fleet of mixed app versions: an
* image that predates a key simply keeps that surface visible instead of
* refusing to boot.
*/
/**
* Instance settings pages that can be hidden (nav entry + route). The General
* page is deliberately not hideable: it is the settings root and the redirect
* target for hidden pages. Individual General sections are hideable below.
*/
export const HIDEABLE_INSTANCE_PAGES = [
"instance.profile",
"instance.environments",
"instance.access",
"instance.heartbeats",
"instance.experimental",
"instance.plugins",
"instance.adapters",
] as const;
export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number];
/**
* Sections of Instance General that can be hidden. Field-backed sections
* (their suffix names a general-settings field) also floor writes to that
* field; `deploymentStatus` and `signOut` are read-only UI with no field.
*/
export const HIDEABLE_GENERAL_SECTIONS = [
"instance.general.deploymentStatus",
"instance.general.censorUsernameInLogs",
"instance.general.keyboardShortcuts",
"instance.general.backupRetention",
"instance.general.feedbackDataSharingPreference",
"instance.general.signOut",
] as const;
export type HideableGeneralSection = (typeof HIDEABLE_GENERAL_SECTIONS)[number];
/** General sections that are informational UI only, with no settings field. */
export const UI_ONLY_GENERAL_SECTIONS = [
"instance.general.deploymentStatus",
"instance.general.signOut",
] as const satisfies readonly HideableGeneralSection[];
export type HideableExperimentalSetting = `instance.experimental.${InstanceFeatureKey}`;
/** The visibility key for an experimental toggle; every boolean flag is hideable. */
export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperimentalSetting {
return `instance.experimental.${key}`;
}
export type HideableSettingKey =
| HideableInstancePage
| HideableGeneralSection
| HideableExperimentalSetting;
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
...HIDEABLE_INSTANCE_PAGES,
...HIDEABLE_GENERAL_SECTIONS,
...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey),
];
/** Stable 403 code for writes to operator-hidden settings. */
export const SETTINGS_OPERATOR_MANAGED_ERROR_CODE = "settings_operator_managed";
export interface ParsedHiddenSettings {
/** Recognized keys, deduplicated, in input order. */
hidden: HideableSettingKey[];
/** Unrecognized entries, for the caller to warn about. */
unknown: string[];
}
/** Parse a `PAPERCLIP_HIDDEN_SETTINGS`-style comma-separated list. */
export function parseHiddenSettingsList(raw: string | undefined): ParsedHiddenSettings {
const hidden: HideableSettingKey[] = [];
const unknown: string[] = [];
if (!raw) return { hidden, unknown };
const known = new Set<string>(HIDEABLE_SETTING_KEYS);
const seen = new Set<string>();
for (const part of raw.split(",")) {
const key = part.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
if (known.has(key)) {
hidden.push(key as HideableSettingKey);
} else {
unknown.push(key);
}
}
return { hidden, unknown };
}
export function hidesInstancePage(
hidden: ReadonlySet<string>,
page: HideableInstancePage,
): boolean {
return hidden.has(page);
}
export function hidesGeneralSection(
hidden: ReadonlySet<string>,
section: HideableGeneralSection,
): boolean {
return hidden.has(section);
}
/**
* Whether a toggle is hidden, either individually or because the whole
* Experimental page is hidden.
*/
export function hidesExperimentalSetting(
hidden: ReadonlySet<string>,
key: InstanceFeatureKey,
): boolean {
return hidden.has("instance.experimental") || hidden.has(experimentalSettingKey(key));
}

View File

@ -0,0 +1,95 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
/**
* Operator-hidden Access surface floor (`instance.access` in
* PAPERCLIP_HIDDEN_SETTINGS): instance-admin user management routes reject
* with a stable code while the rest of the access router stays untouched.
* The floor throws before any data access, so a stub db suffices; the
* unfloored happy paths are covered by the embedded-postgres access tests.
*/
const stubDb = {
select: () => ({
from: () => {
const chain = {
orderBy: async () => [] as unknown[],
where: async () => [] as unknown[],
};
return chain;
},
}),
} as never;
async function createApp() {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId: "instance-admin-1",
source: "local_implicit",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
isInstanceAdmin: true,
} as Express.Request["actor"];
next();
});
app.use("/api", accessRoutes(stubDb, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}));
app.use(errorHandler);
return app;
}
describe("operator-hidden access admin floor", () => {
let app: express.Express;
// The access router is a large module; import and build the app once so the
// cost is not charged to the first test's timeout on slow CI runners.
beforeAll(async () => {
app = await createApp();
}, 30_000);
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
const attempts: Array<[string, () => request.Test]> = [
["promote", () => request(app).post("/api/admin/users/user-1/promote-instance-admin")],
["demote", () => request(app).post("/api/admin/users/user-1/demote-instance-admin")],
[
"company access write",
() => request(app).put("/api/admin/users/user-1/company-access").send({ companyIds: [] }),
],
["user listing", () => request(app).get("/api/admin/users")],
["company access read", () => request(app).get("/api/admin/users/user-1/company-access")],
];
it.each(attempts)(
"floors the %s route when the operator hides the surface",
async (_name, buildRequest) => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.access";
const res = await buildRequest();
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
},
);
it("keeps the routes reachable when the surface is not hidden", async () => {
const res = await request(app).get("/api/admin/users");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([]);
});
});

View File

@ -367,4 +367,41 @@ describe.sequential("adapter management route authorization", () => {
},
);
});
describe("operator-hidden adapter management floor", () => {
beforeEach(() => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.adapters";
});
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
it.each(["install", "disable", "override", "delete", "reload", "reinstall"] as const)(
"floors adapter %s for instance admins when the operator hides the Adapters surface",
async (routeName) => {
resetInstalledExternalAdapterState();
if (routeName !== "install") {
seedInstalledExternalAdapter();
}
const app = createApp(instanceAdmin);
const res = await sendMutatingRequest(app, routeName);
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mocks.execFile).not.toHaveBeenCalled();
expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled();
expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled();
},
);
it("keeps adapter reads open while the surface is hidden", async () => {
seedInstalledExternalAdapter();
const app = createApp(boardMember("admin"));
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/adapters"));
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
});
});

View File

@ -116,6 +116,26 @@ describe("GET /health", () => {
});
});
it("lists operator-hidden settings and drops unknown keys", async () => {
const app = createApp(undefined, testServerInfo, undefined, {
PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.adapters,instance.bogus",
});
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body.hiddenSettings).toEqual(["instance.plugins", "instance.adapters"]);
});
it("omits hiddenSettings entirely when nothing is hidden", async () => {
const app = createApp(undefined, testServerInfo, undefined, {});
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(Object.prototype.hasOwnProperty.call(res.body, "hiddenSettings")).toBe(false);
});
it("returns 200 when the database probe succeeds", async () => {
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),

View File

@ -805,4 +805,123 @@ describe("instance settings routes", () => {
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes" });
});
});
describe("operator-hidden settings floor", () => {
const adminActor = {
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
};
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
it("rejects a write that changes a hidden general field", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("allows a same-value echo of a hidden general field", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: false, keyboardShortcuts: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: false,
keyboardShortcuts: true,
});
});
it("deep-compares hidden backupRetention echoes instead of rejecting them", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.backupRetention";
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
});
const app = await createApp(adminActor);
const echo = await request(app)
.patch("/api/instance/settings/general")
.send({ backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 } });
expect(echo.status).toBe(200);
const change = await request(app)
.patch("/api/instance/settings/general")
.send({ backupRetention: { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 1 } });
expect(change.status).toBe(403);
expect(change.body.details).toMatchObject({ code: "settings_operator_managed" });
});
it("rejects a write that changes a hidden experimental toggle", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableEnvironments";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableEnvironments: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
it("allows writes to non-hidden experimental toggles while others are hidden", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS =
"instance.experimental.enableEnvironments,instance.experimental.enableServerInfoDebugView";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableIsolatedWorkspaces: true,
});
});
it("floors every experimental toggle when the whole Experimental page is hidden", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
it("keeps every field writable when the env var is unset", async () => {
const app = await createApp(adminActor);
const general = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(general.status).toBe(200);
const experimental = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableEnvironments: true });
expect(experimental.status).toBe(200);
});
});
});

View File

@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
@ -1103,3 +1103,55 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(executeTool).not.toHaveBeenCalled();
});
});
describe.sequential("operator-hidden plugin management floor", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins";
});
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
const instanceAdmin = () => boardActor({ isInstanceAdmin: true, userId: "instance-admin" });
it("floors plugin lifecycle and config writes for instance admins", async () => {
const { app, loader } = await createApp(instanceAdmin());
readyPlugin();
const attempts: Array<[string, request.Test]> = [
["install", request(app).post("/api/plugins/install").send({ packageName: "@paperclipai/plugin-modal" })],
["uninstall", request(app).delete(`/api/plugins/${pluginId}`)],
["enable", request(app).post(`/api/plugins/${pluginId}/enable`)],
["disable", request(app).post(`/api/plugins/${pluginId}/disable`).send({})],
["upgrade", request(app).post(`/api/plugins/${pluginId}/upgrade`).send({})],
["config", request(app).post(`/api/plugins/${pluginId}/config`).send({ config: {} })],
["config test", request(app).post(`/api/plugins/${pluginId}/config/test`).send({ config: {} })],
[
"local folder",
request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/data`)
.send({ path: "/tmp/folder" }),
],
];
for (const [name, attempt] of attempts) {
const res = await attempt;
expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details, name).toMatchObject({ code: "settings_operator_managed" });
}
expect(loader.installPlugin).not.toHaveBeenCalled();
expect(mockLifecycle.enable).not.toHaveBeenCalled();
expect(mockLifecycle.disable).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
expect(mockLifecycle.unload).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
});
it("keeps plugin reads open while the surface is hidden", async () => {
const { app } = await createApp(boardActor());
const res = await request(app).get("/api/plugins/examples");
expect(res.status, JSON.stringify(res.body)).toBe(200);
}, 20_000);
});

View File

@ -56,6 +56,22 @@ import {
badRequest,
tooManyRequests
} from "../errors.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
/**
* Floor: when the hosting operator hides the Instance Access surface
* (`instance.access` in PAPERCLIP_HIDDEN_SETTINGS), instance-admin user
* management is rejected alongside it user administration then belongs to
* the operator's own control plane. Applies to the Access page's reads too;
* invite and company-membership routes are company-scoped and stay open.
*/
function assertAccessAdminVisible() {
if (getHiddenSettings().has("instance.access")) {
throw forbidden("Instance user administration is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
import {
createInviteRateLimiter,
type InviteRateLimiter,
@ -4774,6 +4790,7 @@ export function accessRoutes(
"/admin/users/:userId/promote-instance-admin",
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
const result = await access.promoteInstanceAdmin(userId);
res.status(201).json(result);
@ -4782,6 +4799,7 @@ export function accessRoutes(
router.get("/admin/users", async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const query = searchAdminUsersQuerySchema.parse(req.query);
const needle = query.query.trim().toLowerCase();
const users = await db
@ -4844,6 +4862,7 @@ export function accessRoutes(
"/admin/users/:userId/demote-instance-admin",
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
const removed = await access.demoteInstanceAdmin(userId);
if (!removed) throw notFound("Instance admin role not found");
@ -4853,6 +4872,7 @@ export function accessRoutes(
router.get("/admin/users/:userId/company-access", async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
res.json(await loadUserCompanyAccessResponse(db, access, userId));
});
@ -4862,6 +4882,7 @@ export function accessRoutes(
validate(updateUserCompanyAccessSchema),
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
await access.setUserCompanyAccess(
userId,

View File

@ -50,6 +50,7 @@ import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSour
import { logger } from "../middleware/logger.js";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
@ -71,6 +72,20 @@ function assertAdapterCodeInstallAllowed() {
}
}
/**
* Floor: when the hosting operator hides the Adapters settings surface
* (`instance.adapters` in PAPERCLIP_HIDDEN_SETTINGS), adapter management
* writes are rejected alongside it. Reads stay open adapter metadata is
* consumed by agent-creation UIs outside the hidden page.
*/
function assertAdapterManagementVisible() {
if (getHiddenSettings().has("instance.adapters")) {
throw forbidden("Adapter management is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
@ -288,6 +303,7 @@ export function adapterRoutes() {
router.post("/adapters/install", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
assertAdapterManagementVisible();
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
@ -435,6 +451,8 @@ export function adapterRoutes() {
router.patch("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
const { disabled } = req.body as { disabled?: boolean };
@ -470,6 +488,8 @@ export function adapterRoutes() {
router.patch("/adapters/:type/override", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
const { paused } = req.body as { paused?: boolean };
@ -497,6 +517,7 @@ export function adapterRoutes() {
*/
router.delete("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
@ -573,6 +594,7 @@ export function adapterRoutes() {
*/
router.post("/adapters/:type/reload", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const type = req.params.type;
@ -626,6 +648,7 @@ export function adapterRoutes() {
router.post("/adapters/:type/reinstall", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
assertAdapterManagementVisible();
const type = req.params.type;

View File

@ -12,6 +12,7 @@ import {
isCloudManagedInstance,
type CloudInstanceEnv,
} from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import {
inspectDatabaseBackupHealth,
type DatabaseBackupHealthStatus,
@ -157,6 +158,11 @@ export function healthRoutes(
);
const runtimeEnv = opts.runtimeEnv ?? process.env;
const cloud = getCloudHealthStatus(runtimeEnv);
// Operator-hidden settings ride every response (like `cloud`): the list
// holds UI surface names only, and the settings nav needs it before any
// fuller-detail fetch. Omitted entirely when nothing is hidden, so
// deployments without the env var keep today's byte-identical responses.
const hiddenSettings = [...getHiddenSettings(runtimeEnv)];
// serverInfo (git SHA + process start) rides on the full-details responses
// only, so it reaches board/agent actors in authenticated mode or any caller
// in local_trusted dev — never anonymous authenticated callers. The
@ -192,12 +198,14 @@ export function healthRoutes(
commit,
serverInfo,
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
}
: {
status: "ok",
deploymentMode: opts.deploymentMode,
commit,
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
},
);
return;
@ -307,6 +315,7 @@ export function healthRoutes(
// this instance becomes visible.
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
});
return;
}
@ -330,6 +339,7 @@ export function healthRoutes(
...(devServer ? { devServer } : {}),
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
});
});

View File

@ -8,12 +8,52 @@ import {
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { validate } from "../middleware/validate.js";
import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js";
import { environmentService } from "../services/environments.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
function sameJsonValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a)
&& Array.isArray(b)
&& a.length === b.length
&& a.every((value, i) => sameJsonValue(value, b[i]))
);
}
const aKeys = Object.keys(a);
const bKeys = new Set(Object.keys(b));
return aKeys.length === bKeys.size && aKeys.every((key) =>
bKeys.has(key) && sameJsonValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
}
/**
* Floor writes to operator-hidden settings. Same-value writes pass so clients
* that echo a full GET response keep working (the executionMode precedent);
* only a write that would actually change a hidden setting is rejected.
*/
async function assertNoHiddenSettingChanges(
body: Record<string, unknown>,
getCurrent: () => Promise<object>,
isHiddenField: (field: string) => boolean,
) {
const hiddenKeys = Object.keys(body).filter(isHiddenField);
if (hiddenKeys.length === 0) return;
const current = (await getCurrent()) as Record<string, unknown>;
for (const key of hiddenKeys) {
if (sameJsonValue(body[key], current[key])) continue;
throw forbidden(`${key} is managed by the hosting operator on this instance`, {
code: "settings_operator_managed",
});
}
}
function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
@ -122,6 +162,12 @@ export function instanceSettingsRoutes(db: Db) {
});
}
}
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getGeneral(),
(field) => hidden.has(`instance.general.${field}`),
);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
@ -161,6 +207,15 @@ export function instanceSettingsRoutes(db: Db) {
validate(patchInstanceExperimentalSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
// Hiding the whole Experimental page floors every toggle; otherwise
// only individually hidden keys are floored.
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getExperimental(),
(field) =>
hidden.has("instance.experimental") || hidden.has(`instance.experimental.${field}`),
);
const updated = await svc.updateExperimental(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();

View File

@ -89,9 +89,24 @@ import {
isWithinBundledPluginRoot,
} from "../services/plugin-install-guard.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { secretService } from "../services/secrets.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/**
* Floor: when the hosting operator hides the Plugins settings surface
* (`instance.plugins` in PAPERCLIP_HIDDEN_SETTINGS), plugin lifecycle and
* configuration writes are rejected alongside it. Reads stay open installed
* plugins keep running and `/plugins/ui-contributions` still powers their UI.
*/
function assertPluginManagementVisible() {
if (getHiddenSettings().has("instance.plugins")) {
throw forbidden("Plugin management is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
/** UI slot declaration extracted from plugin manifest */
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
/** Launcher declaration extracted from plugin manifest */
@ -1125,6 +1140,7 @@ export function pluginRoutes(
*/
router.post("/plugins/install", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
// Input validation
@ -1968,6 +1984,7 @@ export function pluginRoutes(
*/
router.delete("/plugins/:pluginId", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const purge = req.query.purge === "true";
@ -2004,6 +2021,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/enable", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -2042,6 +2060,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/disable", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const body = req.body as { reason?: string } | undefined;
const reason = body?.reason;
@ -2204,6 +2223,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const body = req.body as { version?: string } | undefined;
const version = body?.version;
@ -2285,6 +2305,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/config", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -2417,6 +2438,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/config/test", async (req, res) => {
assertBoardOrgAccess(req);
assertPluginManagementVisible();
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -2893,6 +2915,7 @@ export function pluginRoutes(
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
assertBoardOrgAccess(req);
assertPluginManagementVisible();
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);

View File

@ -0,0 +1,37 @@
import { parseHiddenSettingsList } from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
/**
* Operator-hidden settings, from the `PAPERCLIP_HIDDEN_SETTINGS` env var
* (comma-separated keys from the shared settings-visibility registry). Unknown
* keys are warned about once and ignored so one list can be rolled across a
* fleet of mixed app versions without refusing boot on older images.
*/
export const HIDDEN_SETTINGS_ENV_KEY = "PAPERCLIP_HIDDEN_SETTINGS";
export type HiddenSettingsEnv = Record<string, string | undefined>;
let cache: { raw: string | undefined; hidden: ReadonlySet<string> } | null = null;
/**
* Parse-once accessor keyed on the raw env value. Callers that pass a custom
* env (tests) get a fresh parse whenever the raw value differs; process.env
* callers share one parsed set for the process lifetime. Members are always
* `HideableSettingKey`s; typed as strings so route code can probe with
* computed `instance.*` keys.
*/
export function getHiddenSettings(
env: HiddenSettingsEnv = process.env,
): ReadonlySet<string> {
const raw = env[HIDDEN_SETTINGS_ENV_KEY];
if (cache && cache.raw === raw) return cache.hidden;
const { hidden, unknown } = parseHiddenSettingsList(raw);
if (unknown.length > 0) {
logger.warn(
{ unknownKeys: unknown },
`${HIDDEN_SETTINGS_ENV_KEY} contains unknown keys; they are ignored`,
);
}
cache = { raw, hidden: new Set(hidden) };
return cache.hidden;
}

View File

@ -9,6 +9,8 @@ import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGat
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate";
import { AppsExperimentalGate } from "./components/AppsExperimentalGate";
import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate";
import { useHiddenSettings } from "./hooks/useHiddenSettings";
import { Cases } from "./pages/Cases";
import { CaseDetail } from "./pages/CaseDetail";
import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
@ -96,7 +98,7 @@ import {
shouldRedirectCompanylessRouteToOnboarding,
} from "./lib/onboarding-route";
import { useCompanyMission } from "./hooks/useCompanyMission";
import { normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings";
import { filterHiddenInstanceSettingsPath, normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings";
const CompanyExport = lazy(() =>
import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })),
@ -155,17 +157,31 @@ function boardRoutes() {
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
</Route>
<Route path="company/settings/instance" element={<Navigate to="/company/settings" replace />} />
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
<Route element={<HiddenSettingsPageGate pageKey="instance.profile" />}>
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
</Route>
<Route path="company/settings/instance/general" element={<Navigate to="/company/settings" replace />} />
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
<Route path="company/settings/instance/environments/:environmentId/edit" element={<CompanyEnvironments mode="edit" />} />
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
<Route path="company/settings/instance/plugins" element={<PluginManager />} />
<Route path="company/settings/instance/plugins/:pluginId" element={<PluginSettings />} />
<Route path="company/settings/instance/adapters" element={<AdapterManager />} />
<Route element={<HiddenSettingsPageGate pageKey="instance.environments" />}>
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
<Route path="company/settings/instance/environments/:environmentId/edit" element={<CompanyEnvironments mode="edit" />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.access" />}>
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.heartbeats" />}>
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.experimental" />}>
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.plugins" />}>
<Route path="company/settings/instance/plugins" element={<PluginManager />} />
<Route path="company/settings/instance/plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="instance.adapters" />}>
<Route path="company/settings/instance/adapters" element={<AdapterManager />} />
</Route>
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
<Route path="skills/studio" element={<SkillStudio />} />
<Route path="skills/studio/new" element={<SkillStudio />} />
@ -350,6 +366,7 @@ function LegacySettingsRedirect() {
const location = useLocation();
const { companies, selectedCompany, loading } = useCompany();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const { hidden: hiddenSettings } = useHiddenSettings();
if (loading) {
return <PaperclipLoading />;
@ -375,8 +392,11 @@ function LegacySettingsRedirect() {
return <NoCompaniesStartPage />;
}
const normalizedPath = normalizeRememberedInstanceSettingsPath(
`${location.pathname}${location.search}${location.hash}`,
const normalizedPath = filterHiddenInstanceSettingsPath(
normalizeRememberedInstanceSettingsPath(
`${location.pathname}${location.search}${location.hash}`,
),
hiddenSettings,
);
return (

View File

@ -36,6 +36,11 @@ export type HealthStatus = {
serverInfo?: ServerInfoSnapshot;
devServer?: DevServerHealthStatus;
cloud?: CloudInstanceHealthStatus;
/**
* Settings surfaces hidden by the hosting operator (keys from the shared
* settings-visibility registry). Absent when nothing is hidden.
*/
hiddenSettings?: string[];
};
export const healthApi = {

View File

@ -3,6 +3,7 @@
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
@ -353,3 +354,64 @@ describe("CompanySettingsSidebar", () => {
});
});
});
describe("CompanySettingsSidebar operator-hidden entries", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockSidebarBadgesApi.get.mockResolvedValue({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 0,
});
mockPluginsApi.list.mockResolvedValue([]);
mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false, errorMessage: null });
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderSidebar(hiddenSettings?: string[]) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, {
status: "ok",
...(hiddenSettings ? { hiddenSettings } : {}),
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
}
it("skips operator-hidden pages and their queries", async () => {
await renderSidebar(["instance.plugins", "instance.heartbeats"]);
expect(container.textContent).not.toContain("Plugins");
expect(container.textContent).not.toContain("Heartbeats");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Adapters");
expect(container.textContent).toContain("Access");
expect(mockPluginsApi.list).not.toHaveBeenCalled();
});
it("keeps every entry when nothing is hidden", async () => {
await renderSidebar();
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Plugins");
expect(container.textContent).toContain("Heartbeats");
expect(container.textContent).toContain("Adapters");
expect(mockPluginsApi.list).toHaveBeenCalled();
});
});

View File

@ -25,6 +25,7 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { usePluginSlots } from "@/plugins/slots";
import { SidebarNavItem } from "./SidebarNavItem";
@ -43,6 +44,9 @@ function isSandboxProviderOnly(plugin: PluginRecord): boolean {
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { hidden: hiddenSettings } = useHiddenSettings();
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
const showPlugins = showPage("instance.plugins");
const { slots: companySettingsPluginSlots } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
@ -69,6 +73,9 @@ export function CompanySettingsSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
// The listing only feeds the per-plugin subtree below; skip it when the
// operator hides the Plugins surface.
enabled: showPlugins,
});
const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin));
@ -90,12 +97,14 @@ export function CompanySettingsSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`}
label="Profile"
icon={UserRoundPen}
end
/>
{showPage("instance.profile") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`}
label="Profile"
icon={UserRoundPen}
end
/>
)}
<SidebarNavItem
to="/company/settings/members"
label="Members"
@ -116,37 +125,47 @@ export function CompanySettingsSidebar() {
))}
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
label="Environments"
icon={MonitorCog}
end
/>
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
label="Access"
icon={Shield}
end
/>
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`}
label="Heartbeats"
icon={Clock3}
end
/>
{showPage("instance.environments") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
label="Environments"
icon={MonitorCog}
end
/>
)}
{showPage("instance.access") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
label="Access"
icon={Shield}
end
/>
)}
{showPage("instance.heartbeats") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`}
label="Heartbeats"
icon={Clock3}
end
/>
)}
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}
label="Experimental"
icon={FlaskConical}
/>
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`}
label="Plugins"
icon={Puzzle}
/>
{sidebarPlugins.length > 0 ? (
{showPage("instance.experimental") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}
label="Experimental"
icon={FlaskConical}
/>
)}
{showPlugins && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`}
label="Plugins"
icon={Puzzle}
/>
)}
{showPlugins && sidebarPlugins.length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{sidebarPlugins.map((plugin) => (
<NavLink
@ -167,11 +186,13 @@ export function CompanySettingsSidebar() {
))}
</div>
) : null}
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/adapters`}
label="Adapters"
icon={Cpu}
/>
{showPage("instance.adapters") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/adapters`}
label="Adapters"
icon={Cpu}
/>
)}
</div>
</nav>
</aside>

View File

@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { HiddenSettingsPageGate } from "./HiddenSettingsPageGate";
vi.mock("@/lib/router", () => ({
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
),
Outlet: () => <div data-testid="page-content">Page content</div>,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("HiddenSettingsPageGate", () => {
let container: HTMLDivElement;
let root: Root | null = null;
async function renderGate(pageKey: string, health?: Record<string, unknown>) {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (health !== undefined) {
queryClient.setQueryData(queryKeys.health, health);
}
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<HiddenSettingsPageGate pageKey={pageKey} />
</QueryClientProvider>,
);
});
await flushReact();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("redirects to the settings root when the page is operator-hidden", async () => {
await renderGate("instance.plugins", {
status: "ok",
hiddenSettings: ["instance.plugins", "instance.adapters"],
});
expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe(
"/company/settings",
);
expect(container.querySelector('[data-testid="page-content"]')).toBeNull();
});
it("renders the page when it is not hidden", async () => {
await renderGate("instance.plugins", { status: "ok", hiddenSettings: ["instance.access"] });
expect(container.querySelector('[data-testid="page-content"]')).not.toBeNull();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
});
it("renders the page when nothing is hidden at all", async () => {
await renderGate("instance.adapters", { status: "ok" });
expect(container.querySelector('[data-testid="page-content"]')).not.toBeNull();
});
it("renders nothing until health is cached", async () => {
await renderGate("instance.plugins");
expect(container.querySelector('[data-testid="page-content"]')).toBeNull();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
});
});

View File

@ -0,0 +1,17 @@
import { Navigate, Outlet } from "@/lib/router";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
/**
* Route gate for instance-settings pages the hosting operator can hide
* (`instance.access`, `instance.plugins`, `instance.adapters`). Hidden pages
* redirect to the settings root instead of rendering; until health is cached
* nothing renders, so a hidden page never flashes. Under CloudAccessGate the
* health response is always cached before board routes mount.
*/
export function HiddenSettingsPageGate({ pageKey }: { pageKey: string }) {
const { hidden, loaded } = useHiddenSettings();
if (!loaded) return null;
if (hidden.has(pageKey)) return <Navigate to="/company/settings" replace />;
return <Outlet />;
}

View File

@ -1,288 +0,0 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRecord } from "@paperclipai/shared";
const mockPluginsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/lib/router", () => ({
NavLink: ({
children,
to,
className,
}: {
children: ReactNode | ((arg: { isActive: boolean }) => ReactNode);
to: string;
state?: unknown;
end?: boolean;
onClick?: () => void;
className?: string | ((arg: { isActive: boolean }) => string);
}) => {
const resolvedClass =
typeof className === "function" ? className({ isActive: false }) : className;
const content = typeof children === "function" ? children({ isActive: false }) : children;
return (
<a href={to} className={resolvedClass}>
{content}
</a>
);
},
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
import { InstanceSidebar } from "./InstanceSidebar";
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
function makePlugin(overrides: Partial<PluginRecord> & { manifestJson: PluginRecord["manifestJson"] }): PluginRecord {
return {
id: overrides.id ?? "plugin-id",
pluginKey: overrides.pluginKey ?? "plugin-key",
packageName: overrides.packageName ?? "@scope/pkg",
version: overrides.version ?? "1.0.0",
apiVersion: overrides.apiVersion ?? 1,
categories: overrides.categories ?? [],
manifestJson: overrides.manifestJson,
status: overrides.status ?? "ready",
installOrder: overrides.installOrder ?? 0,
packagePath: overrides.packagePath ?? null,
lastError: overrides.lastError ?? null,
installedAt: overrides.installedAt ?? new Date(0),
updatedAt: overrides.updatedAt ?? new Date(0),
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function findPluginLinks(container: HTMLElement, expectedCount: number) {
await act(async () => {
await vi.waitFor(() => {
expect(container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]')).toHaveLength(expectedCount);
});
});
return Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/company/settings/instance/plugins/"]'));
}
function renderSidebar(container: HTMLElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<InstanceSidebar />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("InstanceSidebar", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = null;
mockPluginsApi.list.mockReset();
});
afterEach(async () => {
if (root) {
const currentRoot = root;
await act(async () => {
currentRoot.unmount();
});
}
queryClient?.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("filters out sandbox-provider-only plugins from the sidebar", async () => {
const sandboxPlugin = makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B Sandbox Provider",
displayName: "E2B Sandbox Provider",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
const regularPlugin = makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([sandboxPlugin, regularPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/company/settings/instance/plugins/linear");
expect(pluginLinks[0]?.textContent).toBe("Linear");
expect(container.textContent).not.toContain("Access");
expect(container.textContent).not.toContain("Heartbeats");
});
it("keeps plugins that mix sandbox-provider with other contributions", async () => {
const hybridPlugin = makePlugin({
id: "hybrid",
packageName: "@example/plugin-hybrid",
manifestJson: {
id: "hybrid",
name: "Hybrid",
displayName: "Hybrid",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "sb",
kind: "sandbox_provider",
displayName: "SB",
configSchema: { type: "object" },
},
{
driverKey: "env",
kind: "environment_driver",
displayName: "Env",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
});
mockPluginsApi.list.mockResolvedValue([hybridPlugin]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
const pluginLinks = await findPluginLinks(container, 1);
expect(pluginLinks[0]?.getAttribute("href")).toBe("/company/settings/instance/plugins/hybrid");
});
it("renders the indented plugin list between the Plugins and Adapters rows", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "linear",
packageName: "@paperclipai/plugin-linear",
manifestJson: {
id: "linear",
name: "Linear",
displayName: "Linear",
version: "1.0.0",
apiVersion: 1,
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await findPluginLinks(container, 1);
await vi.waitFor(() => {
const links = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a[href^="/company/settings/instance/"]'),
);
expect(links.some((a) => a.getAttribute("href") === "/company/settings/instance/plugins/linear")).toBe(true);
});
const topLevelLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('a[href^="/company/settings/instance/"]'));
const hrefs = topLevelLinks.map((a) => a.getAttribute("href"));
const pluginsIndex = hrefs.indexOf("/company/settings/instance/plugins");
const adaptersIndex = hrefs.indexOf("/company/settings/instance/adapters");
const linearIndex = hrefs.indexOf("/company/settings/instance/plugins/linear");
expect(pluginsIndex).toBeGreaterThanOrEqual(0);
expect(adaptersIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeGreaterThan(pluginsIndex);
expect(linearIndex).toBeLessThan(adaptersIndex);
});
it("does not render the indented group when every plugin is filtered out", async () => {
mockPluginsApi.list.mockResolvedValue([
makePlugin({
id: "e2b",
packageName: "@paperclipai/plugin-e2b",
manifestJson: {
id: "e2b",
name: "E2B",
displayName: "E2B",
version: "1.0.0",
apiVersion: 1,
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B",
configSchema: { type: "object" },
},
],
} as unknown as PluginRecord["manifestJson"],
}),
]);
const rendered = renderSidebar(container);
root = rendered.root;
queryClient = rendered.queryClient;
await flushReact();
await vi.waitFor(() => {
expect(mockPluginsApi.list).toHaveBeenCalled();
});
const pluginLinks = Array.from(container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]'));
expect(pluginLinks).toHaveLength(0);
});
});

View File

@ -1,66 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Cpu, FlaskConical, MonitorCog, Puzzle, SlidersHorizontal, UserRoundPen } from "lucide-react";
import type { PluginRecord } from "@paperclipai/shared";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem";
/**
* Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin
* settings page `PluginSettings` redirects them to the Environments page
* so a sidebar entry would lead nowhere useful. Filter them out here. Plugins
* that mix a sandbox provider with other contributions still appear.
*/
function isSandboxProviderOnly(plugin: PluginRecord): boolean {
const drivers = plugin.manifestJson.environmentDrivers ?? [];
if (drivers.length === 0) return false;
return drivers.every((d) => d.kind === "sandbox_provider");
}
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const sidebarPlugins = (plugins ?? []).filter((p) => !isSandboxProviderOnly(p));
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`} label="Profile" icon={UserRoundPen} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/general`} label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`} label="Environments" icon={MonitorCog} end />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`} label="Experimental" icon={FlaskConical} />
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`} label="Plugins" icon={Puzzle} />
{sidebarPlugins.length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{sidebarPlugins.map((plugin) => (
<NavLink
key={plugin.id}
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins/${plugin.id}`}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/adapters`} label="Adapters" icon={Cpu} />
</div>
</nav>
</aside>
);
}

View File

@ -2,7 +2,9 @@
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { CompanySettingsNav, getCompanySettingsTab } from "./CompanySettingsNav";
let currentPathname = "/company/settings";
@ -89,12 +91,25 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters");
});
function renderNav(root: ReturnType<typeof createRoot>, hiddenSettings?: string[]) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, {
status: "ok",
...(hiddenSettings ? { hiddenSettings } : {}),
});
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsNav />
</QueryClientProvider>,
);
}
it("renders the active tab and navigates when a different tab is selected", async () => {
currentPathname = "/PAP/company/settings/members";
const root = createRoot(container);
await act(async () => {
root.render(<CompanySettingsNav />);
renderNav(root);
});
expect(container.textContent).toContain("members");
@ -132,4 +147,34 @@ describe("CompanySettingsNav", () => {
root.unmount();
});
});
it("filters operator-hidden tabs out of the tab bar", async () => {
currentPathname = "/PAP/company/settings/members";
const root = createRoot(container);
await act(async () => {
renderNav(root, ["instance.plugins", "instance.heartbeats"]);
});
const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map(
(item: { value: string }) => item.value,
);
expect(renderedValues).toEqual([
"general",
"export",
"import",
"members",
"invites",
"secrets",
"instance-profile",
"instance-environments",
"instance-access",
"instance-experimental",
"instance-adapters",
]);
await act(async () => {
root.unmount();
});
});
});

View File

@ -1,5 +1,6 @@
import { PageTabBar } from "@/components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
import { useLocation, useNavigate } from "@/lib/router";
@ -21,6 +22,17 @@ const items = [
type CompanySettingsTab = (typeof items)[number]["value"];
/** Tab values suppressed when their page is operator-hidden. */
const hiddenSettingKeyByTab: Partial<Record<CompanySettingsTab, string>> = {
"instance-profile": "instance.profile",
"instance-environments": "instance.environments",
"instance-access": "instance.access",
"instance-heartbeats": "instance.heartbeats",
"instance-experimental": "instance.experimental",
"instance-plugins": "instance.plugins",
"instance-adapters": "instance.adapters",
};
export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`)) {
return "instance-profile";
@ -84,10 +96,15 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
export function CompanySettingsNav() {
const location = useLocation();
const navigate = useNavigate();
const { hidden: hiddenSettings } = useHiddenSettings();
const activeTab = getCompanySettingsTab(location.pathname);
const visibleItems = items.filter((item) => {
const hiddenKey = hiddenSettingKeyByTab[item.value];
return !hiddenKey || !hiddenSettings.has(hiddenKey);
});
function handleTabChange(value: string) {
const nextTab = items.find((item) => item.value === value);
const nextTab = visibleItems.find((item) => item.value === value);
if (!nextTab || nextTab.value === activeTab) return;
navigate(nextTab.href);
}
@ -95,7 +112,7 @@ export function CompanySettingsNav() {
return (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageTabBar
items={items.map(({ value, label }) => ({ value, label }))}
items={visibleItems.map(({ value, label }) => ({ value, label }))}
value={activeTab}
onValueChange={handleTabChange}
align="start"

View File

@ -0,0 +1,27 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { healthApi } from "@/api/health";
import { queryKeys } from "@/lib/queryKeys";
/**
* Settings surfaces hidden by the hosting operator, from the app-wide health
* query cache (keys from the shared settings-visibility registry, e.g.
* "instance.plugins" or "instance.experimental.enableSmokeLab").
* CloudAccessGate owns the fetch; this observer never issues its own request.
*
* `loaded` is false until the health response is in the cache gates should
* render nothing rather than flash a surface that may turn out to be hidden.
*/
export function useHiddenSettings(): { hidden: ReadonlySet<string>; loaded: boolean } {
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
enabled: false,
});
const data = healthQuery.data;
return useMemo(
() => ({ hidden: new Set(data?.hiddenSettings ?? []), loaded: data !== undefined }),
[data],
);
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
filterHiddenInstanceSettingsPath,
normalizeRememberedInstanceSettingsPath,
} from "./instance-settings";
@ -48,3 +49,29 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
});
describe("filterHiddenInstanceSettingsPath", () => {
const hidden = new Set(["instance.plugins", "instance.heartbeats"]);
it("remaps hidden pages (including sub-paths) to the default settings path", () => {
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/plugins", hidden),
).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/plugins/plugin-1", hidden),
).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/heartbeats", hidden),
).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
it("keeps visible pages and non-settings paths untouched", () => {
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/experimental", hidden),
).toBe("/company/settings/instance/experimental");
expect(filterHiddenInstanceSettingsPath("/dashboard", hidden)).toBe("/dashboard");
expect(
filterHiddenInstanceSettingsPath("/company/settings/instance/plugins", new Set()),
).toBe("/company/settings/instance/plugins");
});
});

View File

@ -79,3 +79,23 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
/**
* Remaps a remembered instance-settings path onto the settings root when its
* page is hidden by the hosting operator (`hidden` holds keys from the shared
* settings-visibility registry). Saves a redirect hop; the route-level
* HiddenSettingsPageGate stays the enforcement point.
*/
export function filterHiddenInstanceSettingsPath(
normalizedPath: string,
hidden: ReadonlySet<string>,
): string {
const { pathname } = splitPath(normalizedPath);
const suffix = instanceSettingsSuffix(pathname);
if (!suffix) return normalizedPath;
const page = suffix.split("/")[1];
if (page && page !== "general" && hidden.has(`instance.${page}`)) {
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
return normalizedPath;
}

View File

@ -931,3 +931,55 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)"
expect(badges).not.toContain("Experimental");
});
});
describe("InstanceExperimentalSettings — operator-hidden cards", () => {
let container: HTMLDivElement;
let root: Root | null = null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
queryClient?.clear();
container.remove();
vi.clearAllMocks();
});
async function renderPage(hiddenSettings?: string[]) {
mockInstanceSettingsApi.getExperimental.mockResolvedValue(defaultExperimentalSettings());
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, {
status: "ok",
...(hiddenSettings ? { hiddenSettings } : {}),
});
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<InstanceExperimentalSettings />
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders nothing for an operator-hidden toggle and keeps the rest", async () => {
await renderPage(["instance.experimental.enableEnvironments"]);
expect(container.textContent).not.toContain("Enable Environments");
expect(container.textContent).toContain("Beta skills");
expect(container.textContent).toContain("Task Watchdogs");
});
it("shows every toggle when nothing is hidden", async () => {
await renderPage();
expect(container.textContent).toContain("Enable Environments");
expect(container.textContent).toContain("Beta skills");
});
});

View File

@ -4,11 +4,14 @@ import { AlertTriangle, Clock, FlaskConical, Lock, Play, Search } from "lucide-r
import type {
InstanceExperimentalSettings,
InstanceExperimentalSettingsWithManaged,
InstanceFeatureKey,
IssueGraphLivenessAutoRecoveryPreview,
ManagedSettingMetadata,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { experimentalSettingKey } from "@paperclipai/shared";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@ -92,6 +95,7 @@ function ExperimentalToggleCard({
checked,
onCheckedChange,
disabled,
settingKey,
managed,
ariaLabel,
}: {
@ -101,10 +105,14 @@ function ExperimentalToggleCard({
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
/** Flag key backing this card; operator-hidden keys render nothing. */
settingKey: InstanceFeatureKey;
managed?: ManagedSettingMetadata;
ariaLabel: string;
}) {
const { hidden: hiddenSettings } = useHiddenSettings();
const isManaged = managed?.managed === true;
if (hiddenSettings.has(experimentalSettingKey(settingKey))) return null;
return (
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
@ -465,6 +473,7 @@ export function InstanceExperimentalSettings() {
checked={enableApps}
onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })}
disabled={toggleMutation.isPending}
settingKey="enableApps"
managed={managedKeys.enableApps}
ariaLabel="Toggle apps experimental setting"
/>
@ -565,6 +574,7 @@ export function InstanceExperimentalSettings() {
checked={autoRestartDevServerWhenIdle}
onCheckedChange={(checked) => toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked })}
disabled={toggleMutation.isPending}
settingKey="autoRestartDevServerWhenIdle"
managed={managedKeys.autoRestartDevServerWhenIdle}
ariaLabel="Toggle guarded dev-server auto-restart"
/>
@ -575,6 +585,7 @@ export function InstanceExperimentalSettings() {
checked={enableBetaSkills}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBetaSkills: checked })}
disabled={toggleMutation.isPending}
settingKey="enableBetaSkills"
managed={managedKeys.enableBetaSkills}
ariaLabel="Toggle beta skills experimental setting"
/>
@ -585,6 +596,7 @@ export function InstanceExperimentalSettings() {
checked={enableBuiltInAgents}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBuiltInAgents: checked })}
disabled={toggleMutation.isPending}
settingKey="enableBuiltInAgents"
managed={managedKeys.enableBuiltInAgents}
ariaLabel="Toggle built-in agents experimental setting"
/>
@ -596,6 +608,7 @@ export function InstanceExperimentalSettings() {
checked={enableCases}
onCheckedChange={(checked) => toggleMutation.mutate({ enableCases: checked })}
disabled={toggleMutation.isPending}
settingKey="enableCases"
managed={managedKeys.enableCases}
ariaLabel="Toggle cases experimental setting"
/>
@ -607,6 +620,7 @@ export function InstanceExperimentalSettings() {
checked={enableClassicTaskInterface}
onCheckedChange={(checked) => toggleMutation.mutate({ enableClassicTaskInterface: checked })}
disabled={toggleMutation.isPending}
settingKey="enableClassicTaskInterface"
managed={managedKeys.enableClassicTaskInterface}
ariaLabel="Toggle classic task interface experimental setting"
/>
@ -618,6 +632,7 @@ export function InstanceExperimentalSettings() {
checked={enableConferenceRoomChat}
onCheckedChange={(checked) => toggleMutation.mutate({ enableConferenceRoomChat: checked })}
disabled={toggleMutation.isPending}
settingKey="enableConferenceRoomChat"
managed={managedKeys.enableConferenceRoomChat}
ariaLabel="Toggle conference room chat experimental setting"
/>
@ -629,6 +644,7 @@ export function InstanceExperimentalSettings() {
checked={enableDecisions}
onCheckedChange={(checked) => toggleMutation.mutate({ enableDecisions: checked })}
disabled={toggleMutation.isPending}
settingKey="enableDecisions"
managed={managedKeys.enableDecisions}
ariaLabel="Toggle decisions experimental setting"
/>
@ -639,6 +655,7 @@ export function InstanceExperimentalSettings() {
checked={enableEnvironments}
onCheckedChange={(checked) => toggleMutation.mutate({ enableEnvironments: checked })}
disabled={toggleMutation.isPending}
settingKey="enableEnvironments"
managed={managedKeys.enableEnvironments}
ariaLabel="Toggle environments experimental setting"
/>
@ -649,6 +666,7 @@ export function InstanceExperimentalSettings() {
checked={enableExternalObjects}
onCheckedChange={(checked) => toggleMutation.mutate({ enableExternalObjects: checked })}
disabled={toggleMutation.isPending}
settingKey="enableExternalObjects"
managed={managedKeys.enableExternalObjects}
ariaLabel="Toggle external objects experimental setting"
/>
@ -659,6 +677,7 @@ export function InstanceExperimentalSettings() {
checked={enableIsolatedWorkspaces}
onCheckedChange={(checked) => toggleMutation.mutate({ enableIsolatedWorkspaces: checked })}
disabled={toggleMutation.isPending}
settingKey="enableIsolatedWorkspaces"
managed={managedKeys.enableIsolatedWorkspaces}
ariaLabel="Toggle isolated workspaces experimental setting"
/>
@ -669,6 +688,7 @@ export function InstanceExperimentalSettings() {
checked={enableExperimentalFileViewer}
onCheckedChange={(checked) => toggleMutation.mutate({ enableExperimentalFileViewer: checked })}
disabled={toggleMutation.isPending}
settingKey="enableExperimentalFileViewer"
managed={managedKeys.enableExperimentalFileViewer}
ariaLabel="Toggle experimental file viewer setting"
/>
@ -679,6 +699,7 @@ export function InstanceExperimentalSettings() {
checked={enableGoalsSidebarLink}
onCheckedChange={(checked) => toggleMutation.mutate({ enableGoalsSidebarLink: checked })}
disabled={toggleMutation.isPending}
settingKey="enableGoalsSidebarLink"
managed={managedKeys.enableGoalsSidebarLink}
ariaLabel="Toggle goals sidebar link experimental setting"
/>
@ -689,6 +710,7 @@ export function InstanceExperimentalSettings() {
checked={enableManagedSandboxOnly}
onCheckedChange={(checked) => toggleMutation.mutate({ enableManagedSandboxOnly: checked })}
disabled={toggleMutation.isPending}
settingKey="enableManagedSandboxOnly"
managed={managedKeys.enableManagedSandboxOnly}
ariaLabel="Toggle managed sandbox only experimental setting"
/>
@ -756,6 +778,7 @@ export function InstanceExperimentalSettings() {
checked={enableServerInfoDebugView}
onCheckedChange={(checked) => toggleMutation.mutate({ enableServerInfoDebugView: checked })}
disabled={toggleMutation.isPending}
settingKey="enableServerInfoDebugView"
managed={managedKeys.enableServerInfoDebugView}
ariaLabel="Toggle server info debug view experimental setting"
/>
@ -768,6 +791,7 @@ export function InstanceExperimentalSettings() {
toggleMutation.mutate({ enableSimplifiedEnglishInteractions: checked })
}
disabled={toggleMutation.isPending}
settingKey="enableSimplifiedEnglishInteractions"
managed={managedKeys.enableSimplifiedEnglishInteractions}
ariaLabel="Toggle simplified english interactions experimental setting"
/>
@ -778,6 +802,7 @@ export function InstanceExperimentalSettings() {
checked={enableSmokeLab}
onCheckedChange={(checked) => toggleMutation.mutate({ enableSmokeLab: checked })}
disabled={toggleMutation.isPending}
settingKey="enableSmokeLab"
managed={managedKeys.enableSmokeLab}
ariaLabel="Toggle smoke lab experimental setting"
/>
@ -795,6 +820,7 @@ export function InstanceExperimentalSettings() {
)
}
disabled={toggleMutation.isPending || statusCardsBlockedByManagedSummaries}
settingKey="enableStatusCards"
managed={managedKeys.enableStatusCards}
ariaLabel="Toggle status cards experimental setting"
/>
@ -812,6 +838,7 @@ export function InstanceExperimentalSettings() {
)
}
disabled={toggleMutation.isPending || summariesRequiredByManagedStatusCards}
settingKey="enableSummaries"
managed={managedKeys.enableSummaries}
ariaLabel="Toggle summaries experimental setting"
/>
@ -822,6 +849,7 @@ export function InstanceExperimentalSettings() {
checked={enableIssuePlanDecompositions}
onCheckedChange={(checked) => toggleMutation.mutate({ enableIssuePlanDecompositions: checked })}
disabled={toggleMutation.isPending}
settingKey="enableIssuePlanDecompositions"
managed={managedKeys.enableIssuePlanDecompositions}
ariaLabel="Toggle task plan decomposition panel experimental setting"
/>
@ -832,6 +860,7 @@ export function InstanceExperimentalSettings() {
checked={enableTaskWatchdogs}
onCheckedChange={(checked) => toggleMutation.mutate({ enableTaskWatchdogs: checked })}
disabled={toggleMutation.isPending}
settingKey="enableTaskWatchdogs"
managed={managedKeys.enableTaskWatchdogs}
ariaLabel="Toggle task watchdogs experimental setting"
/>

View File

@ -205,3 +205,67 @@ describe("InstanceGeneralSettings sign-out", () => {
await vi.waitFor(() => expect(signOutButton()?.disabled).toBe(false));
});
});
describe("InstanceGeneralSettings operator-hidden sections", () => {
let container: HTMLDivElement;
let root: Root | null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "not_allowed",
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
});
});
afterEach(() => {
flushSync(() => root?.unmount());
queryClient.clear();
container.remove();
vi.clearAllMocks();
});
async function renderPage(health: Record<string, unknown>) {
mockHealthApi.get.mockResolvedValue(health);
queryClient.setQueryData(queryKeys.health, health);
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<InstanceGeneralSettings />
</QueryClientProvider>,
);
});
await vi.waitFor(() => expect(container.textContent).toContain("Keyboard shortcuts"));
}
it("hides an operator-hidden field-backed section and a UI-only section", async () => {
await renderPage({
...SELF_HOSTED_HEALTH,
hiddenSettings: [
"instance.general.censorUsernameInLogs",
"instance.general.deploymentStatus",
],
});
expect(container.textContent).not.toContain("Censor username in logs");
expect(container.textContent).not.toContain("Deployment and auth");
expect(container.textContent).toContain("Backup retention");
expect(container.textContent).toContain("AI feedback sharing");
expect(container.textContent).toContain("Sign out");
});
it("shows every section when nothing is hidden", async () => {
await renderPage(SELF_HOSTED_HEALTH);
expect(container.textContent).toContain("Deployment and auth");
expect(container.textContent).toContain("Censor username in logs");
expect(container.textContent).toContain("Backup retention");
});
});

View File

@ -61,7 +61,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
},
});
if (generalQuery.isLoading) {
if (generalQuery.isLoading || healthQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
@ -79,6 +79,22 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION;
const hiddenSettings = new Set(healthQuery.data?.hiddenSettings ?? []);
const showDeploymentStatus = !hiddenSettings.has("instance.general.deploymentStatus");
const showCensorUsernameInLogs = !hiddenSettings.has("instance.general.censorUsernameInLogs");
const showKeyboardShortcuts = !hiddenSettings.has("instance.general.keyboardShortcuts");
const showBackupRetention = !hiddenSettings.has("instance.general.backupRetention");
const showFeedbackDataSharing = !hiddenSettings.has("instance.general.feedbackDataSharingPreference");
const showSignOut = !hiddenSettings.has("instance.general.signOut");
const visibleTopics = [
...(showCensorUsernameInLogs ? ["log display"] : []),
...(showKeyboardShortcuts ? ["keyboard shortcuts"] : []),
...(showBackupRetention ? ["backup retention"] : []),
...(showFeedbackDataSharing ? ["data sharing"] : []),
];
const topicSummary = visibleTopics.length > 2
? `${visibleTopics.slice(0, -1).join(", ")}, and ${visibleTopics[visibleTopics.length - 1]}`
: visibleTopics.join(" and ");
const visibleActionError = signOutMutation.error instanceof Error
? signOutMutation.error.message
: signOutMutation.error
@ -94,8 +110,8 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide preferences including log display, keyboard shortcuts, backup
retention, and data sharing.
Configure instance-wide preferences
{visibleTopics.length > 0 ? <> including {topicSummary}</> : null}.
</p>
</div>
) : null}
@ -106,6 +122,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
</div>
)}
{showDeploymentStatus && (
<section>
<div className="space-y-3">
<div className="flex items-center gap-2">
@ -138,7 +155,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
</div>
</div>
</section>
)}
{showCensorUsernameInLogs && (
<section>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
@ -157,7 +176,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
/>
</div>
</section>
)}
{showKeyboardShortcuts && (
<section>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
@ -175,7 +196,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
/>
</div>
</section>
)}
{showBackupRetention && (
<section>
<div className="space-y-5">
<div className="space-y-1.5">
@ -277,7 +300,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
</div>
</div>
</section>
)}
{showFeedbackDataSharing && (
<section>
<div className="space-y-4">
<div className="space-y-1.5">
@ -354,6 +379,9 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
</div>
</section>
)}
{showSignOut && (
<section>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
@ -376,6 +404,7 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole
</Button>
</div>
</section>
)}
</div>
);
}