From 1eaa26a69b8e5d86a19e620fcb5979a06f996b51 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 30 Mar 2026 07:34:19 -0500 Subject: [PATCH] Add company roles and human member management Co-Authored-By: Paperclip --- packages/shared/src/constants.ts | 24 ++ packages/shared/src/index.ts | 7 + packages/shared/src/validators/access.ts | 12 + packages/shared/src/validators/index.ts | 2 + .../__tests__/authz-company-access.test.ts | 48 ++++ .../src/__tests__/invite-join-grants.test.ts | 34 ++- server/src/middleware/auth.ts | 8 +- server/src/routes/access.ts | 269 ++++++++++++++---- server/src/routes/authz.ts | 10 + server/src/services/access.ts | 43 ++- server/src/services/board-auth.ts | 11 +- server/src/services/company-member-roles.ts | 59 ++++ server/src/services/invite-grants.ts | 55 ++++ server/src/types/express.d.ts | 5 + ui/src/api/access.ts | 38 +++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/CompanySettings.tsx | 243 +++++++++++++++- ui/src/pages/InviteLanding.tsx | 18 +- 18 files changed, 814 insertions(+), 73 deletions(-) create mode 100644 server/src/__tests__/authz-company-access.test.ts create mode 100644 server/src/services/company-member-roles.ts create mode 100644 server/src/services/invite-grants.ts diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0c5aa424fdd..12d5318e4b2 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -326,6 +326,30 @@ export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; +export const COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", + "member", +] as const; +export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", +] as const; +export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + export const INSTANCE_USER_ROLES = ["instance_admin"] as const; export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 891011f7a39..82b10056da4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -44,6 +44,9 @@ export { LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, + COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -110,6 +113,8 @@ export { type LiveEventType, type PrincipalType, type MembershipStatus, + type CompanyMembershipRole, + type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -451,6 +456,7 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCostEvent, @@ -465,6 +471,7 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type UpdateCompanyMember, type UpdateMemberPermissions, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 126a084386c..beb55d2d842 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,14 +1,17 @@ import { z } from "zod"; import { AGENT_ADAPTER_TYPES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, + MEMBERSHIP_STATUSES, PERMISSION_KEYS, } from "../constants.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), + humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), agentMessage: z.string().max(4000).optional().nullable(), }); @@ -85,6 +88,15 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +export const updateCompanyMemberSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(MEMBERSHIP_STATUSES).optional(), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMember = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3f33bcebf0c..9ec8ed7a0e7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -230,6 +230,7 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, @@ -240,6 +241,7 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type UpdateCompanyMember, type UpdateMemberPermissions, type UpdateUserCompanyAccess, } from "./access.js"; diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts new file mode 100644 index 00000000000..7b30399e187 --- /dev/null +++ b/server/src/__tests__/authz-company-access.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { assertCompanyAccess } from "../routes/authz.js"; + +function makeReq(input: { + method?: string; + actor: Express.Request["actor"]; +}) { + return { + method: input.method ?? "GET", + actor: input.actor, + } as Express.Request; +} + +describe("assertCompanyAccess", () => { + it("allows viewer memberships to read", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects viewer memberships for writes", () => { + const req = makeReq({ + method: "PATCH", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only"); + }); +}); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7dd342677f6..0b3becaffc9 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { agentJoinGrantsFromDefaults } from "../routes/access.js"; +import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; describe("agentJoinGrantsFromDefaults", () => { it("adds tasks:assign when invite defaults do not specify agent grants", () => { @@ -55,3 +60,30 @@ describe("agentJoinGrantsFromDefaults", () => { ]); }); }); + +describe("human invite roles", () => { + it("maps owner to the full management grant set", () => { + expect(grantsForHumanRole("owner")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + + it("defaults legacy or missing roles to operator", () => { + expect(normalizeHumanRole("member")).toBe("operator"); + expect(resolveHumanInviteRole(null)).toBe("operator"); + }); + + it("reads the configured human invite role from defaults", () => { + expect( + resolveHumanInviteRole({ + human: { + role: "viewer", + }, + }), + ).toBe("viewer"); + }); +}); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 6140c87d5b5..ce1360b8ce7 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -49,7 +49,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -63,6 +67,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa type: "board", userId, companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(roleRow), runId: runIdHeader ?? undefined, source: "session", @@ -91,6 +96,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa type: "board", userId: boardKey.userId, companyIds: access.companyIds, + memberships: access.memberships, isInstanceAdmin: access.isInstanceAdmin, keyId: boardKey.id, runId: runIdHeader || undefined, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 7d7dfe2b2b0..4df973358b4 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, eq, isNull, desc } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, @@ -25,11 +25,16 @@ import { createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, + updateCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared"; -import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import type { + DeploymentExposure, + DeploymentMode, + HumanCompanyMembershipRole, +} from "@paperclipai/shared"; import { forbidden, conflict, @@ -47,6 +52,15 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; +import { + agentJoinGrantsFromDefaults, + grantsFromDefaults, +} from "../services/invite-grants.js"; import { assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, @@ -867,6 +881,7 @@ function toInviteSummaryResponse( companyId: invite.companyId, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite.defaultsPayload), expiresAt: invite.expiresAt, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, @@ -1335,7 +1350,8 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null + agentMessage: string | null, + humanRole: HumanCompanyMembershipRole | null ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" @@ -1344,6 +1360,15 @@ function mergeInviteDefaults( if (agentMessage) { merged.agentMessage = agentMessage; } + if (humanRole) { + const nextHumanDefaults = + merged.human && typeof merged.human === "object" && !Array.isArray(merged.human) + ? { ...(merged.human as Record) } + : {}; + nextHumanDefaults.role = humanRole; + nextHumanDefaults.grants = grantsForHumanRole(humanRole); + merged.human = nextHumanDefaults; + } return Object.keys(merged).length ? merged : null; } @@ -1376,58 +1401,24 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } -function grantsFromDefaults( - defaultsPayload: Record | null | undefined, - key: "human" | "agent" -): Array<{ - permissionKey: (typeof PERMISSION_KEYS)[number]; - scope: Record | null; -}> { - if (!defaultsPayload || typeof defaultsPayload !== "object") return []; - const scoped = defaultsPayload[key]; - if (!scoped || typeof scoped !== "object") return []; - const grants = (scoped as Record).grants; - if (!Array.isArray(grants)) return []; - const validPermissionKeys = new Set(PERMISSION_KEYS); - const result: Array<{ - permissionKey: (typeof PERMISSION_KEYS)[number]; - scope: Record | null; - }> = []; - for (const item of grants) { - if (!item || typeof item !== "object") continue; - const record = item as Record; - if (typeof record.permissionKey !== "string") continue; - if (!validPermissionKeys.has(record.permissionKey)) continue; - result.push({ - permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], - scope: - record.scope && - typeof record.scope === "object" && - !Array.isArray(record.scope) - ? (record.scope as Record) - : null - }); - } - return result; +function extractInviteHumanRole( + defaultsPayload: Record | null | undefined +): HumanCompanyMembershipRole | null { + if (!defaultsPayload || typeof defaultsPayload !== "object") return null; + const scoped = defaultsPayload.human; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) return null; + return normalizeHumanRole((scoped as Record).role, "operator"); } -export function agentJoinGrantsFromDefaults( +function resolveHumanInviteGrants( defaultsPayload: Record | null | undefined ): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record | null; }> { - const grants = grantsFromDefaults(defaultsPayload, "agent"); - if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { - return grants; - } - return [ - ...grants, - { - permissionKey: "tasks:assign", - scope: null - } - ]; + const explicit = grantsFromDefaults(defaultsPayload, "human"); + if (explicit.length > 0) return explicit; + return grantsForHumanRole(resolveHumanInviteRole(defaultsPayload)); } type JoinRequestManagerCandidate = { @@ -1808,6 +1799,23 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } + function currentCompanyRole(req: Request, companyId: string): HumanCompanyMembershipRole | null { + if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner"; + if (req.actor.type !== "board") return null; + const membership = req.actor.memberships?.find((item) => item.companyId === companyId); + if (!membership) return null; + return normalizeHumanRole(membership.membershipRole, "operator"); + } + + function canManageCompanyMembers(req: Request, companyId: string) { + return currentCompanyRole(req, companyId) === "owner"; + } + + function canInviteHumanMembers(req: Request, companyId: string) { + const role = currentCompanyRole(req, companyId); + return role === "owner" || role === "admin"; + } + async function assertCanGenerateOpenClawInvitePrompt( req: Request, companyId: string @@ -1834,6 +1842,7 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyMembershipRole | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1847,7 +1856,8 @@ export function accessRoutes( allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage + normalizedAgentMessage, + input.allowedJoinTypes === "agent" ? null : (input.humanRole ?? "operator") ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1915,11 +1925,19 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); + if ( + req.body.humanRole && + (req.body.humanRole === "owner" || req.body.humanRole === "admin") && + !canManageCompanyMembers(req, companyId) + ) { + throw forbidden("Owner role required to invite admins or owners"); + } const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({ req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, + humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -2172,6 +2190,9 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } + if (inviteAlreadyAccepted && requestType === "human") { + throw notFound("Invite not found"); + } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || @@ -2264,6 +2285,12 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; + const humanRole = + requestType === "human" + ? resolveHumanInviteRole( + invite.defaultsPayload as Record | null + ) + : null; const created = !inviteAlreadyAccepted ? await db.transaction(async (tx) => { await tx @@ -2277,13 +2304,14 @@ export function accessRoutes( ) ); + const autoApproveHuman = requestType === "human"; const row = await tx .insert(joinRequests) .values({ inviteId: invite.id, companyId, requestType, - status: "pending_approval", + status: autoApproveHuman ? "approved" : "pending_approval", requestIp: requestIp(req), requestingUserId: requestType === "human" @@ -2300,7 +2328,11 @@ export function accessRoutes( agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, claimSecretHash, - claimSecretExpiresAt + claimSecretExpiresAt, + approvedByUserId: autoApproveHuman + ? req.actor.userId ?? "local-board" + : null, + approvedAt: autoApproveHuman ? new Date() : null, }) .returning() .then((rows) => rows[0]); @@ -2335,6 +2367,29 @@ export function accessRoutes( throw conflict("Join request not found"); } + if ( + requestType === "human" && + created.status === "approved" && + created.requestingUserId + ) { + await access.ensureMembership( + companyId, + "user", + created.requestingUserId, + humanRole ?? "operator", + "active" + ); + await access.setPrincipalGrants( + companyId, + "user", + created.requestingUserId, + resolveHumanInviteGrants( + invite.defaultsPayload as Record | null + ), + req.actor.userId ?? "local-board" + ); + } + if ( inviteAlreadyAccepted && requestType === "agent" && @@ -2444,9 +2499,12 @@ export function accessRoutes( ? req.actor.agentId ?? "invite-agent" : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), - action: inviteAlreadyAccepted - ? "join.request_replayed" - : "join.requested", + action: + requestType === "human" + ? "join.accepted" + : inviteAlreadyAccepted + ? "join.request_replayed" + : "join.requested", entityType: "join_request", entityId: created.id, details: { @@ -2818,11 +2876,108 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; - await assertCompanyPermission(req, companyId, "users:manage_permissions"); - const members = await access.listMembers(companyId); - res.json(members); + assertCompanyAccess(req, companyId); + const members = (await access.listMembers(companyId)).filter( + (member) => member.principalType === "user" + ); + const userIds = Array.from( + new Set(members.map((member) => member.principalId)) + ); + const users = userIds.length + ? await db + .select({ id: authUsers.id, email: authUsers.email, name: authUsers.name }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)) + : []; + const usersById = new Map(users.map((user) => [user.id, user])); + res.json({ + members: members.map((member) => ({ + ...member, + membershipRole: normalizeHumanRole(member.membershipRole, "operator"), + user: usersById.get(member.principalId) ?? null, + })), + access: { + currentUserRole: currentCompanyRole(req, companyId), + canManageMembers: canManageCompanyMembers(req, companyId), + canInviteUsers: canInviteHumanMembers(req, companyId), + }, + }); }); + router.patch( + "/companies/:companyId/members/:memberId", + validate(updateCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + assertCompanyAccess(req, companyId); + if (req.actor.type !== "board") throw unauthorized(); + if (!canManageCompanyMembers(req, companyId)) { + throw forbidden("Owner role required"); + } + + const existing = await access.getMemberById(companyId, memberId); + if (!existing) throw notFound("Member not found"); + if (existing.principalType !== "user") { + throw badRequest("Only human members can be updated"); + } + + const currentRole = normalizeHumanRole(existing.membershipRole, "operator"); + const nextRole = + req.body.membershipRole !== undefined + ? normalizeHumanRole(req.body.membershipRole, currentRole) + : currentRole; + const nextStatus = req.body.status ?? existing.status; + const allMembers = (await access.listMembers(companyId)).filter( + (member) => + member.principalType === "user" && + member.status === "active" && + normalizeHumanRole(member.membershipRole, "operator") === "owner" + ); + const removingLastOwner = + existing.status === "active" && + currentRole === "owner" && + (nextStatus !== "active" || nextRole !== "owner") && + allMembers.length <= 1; + if (removingLastOwner) { + throw conflict("Cannot remove the last active owner"); + } + + const updated = await access.updateMember(companyId, memberId, { + membershipRole: nextRole, + status: nextStatus, + }); + if (!updated) throw notFound("Member not found"); + + await access.setPrincipalGrants( + companyId, + "user", + updated.principalId, + grantsForHumanRole(nextRole), + req.actor.userId ?? "local-board" + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "member.updated", + entityType: "membership", + entityId: updated.id, + details: { + principalId: updated.principalId, + membershipRole: nextRole, + status: nextStatus, + }, + }); + + res.json({ + ...updated, + membershipRole: nextRole, + }); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index a881d4ff052..3480da493ce 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -27,6 +27,16 @@ export function assertCompanyAccess(req: Request, companyId: string) { if (!allowedCompanies.includes(companyId)) { throw forbidden("User does not have access to this company"); } + const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(req.method.toUpperCase()); + if (!isSafeMethod && Array.isArray(req.actor.memberships) && req.actor.memberships.length > 0) { + const membership = req.actor.memberships.find((item) => item.companyId === companyId); + if (membership?.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (membership?.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + } } } diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 3e30e1ab3b9..5b7833cf952 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -83,6 +83,14 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } + async function getMemberById(companyId: string, memberId: string) { + return db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + } + async function listActiveUserMemberships(companyId: string) { return db .select() @@ -103,11 +111,7 @@ export function accessService(db: Db) { grants: GrantInput[], grantedByUserId: string | null, ) { - const member = await db - .select() - .from(companyMemberships) - .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) - .then((rows) => rows[0] ?? null); + const member = await getMemberById(companyId, memberId); if (!member) return null; await db.transaction(async (tx) => { @@ -190,7 +194,7 @@ export function accessService(db: Db) { principalType: "user", principalId: userId, status: "active", - membershipRole: "member", + membershipRole: "operator", }); } }); @@ -359,11 +363,37 @@ export function accessService(db: Db) { }); } + async function updateMember( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }, + ) { + const existing = await getMemberById(companyId, memberId); + if (!existing) return null; + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + return db + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + } + return { isInstanceAdmin, canUser, hasPermission, getMembership, + getMemberById, ensureMembership, listMembers, listActiveUserMemberships, @@ -376,5 +406,6 @@ export function accessService(db: Db) { setPrincipalGrants, listPrincipalGrants, setPrincipalPermission, + updateMember, }; } diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 19e533c1163..0637457c54b 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -62,7 +62,11 @@ export function boardAuthService(db: Db) { .where(eq(authUsers.id, userId)) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -71,7 +75,7 @@ export function boardAuthService(db: Db) { eq(companyMemberships.status, "active"), ), ) - .then((rows) => rows.map((row) => row.companyId)), + .then((rows) => rows), db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) @@ -81,7 +85,8 @@ export function boardAuthService(db: Db) { return { user, - companyIds: memberships, + companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(adminRole), }; } diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts new file mode 100644 index 00000000000..6e8513ee451 --- /dev/null +++ b/server/src/services/company-member-roles.ts @@ -0,0 +1,59 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; + +const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [ + "owner", + "admin", + "operator", + "viewer", +]; + +export function normalizeHumanRole( + value: unknown, + fallback: HumanCompanyMembershipRole = "operator" +): HumanCompanyMembershipRole { + if (value === "member") return "operator"; + return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole) + ? (value as HumanCompanyMembershipRole) + : fallback; +} + +export function grantsForHumanRole( + role: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + switch (role) { + case "owner": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "admin": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "operator": + return [{ permissionKey: "tasks:assign", scope: null }]; + case "viewer": + return []; + } +} + +export function resolveHumanInviteRole( + defaultsPayload: Record | null | undefined +): HumanCompanyMembershipRole { + if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator"; + const scoped = defaultsPayload.human; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return "operator"; + } + return normalizeHumanRole((scoped as Record).role, "operator"); +} diff --git a/server/src/services/invite-grants.ts b/server/src/services/invite-grants.ts new file mode 100644 index 00000000000..e31d910fb8c --- /dev/null +++ b/server/src/services/invite-grants.ts @@ -0,0 +1,55 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; + +export function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) + ? (record.scope as Record) + : null, + }); + } + return result; +} + +export function agentJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "agent"); + if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { + return grants; + } + return [ + ...grants, + { + permissionKey: "tasks:assign", + scope: null, + }, + ]; +} diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 78b301eef35..50c2c3cb617 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -9,6 +9,11 @@ declare global { agentId?: string; companyId?: string; companyIds?: string[]; + memberships?: Array<{ + companyId: string; + membershipRole?: string | null; + status?: string; + }>; isInstanceAdmin?: boolean; keyId?: string; runId?: string; diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 90afd1dd68b..b63f8346653 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -1,11 +1,14 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; import { api } from "./client"; +export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer"; + type InviteSummary = { id: string; companyId: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; expiresAt: string; onboardingPath?: string; onboardingUrl?: string; @@ -87,16 +90,39 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; }; +export type CompanyMember = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + status: "pending" | "active" | "suspended"; + membershipRole: HumanCompanyRole; + createdAt: string; + updatedAt: string; + user: { id: string; email: string | null; name: string | null } | null; +}; + +export type CompanyMembersResponse = { + members: CompanyMember[]; + access: { + currentUserRole: HumanCompanyRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + }; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, input: { allowedJoinTypes?: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; defaultsPayload?: Record | null; agentMessage?: string | null; } = {}, @@ -127,6 +153,18 @@ export const accessApi = { listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") => api.get(`/companies/${companyId}/join-requests?status=${status}`), + listMembers: (companyId: string) => + api.get(`/companies/${companyId}/members`), + + updateMember: ( + companyId: string, + memberId: string, + input: { + membershipRole?: HumanCompanyRole | null; + status?: "pending" | "active" | "suspended"; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}`, input), + approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 8b7f2cd7440..6215300dd37 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -84,6 +84,7 @@ export const queryKeys = { joinRequests: (companyId: string, status: string = "pending_approval") => ["access", "join-requests", companyId, status] as const, invite: (token: string) => ["access", "invite", token] as const, + members: (companyId: string) => ["access", "members", companyId] as const, }, auth: { session: ["auth", "session"] as const, diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index e09f67c79d9..a52a6e1f1ae 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,10 +1,10 @@ import { ChangeEvent, useEffect, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; -import { accessApi } from "../api/access"; +import { accessApi, type HumanCompanyRole } from "../api/access"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; @@ -22,6 +22,13 @@ type AgentSnippetInput = { testResolutionUrl?: string | null; }; +const HUMAN_ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + export function CompanySettings() { const { companies, @@ -52,6 +59,10 @@ export function CompanySettings() { const [inviteSnippet, setInviteSnippet] = useState(null); const [snippetCopied, setSnippetCopied] = useState(false); const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0); + const [humanInviteRole, setHumanInviteRole] = useState("operator"); + const [humanInviteUrl, setHumanInviteUrl] = useState(null); + const [humanInviteCopied, setHumanInviteCopied] = useState(false); + const [humanInviteError, setHumanInviteError] = useState(null); const generalDirty = !!selectedCompany && @@ -80,6 +91,27 @@ export function CompanySettings() { } }); + const membersQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.access.members(selectedCompanyId) : ["access", "members", "none"], + queryFn: () => accessApi.listMembers(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + retry: false, + }); + + const memberAccess = membersQuery.data?.access; + const canManageMembers = memberAccess?.canManageMembers ?? false; + const canInviteUsers = memberAccess?.canInviteUsers ?? false; + const availableHumanInviteRoles: HumanCompanyRole[] = + memberAccess?.currentUserRole === "owner" + ? ["owner", "admin", "operator", "viewer"] + : ["operator", "viewer"]; + + useEffect(() => { + if (!availableHumanInviteRoles.includes(humanInviteRole)) { + setHumanInviteRole(availableHumanInviteRoles[0] ?? "operator"); + } + }, [availableHumanInviteRoles, humanInviteRole]); + const inviteMutation = useMutation({ mutationFn: () => accessApi.createOpenClawInvitePrompt(selectedCompanyId!), @@ -133,6 +165,54 @@ export function CompanySettings() { } }); + const humanInviteMutation = useMutation({ + mutationFn: () => + accessApi.createCompanyInvite(selectedCompanyId!, { + allowedJoinTypes: "human", + humanRole: humanInviteRole, + }), + onSuccess: async (invite) => { + const base = window.location.origin.replace(/\/+$/, ""); + const absoluteUrl = invite.inviteUrl.startsWith("http") + ? invite.inviteUrl + : `${base}${invite.inviteUrl}`; + setHumanInviteError(null); + setHumanInviteUrl(absoluteUrl); + try { + await navigator.clipboard.writeText(absoluteUrl); + setHumanInviteCopied(true); + setTimeout(() => setHumanInviteCopied(false), 2000); + } catch { + /* clipboard may not be available */ + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.access.members(selectedCompanyId!), + }); + }, + onError: (err) => { + setHumanInviteError( + err instanceof Error ? err.message : "Failed to create invite" + ); + }, + }); + + const memberMutation = useMutation({ + mutationFn: ({ + memberId, + membershipRole, + status, + }: { + memberId: string; + membershipRole?: HumanCompanyRole; + status?: "active" | "suspended"; + }) => accessApi.updateMember(selectedCompanyId!, memberId, { membershipRole, status }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.access.members(selectedCompanyId!), + }); + }, + }); + const syncLogoState = (nextLogoUrl: string | null) => { setLogoUrl(nextLogoUrl ?? ""); void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -174,6 +254,9 @@ export function CompanySettings() { setInviteSnippet(null); setSnippetCopied(false); setSnippetCopyDelightId(0); + setHumanInviteError(null); + setHumanInviteUrl(null); + setHumanInviteCopied(false); }, [selectedCompanyId]); const archiveMutation = useMutation({ @@ -397,6 +480,63 @@ export function CompanySettings() { Invites
+
+
+ + Invite a human teammate. + + +
+ {canInviteUsers ? ( + <> +
+ + + {humanInviteCopied && ( + Copied + )} +
+ {humanInviteError && ( +

{humanInviteError}

+ )} + {humanInviteUrl && ( +
+
+ Human invite link +
+