Add CEO OpenClaw invite endpoint and update onboarding UX
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user