feat: hideable company settings pages, with import floored on cloud-managed instances (#12199)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The app can run self-hosted or as a cloud-managed instance, where a
hosting platform provisions the instance with its company already
materialized (the existing `isCloudManagedInstance()` predicate and
`cloud_managed` floors)
> - The company Import/Export surface lets an operator materialize whole
companies from an export bundle; on a cloud-managed instance this
bypasses the existing `cloud_managed` company-creation floor and
conflicts with platform-owned provisioning
> - Importing should be disabled on cloud-managed instances, while
export stays open as the data-portability escape hatch
> - This pull request floors every import route with 403
`code=cloud_managed` on cloud-managed instances and hides the Import UI
there, using the existing predicate and the established floor pattern
> - It also extends the operator-hidden settings registry with keys for
every top-level company settings page, so a hosting operator can hide
any of them with `PAPERCLIP_HIDDEN_SETTINGS` alone next time
> - The benefit is one consistent managed-instance policy: cloud-managed
instances cannot import companies, self-hosted installs keep the full
import surface unchanged
## Linked Issues or Issue Description
No public issue exists; the underlying problem follows the enhancement
template.
**What existing behavior does this improve?**
The company import surface (`/api/companies/import*`,
`/api/companies/:companyId/imports/*`) and its UI entry points on
cloud-managed instances.
**Subsystem affected**
Server routes (`server/src/routes/companies.ts`) and UI navigation/pages
(settings sidebar, settings tabs, org chart, `/company/import` route).
**Current behavior**
A cloud-managed instance floors direct company creation (`POST
/api/companies` answers 403 `cloud_managed`), but the import routes
still accept company bundles, so an import can materialize companies the
hosting platform did not provision. The UI offers Import entry points
that lead to a surface that is not available on cloud-managed instances.
**Proposed behavior**
On instances where `isCloudManagedInstance()` is true, every import
route answers 403 `code=cloud_managed` before auth and body work, and
the Import UI (sidebar entry, settings tab, org-chart button,
`/company/import` route) is hidden or redirected. Export remains fully
available. Self-hosted instances are unchanged.
**Reason and benefit**
Cloud-managed instances keep one consistent provisioning authority, and
users do not see an Import surface that dead-ends in a 403.
## What Changed
- `server/src/routes/companies.ts`: a router-level floor mounted at the
`/import` and `/:companyId/imports` prefixes. It covers the single-shot
upload, preview, job polling, chunked transfer
declare/part-upload/status/preview/apply, and the agent-safe per-company
import routes. It throws `forbidden(..., { code: "cloud_managed" })` on
cloud-managed instances, or `403 settings_operator_managed` when the
operator hides `company.import` — both before auth and body validation,
mirroring the company-creation floor.
- `packages/shared/src/settings-visibility.ts`: new
`HIDEABLE_COMPANY_PAGES` registry group — `company.members`,
`company.invites`, `company.secrets`, `company.export`, `company.import`
— with a `hidesCompanyPage` helper. The company General page stays
non-hideable (settings root). `company.import` floors its API; the other
keys are UI-visibility only, as documented in the registry, so
membership/invite/secret/export APIs stay live for agents.
- `ui/src/components/CloudManagedPageGate.tsx` (new): route gate that
redirects cloud-managed instances to `/company/settings`, modeled on
`HiddenSettingsPageGate`.
- `ui/src/App.tsx`: wraps the `company/import` route in
`CloudManagedPageGate`.
- `ui/src/components/CompanySettingsSidebar.tsx`,
`ui/src/components/access/CompanySettingsNav.tsx`,
`ui/src/pages/OrgChart.tsx`: hide the Import entry points when
`useCloudInstance()` reports a managed instance, and honor the new
`company.*` hidden-settings keys for every company page entry (sidebar
item, tab, org-chart buttons).
- `ui/src/App.tsx`: `HiddenSettingsPageGate` route gates for the members
(incl. the legacy access route), invites, secrets, export, and import
pages under their `company.*` keys.
- `docs/deploy/environment-variables.md`: documents the new keys and
their semantics; the CLI and board-operator guides note that import is
unavailable on cloud-managed instances.
- Tests: new `server/src/__tests__/company-import-cloud-floor.test.ts`
and `ui/src/components/CloudManagedPageGate.test.tsx`, registry cases in
`packages/shared/src/settings-visibility.test.ts`, plus cloud and
hidden-key cases in the sidebar, settings-nav, and org-chart suites.
## Verification
- TypeScript typechecks pass for every workspace package (`tsc` in
shared, server, ui; the runner's Rust leg needs a local cargo toolchain
and is covered by CI).
- `pnpm test` on this branch fails only in 9 files that also fail on a
clean `origin/master` checkout on the same machine
(environment-dependent suites: live-listener probes,
workspace/native-runtime spawns, skill materialization). Zero
branch-only failures against that baseline; every suite touched by this
change passes.
- `server/src/__tests__/company-import-cloud-floor.test.ts` asserts:
every import route answers 403 `cloud_managed` under the server-token
signal; the managed-config signal alone also floors; every import route
answers 403 `settings_operator_managed` when `company.import` is hidden;
hiding other company pages leaves import open; the floor applies before
auth and body validation; export stays open on cloud-managed instances;
self-hosted import preview and job polling still work.
- `packages/shared/src/settings-visibility.test.ts` covers the new
`company.*` keys and `hidesCompanyPage`.
- UI suites assert the Import tab, sidebar entry, and org-chart button
disappear on a cloud-managed instance while Export stays, that
`/company/import` redirects through the gate, and that the `company.*`
keys hide their sidebar entries and tabs.
## Risks
- Low risk for self-hosted installs: the floor is inert unless a cloud
signal (`PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` or
`PAPERCLIP_MANAGED_CONFIG`) is present, and the self-hosted paths are
regression-tested.
- On cloud-managed instances this is a deliberate behavioral removal:
import (including agent-driven safe imports and resumable transfers)
stops working the moment an instance runs this build. In-flight chunked
transfers on such instances cannot be applied afterward; they answer
403.
- CLI import commands against a cloud-managed instance now fail with the
`cloud_managed` error; the message names the reason.
- The new `company.*` keys change nothing unless an operator sets them:
`PAPERCLIP_HIDDEN_SETTINGS` unset keeps behavior identical, and older
images ignore unknown keys by design. The four non-import company keys
hide UI only; their APIs stay live, which the registry documents
explicitly.
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic
coding session with tool use (code search, editing, local test
execution).
## 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:
parent
a5c2add7be
commit
9c03443c48
|
|
@ -57,6 +57,10 @@ npx paperclipai company import \
|
|||
--include company,agents
|
||||
```
|
||||
|
||||
`company import` is unavailable against cloud-managed instances — the
|
||||
server answers `403` with `code: "cloud_managed"`. Export remains available
|
||||
there.
|
||||
|
||||
With agent authentication, use `company list` or `company current` to resolve
|
||||
the scoped company. `company list` first tries the board-wide list; if that is
|
||||
forbidden, it falls back to `--company-id`, `PAPERCLIP_COMPANY_ID`, context, or
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ All environment variables that Paperclip uses for server configuration.
|
|||
- Any experimental toggle: `instance.experimental.<flagKey>` (e.g.
|
||||
`instance.experimental.enableSmokeLab`) — the card disappears and
|
||||
value-changing writes are rejected.
|
||||
- Any top-level company settings page: `company.members`, `company.invites`,
|
||||
`company.secrets`, `company.export`, `company.import` — removed from the
|
||||
settings sidebar, tab bar, and routing (the company General page is the
|
||||
settings root and stays visible). These are UI-visibility keys: the
|
||||
membership, invite, secret, and export APIs stay live for agents and
|
||||
integrations. `company.import` is the exception — hiding it also floors
|
||||
every company-import route with `403 settings_operator_managed`. On
|
||||
cloud-managed instances import is floored unconditionally with
|
||||
`403 cloud_managed`, independent of this variable.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ my-company/
|
|||
|
||||
Both flows are also available in the web UI as company settings pages: **Export** and **Import** appear in the company settings navigation.
|
||||
|
||||
> **Cloud-managed instances:** when a hosting platform manages the instance, the company is provisioned by the platform and importing is disabled — the Import page and buttons are hidden, and every import API route answers `403` with `code: "cloud_managed"`. Export stays available, so you can always take your company's data with you. Self-hosted instances keep the full import surface.
|
||||
|
||||
The **Export** page lets you pick exactly which files go into the bundle before downloading it. Above the file tree it shows a **"Not included in this export"** panel — the export fidelity report — listing data the bundle will not carry (for example attachments, approvals, cost history, or activity log entries), with blocking issues highlighted.
|
||||
|
||||
The **Import** page previews the package, lets you resolve name collisions and adapter assignments, and applies the import. A **"Start imported agents and routines paused"** checkbox (on by default) makes imported agents and routines land paused instead of live. After the import finishes, an **"Activate imported agents and routines"** panel lists everything that was imported paused so you can resume the agents and activate the routines you select — nothing starts running until you say so.
|
||||
|
|
|
|||
|
|
@ -2499,16 +2499,19 @@ export {
|
|||
type InstanceFeatureKey,
|
||||
} from "./feature-catalog.js";
|
||||
export {
|
||||
HIDEABLE_COMPANY_PAGES,
|
||||
HIDEABLE_GENERAL_SECTIONS,
|
||||
HIDEABLE_INSTANCE_PAGES,
|
||||
HIDEABLE_SETTING_KEYS,
|
||||
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
|
||||
UI_ONLY_GENERAL_SECTIONS,
|
||||
experimentalSettingKey,
|
||||
hidesCompanyPage,
|
||||
hidesExperimentalSetting,
|
||||
hidesGeneralSection,
|
||||
hidesInstancePage,
|
||||
parseHiddenSettingsList,
|
||||
type HideableCompanyPage,
|
||||
type HideableExperimentalSetting,
|
||||
type HideableGeneralSection,
|
||||
type HideableInstancePage,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js";
|
||||
import {
|
||||
HIDEABLE_COMPANY_PAGES,
|
||||
HIDEABLE_GENERAL_SECTIONS,
|
||||
HIDEABLE_SETTING_KEYS,
|
||||
UI_ONLY_GENERAL_SECTIONS,
|
||||
experimentalSettingKey,
|
||||
hidesCompanyPage,
|
||||
hidesExperimentalSetting,
|
||||
hidesGeneralSection,
|
||||
hidesInstancePage,
|
||||
|
|
@ -21,6 +23,19 @@ describe("hideable setting keys", () => {
|
|||
expect(new Set(HIDEABLE_SETTING_KEYS).size).toBe(HIDEABLE_SETTING_KEYS.length);
|
||||
});
|
||||
|
||||
it("covers every top-level company settings page except the General root", () => {
|
||||
expect(HIDEABLE_COMPANY_PAGES).toEqual([
|
||||
"company.members",
|
||||
"company.invites",
|
||||
"company.secrets",
|
||||
"company.export",
|
||||
"company.import",
|
||||
]);
|
||||
for (const page of HIDEABLE_COMPANY_PAGES) {
|
||||
expect(HIDEABLE_SETTING_KEYS).toContain(page);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -57,6 +72,13 @@ describe("membership helpers", () => {
|
|||
).hidden,
|
||||
);
|
||||
|
||||
it("answers company-page membership", () => {
|
||||
const companyHidden = new Set(parseHiddenSettingsList("company.import,company.secrets").hidden);
|
||||
expect(hidesCompanyPage(companyHidden, "company.import")).toBe(true);
|
||||
expect(hidesCompanyPage(companyHidden, "company.secrets")).toBe(true);
|
||||
expect(hidesCompanyPage(companyHidden, "company.export")).toBe(false);
|
||||
});
|
||||
|
||||
it("answers page, section, and experimental membership", () => {
|
||||
expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true);
|
||||
expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalo
|
|||
* 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
|
||||
* 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).
|
||||
* pages, every field-backed General section, every experimental toggle
|
||||
* (individually or via the whole Experimental page), and the company Import
|
||||
* page (whose whole route surface is floored). The other company pages are
|
||||
* UI-visibility keys only: their APIs (memberships, invites, secrets,
|
||||
* exports) stay live for agents and integrations.
|
||||
*
|
||||
* Nothing is hidden by default: with the variable unset, UI and API behave
|
||||
* exactly as before this mechanism existed.
|
||||
|
|
@ -39,6 +42,22 @@ export const HIDEABLE_INSTANCE_PAGES = [
|
|||
|
||||
export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number];
|
||||
|
||||
/**
|
||||
* Company-level settings pages that can be hidden (nav entry + tab + route).
|
||||
* The company General page is deliberately not hideable: it is the settings
|
||||
* root and the redirect target for hidden pages. `company.import` also floors
|
||||
* the import API routes; the rest only hide UI surfaces.
|
||||
*/
|
||||
export const HIDEABLE_COMPANY_PAGES = [
|
||||
"company.members",
|
||||
"company.invites",
|
||||
"company.secrets",
|
||||
"company.export",
|
||||
"company.import",
|
||||
] as const;
|
||||
|
||||
export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_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
|
||||
|
|
@ -70,12 +89,14 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim
|
|||
|
||||
export type HideableSettingKey =
|
||||
| HideableInstancePage
|
||||
| HideableCompanyPage
|
||||
| HideableGeneralSection
|
||||
| HideableExperimentalSetting;
|
||||
|
||||
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
|
||||
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
|
||||
...HIDEABLE_INSTANCE_PAGES,
|
||||
...HIDEABLE_COMPANY_PAGES,
|
||||
...HIDEABLE_GENERAL_SECTIONS,
|
||||
...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey),
|
||||
];
|
||||
|
|
@ -117,6 +138,13 @@ export function hidesInstancePage(
|
|||
return hidden.has(page);
|
||||
}
|
||||
|
||||
export function hidesCompanyPage(
|
||||
hidden: ReadonlySet<string>,
|
||||
page: HideableCompanyPage,
|
||||
): boolean {
|
||||
return hidden.has(page);
|
||||
}
|
||||
|
||||
export function hidesGeneralSection(
|
||||
hidden: ReadonlySet<string>,
|
||||
section: HideableGeneralSection,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockPortabilityService = vi.hoisted(() => ({
|
||||
previewExport: vi.fn(),
|
||||
exportBundle: vi.fn(),
|
||||
previewImport: vi.fn(),
|
||||
importBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTransferRunService = vi.hoisted(() => ({
|
||||
resumeOrCreate: vi.fn(),
|
||||
getRunForActor: vi.fn(),
|
||||
recordCompletedPart: vi.fn(),
|
||||
claimApply: vi.fn(),
|
||||
releaseApplyClaim: vi.fn(),
|
||||
completeRun: vi.fn(),
|
||||
failRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({}),
|
||||
agentService: () => ({}),
|
||||
budgetService: () => ({}),
|
||||
companyArtifactsService: () => ({}),
|
||||
companyPortabilityService: () => mockPortabilityService,
|
||||
companyService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
workTimelineService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/company-transfer-runs.js", () => ({
|
||||
companyTransferRunService: mockTransferRunService,
|
||||
}));
|
||||
|
||||
const TRANSFER_ID = "6e0a4f6e-6f7d-4a37-9a83-0b8f2f9f2b11";
|
||||
|
||||
/**
|
||||
* Every route in the company-import surface. The floor must answer all of
|
||||
* them, including the read-only polling routes: with imports disabled no job
|
||||
* or transfer can exist, so a uniform 403 is clearer than a mixed surface.
|
||||
*/
|
||||
const IMPORT_ROUTES: Array<{ method: "get" | "post" | "put"; path: string }> = [
|
||||
{ method: "post", path: "/api/companies/import/preview" },
|
||||
{ method: "post", path: "/api/companies/import" },
|
||||
{ method: "get", path: "/api/companies/import/jobs/some-job" },
|
||||
{ method: "post", path: "/api/companies/import/transfers" },
|
||||
{ method: "put", path: `/api/companies/import/transfers/${TRANSFER_ID}/parts/0` },
|
||||
{ method: "get", path: `/api/companies/import/transfers/${TRANSFER_ID}` },
|
||||
{ method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/preview` },
|
||||
{ method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/apply` },
|
||||
{ method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/preview" },
|
||||
{ method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/apply" },
|
||||
];
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ companyRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/companies.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api/companies", companyRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const boardAdmin = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "local-user",
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
|
||||
function expectNoImportWork() {
|
||||
expect(mockPortabilityService.previewImport).not.toHaveBeenCalled();
|
||||
expect(mockPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
expect(mockTransferRunService.resumeOrCreate).not.toHaveBeenCalled();
|
||||
expect(mockTransferRunService.getRunForActor).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("company import Cloud floor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
||||
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
|
||||
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
it("returns 403 cloud_managed on every import route on a cloud-managed instance", async () => {
|
||||
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret";
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
for (const route of IMPORT_ROUTES) {
|
||||
const res = await request(app)[route.method](route.path).send({});
|
||||
expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403);
|
||||
expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({
|
||||
code: "cloud_managed",
|
||||
});
|
||||
}
|
||||
expectNoImportWork();
|
||||
});
|
||||
|
||||
it("floors on the managed-config signal alone", async () => {
|
||||
process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({ v: 1, mode: "cloud" });
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
const res = await request(app).post("/api/companies/import").send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({ code: "cloud_managed" });
|
||||
expectNoImportWork();
|
||||
});
|
||||
|
||||
it("applies the floor before auth and request-body validation", async () => {
|
||||
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret";
|
||||
// An agent actor never passes the import routes' own assertBoard, and the
|
||||
// body is not schema-valid either — the floor must still answer first so a
|
||||
// Cloud caller sees one consistent refusal.
|
||||
const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1" });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/import/transfers")
|
||||
.send({ nonsense: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({ code: "cloud_managed" });
|
||||
expectNoImportWork();
|
||||
});
|
||||
|
||||
it("floors every import route with settings_operator_managed when company.import is hidden", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.import";
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
for (const route of IMPORT_ROUTES) {
|
||||
const res = await request(app)[route.method](route.path).send({});
|
||||
expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403);
|
||||
expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
expectNoImportWork();
|
||||
});
|
||||
|
||||
it("keeps import open when only other company pages are hidden", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.secrets,company.members";
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
const res = await request(app).get("/api/companies/import/jobs/unknown-job");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({ error: "Import job not found" });
|
||||
});
|
||||
|
||||
it("keeps company export open on a cloud-managed instance", async () => {
|
||||
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret";
|
||||
mockPortabilityService.exportBundle.mockResolvedValue({ ok: true });
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/11111111-2222-4333-8444-555555555555/exports")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockPortabilityService.exportBundle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves self-hosted import preview", async () => {
|
||||
mockPortabilityService.previewImport.mockResolvedValue({ companies: [] });
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/import/preview")
|
||||
.send({
|
||||
source: { type: "github", url: "https://github.com/example/export" },
|
||||
target: { mode: "new_company" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockPortabilityService.previewImport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves self-hosted import job polling", async () => {
|
||||
const app = await createApp(boardAdmin);
|
||||
|
||||
const res = await request(app).get("/api/companies/import/jobs/unknown-job");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({ error: "Import job not found" });
|
||||
});
|
||||
});
|
||||
|
|
@ -859,9 +859,12 @@ describe.sequential("company portability routes", () => {
|
|||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("keeps Cloud-managed global import apply synchronous when async opt-in is absent", async () => {
|
||||
it.sequential("floors global import apply on a cloud-managed instance, even for the trusted tenant actor", async () => {
|
||||
// The trusted-tenant tests above run without the cloud env signal on
|
||||
// purpose: the import floor keys on isCloudManagedInstance(), not on the
|
||||
// actor. With the signal present, even the trusted tenant actor is
|
||||
// floored — importing is disabled on cloud-managed instances outright.
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-secret");
|
||||
mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created"));
|
||||
try {
|
||||
const app = await createApp(cloudTenantActor());
|
||||
|
||||
|
|
@ -870,15 +873,10 @@ describe.sequential("company portability routes", () => {
|
|||
.set(cloudHeaders)
|
||||
.send(importRequest);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.company.id).toBe(companyId);
|
||||
expect(res.body.company.action).toBe("created");
|
||||
expect(res.body.job).toBeUndefined();
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1", { pauseAutomations: false });
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.imported",
|
||||
companyId,
|
||||
}));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({ code: "cloud_managed" });
|
||||
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import express, { Router, type Request, type Response } from "express";
|
||||
import express, { Router, type NextFunction, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { and, count as countFn, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "@paperclipai/shared/portability-zip";
|
||||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
|
||||
companyArtifactsQuerySchema,
|
||||
companyPortabilityExportSchema,
|
||||
companyPortabilityImportSchema,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
hidesCompanyPage,
|
||||
updateCompanyBrandingSchema,
|
||||
updateCompanySchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -61,6 +63,7 @@ import {
|
|||
workTimelineService,
|
||||
} from "../services/index.js";
|
||||
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js";
|
||||
|
|
@ -503,6 +506,40 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
|
|||
res.json(buildExportFidelityReport(companyId, counts));
|
||||
});
|
||||
|
||||
/**
|
||||
* Floor for the whole company-import surface: single-shot upload, preview,
|
||||
* job polling, chunked transfers, and the per-company agent-safe import
|
||||
* routes. Two independent signals close it, both checked up front — before
|
||||
* auth or body work, the same way the company-creation floor does:
|
||||
*
|
||||
* - a cloud-managed instance (`cloud_managed`): the hosting platform
|
||||
* provisions the company, so materializing imported companies on a
|
||||
* managed instance is disabled unconditionally;
|
||||
* - the operator hiding the Import page (`company.import` in
|
||||
* `PAPERCLIP_HIDDEN_SETTINGS` → `settings_operator_managed`): hiding the
|
||||
* page also disables its API, so the hide is real rather than cosmetic.
|
||||
*
|
||||
* Export routes stay open either way — they are the tenant's
|
||||
* data-portability escape hatch.
|
||||
*/
|
||||
const importFloor = (_req: Request, _res: Response, next: NextFunction) => {
|
||||
if (isCloudManagedInstance()) {
|
||||
throw forbidden("Company import is disabled on cloud-managed instances", {
|
||||
code: "cloud_managed",
|
||||
});
|
||||
}
|
||||
if (hidesCompanyPage(getHiddenSettings(), "company.import")) {
|
||||
throw forbidden("Company import is hidden by the hosting operator", {
|
||||
code: SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
// COMPANY_IMPORT_TRANSFERS_ROUTE_PATH nests under the import path, so these
|
||||
// two prefixes cover every import route registered below.
|
||||
router.use(COMPANY_IMPORT_ROUTE_PATH, importFloor);
|
||||
router.use("/:companyId/imports", importFloor);
|
||||
|
||||
router.post("/import/preview", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const body = companyPortabilityPreviewSchema.parse(await resolveImportPayload(req, res));
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGat
|
|||
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
|
||||
import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate";
|
||||
import { AppsExperimentalGate } from "./components/AppsExperimentalGate";
|
||||
import { CloudManagedPageGate } from "./components/CloudManagedPageGate";
|
||||
import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate";
|
||||
import { useHiddenSettings } from "./hooks/useHiddenSettings";
|
||||
import { Cases } from "./pages/Cases";
|
||||
|
|
@ -115,19 +116,31 @@ function boardRoutes() {
|
|||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<Navigate to="/company/export" replace />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route
|
||||
path="company/export/*"
|
||||
element={(
|
||||
<Suspense fallback={<PaperclipLoading />}>
|
||||
<CompanyExport />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.members" />}>
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
</Route>
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.invites" />}>
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
</Route>
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.export" />}>
|
||||
<Route
|
||||
path="company/export/*"
|
||||
element={(
|
||||
<Suspense fallback={<PaperclipLoading />}>
|
||||
<CompanyExport />
|
||||
</Suspense>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<CloudManagedPageGate />}>
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.import" />}>
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.secrets" />}>
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
</Route>
|
||||
<Route path="company/settings/tools" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="tools" element={<LegacyToolsRedirect />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
// @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 { CloudManagedPageGate } from "./CloudManagedPageGate";
|
||||
|
||||
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("CloudManagedPageGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderGate(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}>
|
||||
<CloudManagedPageGate />
|
||||
</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 on a Cloud-managed instance", async () => {
|
||||
await renderGate({ status: "ok", cloud: { managed: true } });
|
||||
|
||||
expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe(
|
||||
"/company/settings",
|
||||
);
|
||||
expect(container.querySelector('[data-testid="page-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the page on a self-hosted instance", async () => {
|
||||
await renderGate({ status: "ok" });
|
||||
|
||||
expect(container.querySelector('[data-testid="page-content"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Navigate, Outlet } from "@/lib/router";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
|
||||
/**
|
||||
* Route gate for pages that are floored on cloud-managed instances (the
|
||||
* server answers 403 `cloud_managed`), like company import. Cloud-managed
|
||||
* instances redirect to the settings root instead of rendering a dead-ended
|
||||
* page. Under
|
||||
* CloudAccessGate the health response is always cached before board routes
|
||||
* mount, so the cloud flag is already resolved when this renders.
|
||||
*/
|
||||
export function CloudManagedPageGate() {
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
|
||||
if (isCloud) return <Navigate to="/company/settings" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -377,12 +377,13 @@ describe("CompanySettingsSidebar operator-hidden entries", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebar(hiddenSettings?: string[]) {
|
||||
async function renderSidebar(hiddenSettings?: string[], cloud?: { managed: boolean }) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
...(hiddenSettings ? { hiddenSettings } : {}),
|
||||
...(cloud ? { cloud } : {}),
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -412,6 +413,31 @@ describe("CompanySettingsSidebar operator-hidden entries", () => {
|
|||
expect(container.textContent).toContain("Plugins");
|
||||
expect(container.textContent).toContain("Heartbeats");
|
||||
expect(container.textContent).toContain("Adapters");
|
||||
expect(container.textContent).toContain("Import");
|
||||
expect(mockPluginsApi.list).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides Import but keeps Export on a Cloud-managed instance", async () => {
|
||||
await renderSidebar(undefined, { managed: true });
|
||||
|
||||
expect(container.textContent).not.toContain("Import");
|
||||
expect(container.textContent).toContain("Export");
|
||||
});
|
||||
|
||||
it("hides operator-hidden company pages", async () => {
|
||||
await renderSidebar([
|
||||
"company.members",
|
||||
"company.invites",
|
||||
"company.secrets",
|
||||
"company.export",
|
||||
"company.import",
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).not.toContain("Members");
|
||||
expect(container.textContent).not.toContain("Invites");
|
||||
expect(container.textContent).not.toContain("Secrets");
|
||||
expect(container.textContent).not.toContain("Export");
|
||||
expect(container.textContent).not.toContain("Import");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
|
@ -47,6 +48,9 @@ export function CompanySettingsSidebar() {
|
|||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
|
||||
const showPlugins = showPage("instance.plugins");
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
// nav entry is hidden rather than dead-ending. Export stays available.
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const { slots: companySettingsPluginSlots } = usePluginSlots({
|
||||
slotTypes: ["companySettingsPage"],
|
||||
companyId: selectedCompanyId,
|
||||
|
|
@ -105,13 +109,15 @@ export function CompanySettingsSidebar() {
|
|||
end
|
||||
/>
|
||||
)}
|
||||
<SidebarNavItem
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
icon={Users}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
{showPage("company.members") && (
|
||||
<SidebarNavItem
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
icon={Users}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
)}
|
||||
{companySettingsPluginSlots
|
||||
.filter((slot) => slot.routePath)
|
||||
.map((slot) => (
|
||||
|
|
@ -123,8 +129,12 @@ export function CompanySettingsSidebar() {
|
|||
end
|
||||
/>
|
||||
))}
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
{showPage("company.invites") && (
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
)}
|
||||
{showPage("company.secrets") && (
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
)}
|
||||
{showPage("instance.environments") && (
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
|
||||
|
|
@ -149,8 +159,12 @@ export function CompanySettingsSidebar() {
|
|||
end
|
||||
/>
|
||||
)}
|
||||
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
|
||||
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
|
||||
{showPage("company.export") && (
|
||||
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
|
||||
)}
|
||||
{!isCloud && showPage("company.import") && (
|
||||
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
|
||||
)}
|
||||
{showPage("instance.experimental") && (
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,16 @@ describe("CompanySettingsNav", () => {
|
|||
expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters");
|
||||
});
|
||||
|
||||
function renderNav(root: ReturnType<typeof createRoot>, hiddenSettings?: string[]) {
|
||||
function renderNav(
|
||||
root: ReturnType<typeof createRoot>,
|
||||
hiddenSettings?: string[],
|
||||
cloud?: { managed: boolean },
|
||||
) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
...(hiddenSettings ? { hiddenSettings } : {}),
|
||||
...(cloud ? { cloud } : {}),
|
||||
});
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
@ -177,4 +182,45 @@ describe("CompanySettingsNav", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters operator-hidden company tabs out of the tab bar", async () => {
|
||||
currentPathname = "/PAP/company/settings/members";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
renderNav(root, ["company.import", "company.secrets"]);
|
||||
});
|
||||
|
||||
const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map(
|
||||
(item: { value: string }) => item.value,
|
||||
);
|
||||
expect(renderedValues).not.toContain("import");
|
||||
expect(renderedValues).not.toContain("secrets");
|
||||
expect(renderedValues).toContain("export");
|
||||
expect(renderedValues).toContain("members");
|
||||
expect(renderedValues).toContain("invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the Import tab on a Cloud-managed instance", async () => {
|
||||
currentPathname = "/PAP/company/settings/members";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
renderNav(root, undefined, { managed: true });
|
||||
});
|
||||
|
||||
const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map(
|
||||
(item: { value: string }) => item.value,
|
||||
);
|
||||
expect(renderedValues).not.toContain("import");
|
||||
expect(renderedValues).toContain("export");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
|
|
@ -24,6 +25,11 @@ type CompanySettingsTab = (typeof items)[number]["value"];
|
|||
|
||||
/** Tab values suppressed when their page is operator-hidden. */
|
||||
const hiddenSettingKeyByTab: Partial<Record<CompanySettingsTab, string>> = {
|
||||
export: "company.export",
|
||||
import: "company.import",
|
||||
members: "company.members",
|
||||
invites: "company.invites",
|
||||
secrets: "company.secrets",
|
||||
"instance-profile": "instance.profile",
|
||||
"instance-environments": "instance.environments",
|
||||
"instance-access": "instance.access",
|
||||
|
|
@ -97,8 +103,12 @@ export function CompanySettingsNav() {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
// tab is suppressed there rather than dead-ending.
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const activeTab = getCompanySettingsTab(location.pathname);
|
||||
const visibleItems = items.filter((item) => {
|
||||
if (item.value === "import" && isCloud) return false;
|
||||
const hiddenKey = hiddenSettingKeyByTab[item.value];
|
||||
return !hiddenKey || !hiddenSettings.has(hiddenKey);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { act } 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 { queryKeys } from "@/lib/queryKeys";
|
||||
import { OrgChart } from "./OrgChart";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
|
@ -262,4 +263,19 @@ describe("OrgChart mobile gestures", () => {
|
|||
|
||||
expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)");
|
||||
});
|
||||
|
||||
it("shows both portability buttons on self-hosted instances", async () => {
|
||||
await renderOrgChart();
|
||||
|
||||
expect(container.textContent).toContain("Import company");
|
||||
expect(container.textContent).toContain("Export company");
|
||||
});
|
||||
|
||||
it("hides the Import button but keeps Export on a Cloud-managed instance", async () => {
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
|
||||
await renderOrgChart();
|
||||
|
||||
expect(container.textContent).not.toContain("Import company");
|
||||
expect(container.textContent).toContain("Export company");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
|
||||
// Layout constants
|
||||
const CARD_W = 200;
|
||||
|
|
@ -175,6 +177,13 @@ export function OrgChart() {
|
|||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
// button is hidden rather than dead-ending. Export stays available. Both
|
||||
// buttons also respect the operator-hidden settings registry.
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const showImport = !isCloud && !hiddenSettings.has("company.import");
|
||||
const showExport = !hiddenSettings.has("company.export");
|
||||
|
||||
const { data: orgTree, isLoading } = useQuery({
|
||||
queryKey: queryKeys.org(selectedCompanyId!),
|
||||
|
|
@ -444,18 +453,22 @@ export function OrgChart() {
|
|||
return (
|
||||
<div className="flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0">
|
||||
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
|
||||
<Link to="/company/import">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import company
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/company/export">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export company
|
||||
</Button>
|
||||
</Link>
|
||||
{showImport && (
|
||||
<Link to="/company/import">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import company
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{showExport && (
|
||||
<Link to="/company/export">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export company
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
Loading…
Reference in New Issue