Add CEO OpenClaw invite endpoint and update onboarding UX

This commit is contained in:
Dotta
2026-03-07 18:19:06 -06:00
parent 2223afa0e9
commit 0233525e99
13 changed files with 608 additions and 120 deletions

View File

@@ -21,6 +21,7 @@ import {
acceptInviteSchema,
claimJoinRequestApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listJoinRequestsQuerySchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
@@ -1942,6 +1943,80 @@ export function accessRoutes(
if (!allowed) throw forbidden("Permission denied");
}
async function assertCanGenerateOpenClawInvitePrompt(
req: Request,
companyId: string
) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent") {
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
}
return;
}
if (req.actor.type !== "board") throw unauthorized();
if (isLocalImplicit(req)) return;
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
if (!allowed) throw forbidden("Permission denied");
}
async function createCompanyInviteForCompany(input: {
req: Request;
companyId: string;
allowedJoinTypes: "human" | "agent" | "both";
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}) {
const normalizedAgentMessage =
typeof input.agentMessage === "string"
? input.agentMessage.trim() || null
: null;
const insertValues = {
companyId: input.companyId,
inviteType: "company_join" as const,
allowedJoinTypes: input.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
input.defaultsPayload ?? null,
normalizedAgentMessage
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: input.req.actor.userId ?? null
};
let token: string | null = null;
let created: typeof invites.$inferSelect | null = null;
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
const candidateToken = createInviteToken();
try {
const row = await db
.insert(invites)
.values({
...insertValues,
tokenHash: hashToken(candidateToken)
})
.returning()
.then((rows) => rows[0]);
token = candidateToken;
created = row;
break;
} catch (error) {
if (!isInviteTokenHashCollisionError(error)) {
throw error;
}
}
}
if (!token || !created) {
throw conflict("Failed to generate a unique invite token. Please retry.");
}
return { token, created, normalizedAgentMessage };
}
router.get("/skills/index", (_req, res) => {
res.json({
skills: [
@@ -1967,49 +2042,14 @@ export function accessRoutes(
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "users:invite");
const normalizedAgentMessage =
typeof req.body.agentMessage === "string"
? req.body.agentMessage.trim() || null
: null;
const insertValues = {
companyId,
inviteType: "company_join" as const,
allowedJoinTypes: req.body.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
req.body.defaultsPayload ?? null,
normalizedAgentMessage
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: req.actor.userId ?? null
};
let token: string | null = null;
let created: typeof invites.$inferSelect | null = null;
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
const candidateToken = createInviteToken();
try {
const row = await db
.insert(invites)
.values({
...insertValues,
tokenHash: hashToken(candidateToken)
})
.returning()
.then((rows) => rows[0]);
token = candidateToken;
created = row;
break;
} catch (error) {
if (!isInviteTokenHashCollisionError(error)) {
throw error;
}
}
}
if (!token || !created) {
throw conflict(
"Failed to generate a unique invite token. Please retry."
);
}
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: req.body.allowedJoinTypes,
defaultsPayload: req.body.defaultsPayload ?? null,
agentMessage: req.body.agentMessage ?? null
});
await logActivity(db, {
companyId,
@@ -2041,6 +2081,51 @@ export function accessRoutes(
}
);
router.post(
"/companies/:companyId/openclaw/invite-prompt",
validate(createOpenClawInvitePromptSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: "agent",
defaultsPayload: null,
agentMessage: req.body.agentMessage ?? null
});
await logActivity(db, {
companyId,
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId:
req.actor.type === "agent"
? req.actor.agentId ?? "unknown-agent"
: req.actor.userId ?? "board",
action: "invite.openclaw_prompt_created",
entityType: "invite",
entityId: created.id,
details: {
inviteType: created.inviteType,
allowedJoinTypes: created.allowedJoinTypes,
expiresAt: created.expiresAt.toISOString(),
hasAgentMessage: Boolean(normalizedAgentMessage)
}
});
const inviteSummary = toInviteSummaryResponse(req, token, created);
res.status(201).json({
...created,
token,
inviteUrl: `/invite/${token}`,
onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage
});
}
);
router.get("/invites/:token", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");