mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add company roles and human member management
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6a72faf83b
commit
1eaa26a69b
18 files changed
+814
-73
No files matched your search
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+5
@@ -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;
|
||||
|
||||
@@ -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`, {}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user