Add company roles and human member management

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dottaandPaperclip committed 2026-03-30 07:34:19 -05:00
1 parent 6a72faf83b
commit 1eaa26a69b
18 files changed
+814 -73

No files matched your search

+24
View File
@@ -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<HumanCompanyMembershipRole, string> = {
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];
+7
View File
@@ -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,
+12
View File
@@ -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<typeof updateMemberPermissionsSchema>;
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<typeof updateCompanyMemberSchema>;
export const updateUserCompanyAccessSchema = z.object({
companyIds: z.array(z.string().uuid()).default([]),
});
+2
View File
@@ -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";
@@ -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");
});
});
@@ -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");
});
});
+7 -1
View File
@@ -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,
+212 -57
View File
@@ -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<string, unknown> | null | undefined,
agentMessage: string | null
agentMessage: string | null,
humanRole: HumanCompanyMembershipRole | null
): Record<string, unknown> | 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<string, unknown>) }
: {};
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<string | null> {
return user?.email ?? null;
}
function grantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
key: "human" | "agent"
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
const scoped = defaultsPayload[key];
if (!scoped || typeof scoped !== "object") return [];
const grants = (scoped as Record<string, unknown>).grants;
if (!Array.isArray(grants)) return [];
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
const result: Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> = [];
for (const item of grants) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
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<string, unknown>)
: null
});
}
return result;
function extractInviteHumanRole(
defaultsPayload: Record<string, unknown> | 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<string, unknown>).role, "operator");
}
export function agentJoinGrantsFromDefaults(
function resolveHumanInviteGrants(
defaultsPayload: Record<string, unknown> | null | undefined
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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),
+10
View File
@@ -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");
}
}
}
}
+37 -6
View File
@@ -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,
};
}
+8 -3
View File
@@ -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),
};
}
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>).role, "operator");
}
+55
View File
@@ -0,0 +1,55 @@
import { PERMISSION_KEYS } from "@paperclipai/shared";
export function grantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
key: "human" | "agent"
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
const scoped = defaultsPayload[key];
if (!scoped || typeof scoped !== "object") return [];
const grants = (scoped as Record<string, unknown>).grants;
if (!Array.isArray(grants)) return [];
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
const result: Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> = [];
for (const item of grants) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
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<string, unknown>)
: null,
});
}
return result;
}
export function agentJoinGrantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
const grants = grantsFromDefaults(defaultsPayload, "agent");
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
return grants;
}
return [
...grants,
{
permissionKey: "tasks:assign",
scope: null,
},
];
}
+5
View File
@@ -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;
+38
View File
@@ -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<string, unknown> | null;
agentMessage?: string | null;
} = {},
@@ -127,6 +153,18 @@ export const accessApi = {
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
listMembers: (companyId: string) =>
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
updateMember: (
companyId: string,
memberId: string,
input: {
membershipRole?: HumanCompanyRole | null;
status?: "pending" | "active" | "suspended";
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}`, input),
approveJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
+1
View File
@@ -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,
+241 -2
View File
@@ -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<HumanCompanyRole, string> = {
owner: "Owner",
admin: "Admin",
operator: "Operator",
viewer: "Viewer",
};
export function CompanySettings() {
const {
companies,
@@ -52,6 +59,10 @@ export function CompanySettings() {
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const [humanInviteRole, setHumanInviteRole] = useState<HumanCompanyRole>("operator");
const [humanInviteUrl, setHumanInviteUrl] = useState<string | null>(null);
const [humanInviteCopied, setHumanInviteCopied] = useState(false);
const [humanInviteError, setHumanInviteError] = useState<string | null>(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
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Invite a human teammate.
</span>
<HintIcon text="Creates a company invite link for authenticated human users." />
</div>
{canInviteUsers ? (
<>
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={humanInviteRole}
onChange={(e) => setHumanInviteRole(e.target.value as HumanCompanyRole)}
>
{availableHumanInviteRoles.map((role) => (
<option key={role} value={role}>
{HUMAN_ROLE_LABELS[role]}
</option>
))}
</select>
<Button
size="sm"
onClick={() => humanInviteMutation.mutate()}
disabled={humanInviteMutation.isPending}
>
{humanInviteMutation.isPending ? "Creating..." : "Create Human Invite"}
</Button>
{humanInviteCopied && (
<span className="text-xs text-green-600">Copied</span>
)}
</div>
{humanInviteError && (
<p className="text-sm text-destructive">{humanInviteError}</p>
)}
{humanInviteUrl && (
<div className="rounded-md border border-border bg-muted/30 p-2">
<div className="text-xs text-muted-foreground">
Human invite link
</div>
<textarea
className="mt-1 h-24 w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={humanInviteUrl}
readOnly
/>
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Your current company role cannot create human invites.
</p>
)}
</div>
<div className="border-t border-border pt-3" />
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Generate an OpenClaw agent invite snippet.
@@ -463,6 +603,105 @@ export function CompanySettings() {
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Team
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
{membersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : membersQuery.isError ? (
<p className="text-sm text-destructive">
{membersQuery.error instanceof Error
? membersQuery.error.message
: "Failed to load members"}
</p>
) : membersQuery.data?.members.length ? (
membersQuery.data.members.map((member) => {
const updatingMember =
memberMutation.isPending &&
memberMutation.variables?.memberId === member.id;
return (
<div
key={member.id}
className="flex flex-col gap-3 rounded-md border border-border/70 p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">
{member.user?.name || member.user?.email || member.principalId}
</div>
<div className="text-xs text-muted-foreground">
{member.user?.email || member.principalId}
</div>
</div>
<div className="text-xs text-muted-foreground">
{member.status === "active" ? "Active" : "Suspended"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{canManageMembers ? (
<select
className="rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={member.membershipRole}
disabled={updatingMember}
onChange={(e) =>
memberMutation.mutate({
memberId: member.id,
membershipRole: e.target.value as HumanCompanyRole,
status: member.status === "pending" ? "active" : member.status,
})
}
>
{availableHumanInviteRoles.map((role) => (
<option key={role} value={role}>
{HUMAN_ROLE_LABELS[role]}
</option>
))}
</select>
) : (
<span className="rounded-md border border-border px-2.5 py-1.5 text-sm text-muted-foreground">
{HUMAN_ROLE_LABELS[member.membershipRole]}
</span>
)}
{canManageMembers && (
<Button
size="sm"
variant="outline"
disabled={updatingMember}
onClick={() =>
memberMutation.mutate({
memberId: member.id,
membershipRole: member.membershipRole,
status: member.status === "active" ? "suspended" : "active",
})
}
>
{member.status === "active" ? "Suspend" : "Reactivate"}
</Button>
)}
{updatingMember && (
<span className="text-xs text-muted-foreground">Saving...</span>
)}
</div>
</div>
);
})
) : (
<p className="text-sm text-muted-foreground">
No human members yet.
</p>
)}
{memberMutation.isError && (
<p className="text-sm text-destructive">
{memberMutation.error instanceof Error
? memberMutation.error.message
: "Failed to update member"}
</p>
)}
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
+15 -3
View File
@@ -173,16 +173,26 @@ export function InviteLandingPage() {
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
const humanJoinApproved = joinType === "human" && payload.status === "approved";
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Join request submitted</h1>
<h1 className="text-lg font-semibold">
{humanJoinApproved ? "You joined the company" : "Join request submitted"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your request is pending admin approval. You will not have access until approved.
{humanJoinApproved
? "Your membership is active now. You can continue to the board."
: "Your request is pending admin approval. You will not have access until approved."}
</p>
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
Request ID: <span className="font-mono">{payload.id}</span>
</div>
{humanJoinApproved && (
<Button asChild className="mt-4">
<Link to="/">Open board</Link>
</Button>
)}
{claimSecret && claimApiKeyPath && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">One-time claim secret (save now)</p>
@@ -312,7 +322,9 @@ export function InviteLandingPage() {
? "Submitting…"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Submit join request"}
: joinType === "human"
? "Join company"
: "Submit join request"}
</Button>
</div>
</div>