Files
PaperClipAI/ui/src/api/access.ts
T
Svetlana ZolotenkovaandPaperclip 1b0f78100d fix(access): return membershipRole in the company user directory (#15183)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents often check a human approval. The check must confirm the role
of the approving user in the company.
> - An agent can read the company user directory. The directory removes
the membership role from the server record.
> - The admin member list requires `users:manage_permissions`. An
approval check must not need that permission.
> - This pull request returns `membershipRole` in the existing user
directory and keeps all access limits.
> - The benefit is that agents can verify an approver's role without
admin permissions.

## Linked Issues or Issue Description

**What happened?**

`GET /api/companies/{companyId}/user-directory` returns active users
without `membershipRole`. An agent that checks the authority of an
approving user cannot tell an `owner` from other roles. `/members`
requires `users:manage_permissions`.

**Expected behavior**

The directory returns the exact `membershipRole` from
`companyMemberships`. A missing role stays `null`. Reading this field
does not require admin permissions. Member management keeps its current
limits.

**Steps to reproduce**

1. Use a company with an active `owner` user and an active user without
a role.
2. Call `/user-directory` with an agent key.
3. Before this change, neither record has `membershipRole`. After this
change, the field is `owner` and `null`.
4. The same agent still cannot read `/members` or change a member.

## What Changed

- Add `membershipRole` to the select and result of
`loadCompanyUserDirectory`.
- Test the exact `owner`/`null` values, agent read access, denied admin
read and change, denied other company, and denied unauthenticated
request.
- Keep the existing `assertCompanyAccess` and the company, user type,
and active filters.
- The client type includes a nullable `membershipRole`. The management
test rejects only `users:manage_permissions` and checks the exact calls.
The first review findings are fixed in a0d1ad3.

## Verification

- `pnpm --filter @paperclipai/server typecheck`: pass.
- `pnpm --filter @paperclipai/server build`: pass.
- `pnpm --filter @paperclipai/ui typecheck`: pass.
- `pnpm exec vitest run
server/src/__tests__/company-user-directory-route.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/access-routes-hidden-floor.test.ts`: 16 passed, 17
skipped. `ui/src/lib/company-members.test.ts` is added.
- The regression test fails on the old `access.ts` because
`membershipRole` is missing. It passes after the change.
- `git diff --check`: pass.

## Risks

The directory response gets one more field. There are no migrations. The
role comes directly from the server record, with no `owner` fallback.
User management permissions do not change.

## Model Used

OpenAI Codex, GPT-6, with shell tools, code editing, and test runs. The
environment does not expose the exact model identifier.

## 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>
2026-11-05 14:43:02 -06:00

420 lines
13 KiB
TypeScript

import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared";
import { api } from "./client";
export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer";
type InviteSummary = {
id: string;
companyId: string | null;
companyName?: string | null;
companyLogoUrl?: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
expiresAt: string;
onboardingPath?: string;
onboardingUrl?: string;
onboardingTextPath?: string;
onboardingTextUrl?: string;
skillIndexPath?: string;
skillIndexUrl?: string;
inviteMessage?: string | null;
invitedByUserName?: string | null;
joinRequestStatus?: JoinRequest["status"] | null;
joinRequestType?: JoinRequest["requestType"] | null;
};
type AcceptInviteInput =
| { requestType: "human" }
| {
requestType: "agent";
agentName: string;
adapterType?: AgentAdapterType;
capabilities?: string | null;
agentDefaultsPayload?: Record<string, unknown> | null;
};
type AgentJoinRequestAccepted = JoinRequest & {
claimSecret: string;
claimApiKeyPath: string;
onboarding?: Record<string, unknown>;
diagnostics?: Array<{
code: string;
level: "info" | "warn";
message: string;
hint?: string;
}>;
};
type InviteOnboardingManifest = {
invite: InviteSummary;
onboarding: {
inviteMessage?: string | null;
connectivity?: {
guidance?: string;
connectionCandidates?: string[];
testResolutionEndpoint?: {
method?: string;
path?: string;
url?: string;
};
};
textInstructions?: {
url?: string;
};
};
};
type BoardClaimStatus = {
status: "available" | "claimed" | "expired";
requiresSignIn: boolean;
expiresAt: string | null;
claimedByUserId: string | null;
};
type CliAuthChallengeStatus = {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
requiresSignIn: boolean;
canApprove: boolean;
currentUserId: string | null;
};
type CompanyInviteCreated = {
id: string;
token: string;
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
companyName?: string | null;
onboardingTextPath?: string;
onboardingTextUrl?: string;
inviteMessage?: string | null;
};
export type CompanyMemberGrant = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
permissionKey: PermissionKey;
scope: Record<string, unknown> | null;
grantedByUserId: string | null;
createdAt: string;
updatedAt: string;
};
export type CompanyMember = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
status: "pending" | "active" | "suspended" | "archived";
membershipRole: HumanCompanyRole | null;
createdAt: string;
updatedAt: string;
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
grants: CompanyMemberGrant[];
removal?: {
canArchive: boolean;
reason: string | null;
};
};
export type ArchiveCompanyMemberResponse = {
member: CompanyMember;
reassignedIssueCount: number;
};
export type CompanyMembersResponse = {
members: CompanyMember[];
access: {
currentUserRole: HumanCompanyRole | null;
canManageMembers: boolean;
canInviteUsers: boolean;
canApproveJoinRequests: boolean;
};
};
export type CompanyUserDirectoryEntry = {
principalId: string;
status: "active";
membershipRole: string | null;
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
};
export type CompanyUserDirectoryResponse = {
users: CompanyUserDirectoryEntry[];
};
export type CompanyInviteRecord = {
id: string;
companyId: string | null;
companyName: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole: HumanCompanyRole | null;
defaultsPayload: Record<string, unknown> | null;
expiresAt: string;
invitedByUserId: string | null;
revokedAt: string | null;
acceptedAt: string | null;
createdAt: string;
updatedAt: string;
inviteMessage: string | null;
state: "active" | "revoked" | "accepted" | "expired";
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
relatedJoinRequestId: string | null;
};
export type CompanyInviteListResponse = {
invites: CompanyInviteRecord[];
nextOffset: number | null;
};
export type CompanyJoinRequest = JoinRequest & {
requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
invite: {
id: string;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole: HumanCompanyRole | null;
inviteMessage: string | null;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
acceptedAt: string | null;
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
} | null;
};
export type AdminUserDirectoryEntry = {
id: string;
email: string | null;
name: string | null;
image: string | null;
isInstanceAdmin: boolean;
activeCompanyMembershipCount: number;
};
export type UserCompanyAccessEntry = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
status: "pending" | "active" | "suspended" | "archived";
membershipRole: HumanCompanyRole | "member" | null;
createdAt: string;
updatedAt: string;
companyName: string | null;
companyStatus: "active" | "paused" | "archived" | null;
};
export type UserCompanyAccessResponse = {
user: {
id: string;
email: string | null;
name: string | null;
image: string | null;
isInstanceAdmin: boolean;
} | null;
companyAccess: UserCompanyAccessEntry[];
};
export type CurrentBoardAccess = {
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
memberships?: Array<{
companyId: string;
membershipRole: HumanCompanyRole | "member" | null;
status: "pending" | "active" | "suspended" | "archived";
}>;
source: string;
keyId: string | null;
};
function buildInviteListQuery(options: {
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
}) {
const params = new URLSearchParams();
if (options.state) params.set("state", options.state);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
const query = params.toString();
return query ? `?${query}` : "";
}
export const accessApi = {
createCompanyInvite: (
companyId: string,
input: {
allowedJoinTypes?: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
} = {},
) =>
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
createOpenClawInvitePrompt: (
companyId: string,
input: {
agentMessage?: string | null;
} = {},
) =>
api.post<CompanyInviteCreated>(
`/companies/${companyId}/openclaw/invite-prompt`,
input,
),
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
getInviteOnboarding: (token: string) =>
api.get<InviteOnboardingManifest>(`/invites/${token}/onboarding`),
acceptInvite: (token: string, input: AcceptInviteInput) =>
api.post<AgentJoinRequestAccepted | JoinRequest | { bootstrapAccepted: true; userId: string }>(
`/invites/${token}/accept`,
input,
),
listInvites: (
companyId: string,
options: {
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
} = {},
) =>
api.get<CompanyInviteListResponse>(
`/companies/${companyId}/invites${buildInviteListQuery(options)}`,
),
revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}),
listJoinRequests: (
companyId: string,
status: "pending_approval" | "approved" | "rejected" = "pending_approval",
requestType?: "human" | "agent",
) =>
api.get<CompanyJoinRequest[]>(
`/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`,
),
listMembers: (companyId: string) =>
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
listUserDirectory: (companyId: string) =>
api.get<CompanyUserDirectoryResponse>(`/companies/${companyId}/user-directory`),
updateMember: (
companyId: string,
memberId: string,
input: {
membershipRole?: HumanCompanyRole | null;
status?: "pending" | "active" | "suspended";
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}`, input),
updateMemberPermissions: (
companyId: string,
memberId: string,
input: {
grants: Array<{
permissionKey: PermissionKey;
scope?: Record<string, unknown> | null;
}>;
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/permissions`, input),
updateMemberAccess: (
companyId: string,
memberId: string,
input: {
membershipRole?: HumanCompanyRole | null;
status?: "pending" | "active" | "suspended";
grants: Array<{
permissionKey: PermissionKey;
scope?: Record<string, unknown> | null;
}>;
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
archiveMember: (
companyId: string,
memberId: string,
input: {
reassignment?: {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
} | null;
} = {},
) => api.post<ArchiveCompanyMemberResponse>(`/companies/${companyId}/members/${memberId}/archive`, input),
approveJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
rejectJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/reject`, {}),
claimJoinRequestApiKey: (requestId: string, claimSecret: string) =>
api.post<{ keyId: string; token: string; agentId: string; createdAt: string }>(
`/join-requests/${requestId}/claim-api-key`,
{ claimSecret },
),
getBoardClaimStatus: (token: string, code: string) =>
api.get<BoardClaimStatus>(`/board-claim/${token}?code=${encodeURIComponent(code)}`),
claimBoard: (token: string, code: string) =>
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
claimBootstrapAdmin: () =>
api.post<{ claimed: true; userId: string }>("/bootstrap/claim", {}),
getCliAuthChallenge: (id: string, token: string) =>
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
approveCliAuthChallenge: (id: string, token: string) =>
api.post<{ approved: boolean; status: string; userId: string; keyId: string | null; expiresAt: string }>(
`/cli-auth/challenges/${id}/approve`,
{ token },
),
cancelCliAuthChallenge: (id: string, token: string) =>
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
searchAdminUsers: (query: string) =>
api.get<AdminUserDirectoryEntry[]>(`/admin/users?query=${encodeURIComponent(query)}`),
promoteInstanceAdmin: (userId: string) =>
api.post(`/admin/users/${userId}/promote-instance-admin`, {}),
demoteInstanceAdmin: (userId: string) =>
api.post(`/admin/users/${userId}/demote-instance-admin`, {}),
getUserCompanyAccess: (userId: string) =>
api.get<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`),
setUserCompanyAccess: (userId: string, companyIds: string[]) =>
api.put<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`, { companyIds }),
getCurrentBoardAccess: () =>
api.get<CurrentBoardAccess>("/cli-auth/me"),
};