mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## 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>
420 lines
13 KiB
TypeScript
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"),
|
|
};
|