mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board selects a company before it loads that company's inbox and tasks. > - Instance administrators can list companies where they have no membership. > - The board treated that directory as a list of companies the user could enter. > - This pull request gives navigation a list based on the existing company access check. > - Users can select their companies without landing on an inbox that rejects their access. ## Linked Issues or Issue Description Fixes #6090. Refs #4855 for the related account-recovery case; this PR does not grant company membership. **What happened?** An instance administrator can select a company where they have no membership. Its inbox then shows “User does not have access to this company.” Company directory visibility and access to company contents use different rules. **Expected behavior** Company navigation should show only companies the current user can enter. A stored selection for an inaccessible company should fall back to an accessible company. A direct link to an inaccessible company should use the existing unavailable-company page. **Steps to reproduce** 1. Create Company A and Company B with separate owners. 2. Sign in as an instance administrator who belongs only to Company A. 3. Select Company B through a stored selection or a link with its prefix. 4. Observe that the board accepts the company selection, but company-scoped requests return 403. Related: #10524 lets cloud users enter additional companies where they hold memberships. This fix preserves that access and excludes companies where they have no membership. ## What Changed - Added `scope=accessible` to `GET /api/companies`, using the existing `hasCompanyAccess` predicate. - Changed the board navigation list to request that scope. Instance Access uses a separate unscoped, account-keyed directory so administrators can manage all companies. Membership edits refresh navigation. - Reject empty, unknown, and repeated scope values with 400. Directory loading errors offer a retry before access controls are shown. - Added route tests for cloud, session, board-key, local trusted, non-member, and agent access. - Added client and component tests for navigation/admin request isolation, grants outside the navigation list, self-membership refresh, directory failure recovery, and forbidden administration. - Updated the API guide and OpenAPI document. ## Verification - Latest commit: 36 focused UI tests passed. The broader UI shard passed all 281 files / 2,533 tests after correcting an asynchronous test assertion. - Server authorization and OpenAPI regression suites: 31 tests passed. - UI typecheck and `pnpm check:token-gates`: passed. - `pnpm -r typecheck` and `pnpm build`: passed after review fixes. - Full local test runner: exercised the supported shards. Several unrelated suites hit embedded PostgreSQL startup failures or startup timeouts under local load. The UI regression issue found in the broad run was corrected and its full UI shard passed. These local limitations are not reported as a green full-suite result. - [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/34233416473): all checks green on `4e1698cd4` — all server and workspace test shards, all browser end-to-end shards, typecheck/release registry, build, canary dry run, policy, and Docker context integrity. Security checks also passed. - Greptile: 5/5 on the latest commit; both initial findings addressed and all review threads resolved. ## Risks - The UI now excludes companies visible only through instance administrator status. Company membership continues to control access to contents. - Additional companies with active memberships remain available. - The client and server changes must ship together. An older server ignores the new query parameter and retains the previous behavior. - No database migration or permission grant changes. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, repository inspection, code editing, and test execution. The exact served model identifier and context window are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
178 lines
7.8 KiB
TypeScript
178 lines
7.8 KiB
TypeScript
import type {
|
|
Company,
|
|
CompanyPortabilityExportRequest,
|
|
CompanyPortabilityExportPreviewResult,
|
|
CompanyPortabilityExportResult,
|
|
CompanyPortabilityImportRequest,
|
|
CompanyPortabilityImportResult,
|
|
CompanyPortabilityPreviewRequest,
|
|
CompanyPortabilityPreviewResult,
|
|
UpdateCompanyBranding,
|
|
} from "@paperclipai/shared";
|
|
import type { ExportFidelityReport } from "@paperclipai/shared/portability-fidelity";
|
|
import {
|
|
companyImportTransferApplyPath,
|
|
companyImportTransferPartPath,
|
|
companyImportTransferPath,
|
|
companyImportTransferPreviewPath,
|
|
COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
|
|
type CompanyImportTransferCreated,
|
|
type CompanyImportTransferDeclaration,
|
|
type CompanyImportTransferPartUploadResult,
|
|
type CompanyImportTransferStatus,
|
|
} from "@paperclipai/shared/company-import-transfer";
|
|
import { api, detachInflightGet, type RequestOptions } from "./client";
|
|
|
|
// The board navigates only into companies the user can enter. The unscoped
|
|
// directory also includes companies visible solely through instance admin.
|
|
const COMPANIES_LIST_PATH = "/companies?scope=accessible";
|
|
const COMPANIES_DIRECTORY_PATH = "/companies";
|
|
|
|
export type CompanyStats = Record<string, { agentCount: number; issueCount: number }>;
|
|
|
|
/**
|
|
* Import fields for a zip package upload: everything the JSON request carries
|
|
* except `source` — the source is the uploaded zip, read server-side. Sent as a
|
|
* JSON-encoded `meta` form field alongside the raw `package` file.
|
|
*/
|
|
export type CompanyPortabilityPreviewMeta = Omit<CompanyPortabilityPreviewRequest, "source">;
|
|
export type CompanyPortabilityImportMeta = Omit<CompanyPortabilityImportRequest, "source">;
|
|
|
|
function importPackageForm(file: File, meta: CompanyPortabilityPreviewMeta | CompanyPortabilityImportMeta): FormData {
|
|
const form = new FormData();
|
|
form.append("package", file);
|
|
form.append("meta", JSON.stringify(meta));
|
|
return form;
|
|
}
|
|
|
|
export type CompanyImportJobState = "running" | "succeeded" | "failed";
|
|
|
|
/** 202 body from the async opt-in on POST /companies/import (and the 409 body when a job is already running). */
|
|
export interface CompanyImportJobAccepted {
|
|
job: { id: string; status: CompanyImportJobState };
|
|
statusUrl: string;
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
export interface CompanyImportJobStatus {
|
|
job: {
|
|
id: string;
|
|
status: CompanyImportJobState;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
completedAt?: string;
|
|
error?: { message: string };
|
|
/**
|
|
* Summary retained for every terminal job (board and cloud tenant); carries
|
|
* the imported company id so the page can navigate even when the full
|
|
* `importResult` is no longer available.
|
|
*/
|
|
result?: {
|
|
companyId: string;
|
|
agentCount?: number;
|
|
warningCount?: number;
|
|
companyAction?: unknown;
|
|
};
|
|
/** Board-created jobs carry the full result for parity with the sync response. */
|
|
importResult?: CompanyPortabilityImportResult;
|
|
};
|
|
retryAfterMs?: number;
|
|
}
|
|
|
|
export const companiesApi = {
|
|
list: () => api.get<Company[]>(COMPANIES_LIST_PATH),
|
|
directory: () => api.get<Company[]>(COMPANIES_DIRECTORY_PATH),
|
|
detachInflightDirectory: () => detachInflightGet(COMPANIES_DIRECTORY_PATH),
|
|
/**
|
|
* Call before re-reading the list for a different account: an in-flight
|
|
* `/companies` GET issued under the previous session would otherwise be
|
|
* coalesced into, and answer with that account's companies.
|
|
*/
|
|
detachInflightList: () => detachInflightGet(COMPANIES_LIST_PATH),
|
|
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
|
|
stats: () => api.get<CompanyStats>("/companies/stats"),
|
|
create: (data: {
|
|
name: string;
|
|
description?: string | null;
|
|
budgetMonthlyCents?: number;
|
|
}) =>
|
|
api.post<Company>("/companies", data),
|
|
update: (
|
|
companyId: string,
|
|
data: Partial<
|
|
Pick<
|
|
Company,
|
|
| "name"
|
|
| "description"
|
|
| "status"
|
|
| "budgetMonthlyCents"
|
|
| "requireBoardApprovalForNewAgents"
|
|
| "interactionResolverGovernance"
|
|
| "feedbackDataSharingEnabled"
|
|
| "logoAssetId"
|
|
>
|
|
>,
|
|
) => api.patch<Company>(`/companies/${companyId}`, data),
|
|
updateBranding: (companyId: string, data: UpdateCompanyBranding) =>
|
|
api.patch<Company>(`/companies/${companyId}/branding`, data),
|
|
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
|
|
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
|
exportBundle: (
|
|
companyId: string,
|
|
data: CompanyPortabilityExportRequest,
|
|
) =>
|
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
|
exportPreview: (
|
|
companyId: string,
|
|
data: CompanyPortabilityExportRequest,
|
|
options?: RequestOptions,
|
|
) =>
|
|
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data, options),
|
|
exportFidelity: (companyId: string) =>
|
|
api.get<ExportFidelityReport>(`/companies/${companyId}/export/fidelity`),
|
|
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
|
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
|
|
/** Preview a local .zip package by uploading the raw compressed zip as multipart. */
|
|
importPreviewPackage: (file: File, meta: CompanyPortabilityPreviewMeta) =>
|
|
api.postForm<CompanyPortabilityPreviewResult>("/companies/import/preview", importPackageForm(file, meta)),
|
|
importBundle: (data: CompanyPortabilityImportRequest) =>
|
|
api.post<CompanyPortabilityImportResult>("/companies/import", data),
|
|
// Submit an import as a server-side job: 202 with a job id to poll, or 409
|
|
// with the already-running job. Board sessions opt in with the proxy-safe
|
|
// `?async=1` query parameter — the Cloud harness strips the inbound
|
|
// `x-paperclip-cloud-*` header a browser would otherwise use, so that header
|
|
// never survives to engage the async path.
|
|
importBundleAsync: (data: CompanyPortabilityImportRequest) =>
|
|
api.post<CompanyImportJobAccepted>("/companies/import?async=1", data),
|
|
/** Submit a local .zip package as an async import job by uploading the raw compressed zip as multipart. */
|
|
importBundlePackageAsync: (file: File, meta: CompanyPortabilityImportMeta) =>
|
|
api.postForm<CompanyImportJobAccepted>("/companies/import?async=1", importPackageForm(file, meta)),
|
|
getImportJob: (jobId: string) =>
|
|
api.get<CompanyImportJobStatus>(`/companies/import/jobs/${encodeURIComponent(jobId)}`),
|
|
// Chunked resumable transfer for large local .zip packages: declare the
|
|
// sliced zip (content-addressed, so re-declaring the same file resumes the
|
|
// prior transfer with its uploaded parts intact), upload the missing parts,
|
|
// then preview/apply against the server-side assembled spool. Shapes and
|
|
// paths come from the shared transfer contract.
|
|
importTransferCreate: (manifest: CompanyImportTransferDeclaration) =>
|
|
api.post<CompanyImportTransferCreated>(`/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`, manifest),
|
|
importTransferUploadPart: (transferId: string, index: number, bytes: Blob) =>
|
|
api.putRaw<CompanyImportTransferPartUploadResult>(
|
|
`/companies${companyImportTransferPartPath(transferId, index)}`,
|
|
bytes,
|
|
),
|
|
importTransferStatus: (transferId: string) =>
|
|
api.get<CompanyImportTransferStatus>(`/companies${companyImportTransferPath(transferId)}`),
|
|
importTransferPreview: (transferId: string, meta: CompanyPortabilityPreviewMeta) =>
|
|
api.post<CompanyPortabilityPreviewResult>(
|
|
`/companies${companyImportTransferPreviewPath(transferId)}`,
|
|
meta,
|
|
),
|
|
/** Apply a fully uploaded transfer as an async job (same 202/409 contract as importBundlePackageAsync). */
|
|
importTransferApply: (transferId: string, meta: CompanyPortabilityImportMeta) =>
|
|
api.post<CompanyImportJobAccepted>(
|
|
`/companies${companyImportTransferApplyPath(transferId)}?async=1`,
|
|
meta,
|
|
),
|
|
};
|