feat(access): add multi-user auth and access APIs

This commit is contained in:
Dotta committed 2026-04-11 08:12:54 -05:00
1 parent 682ffe6795
commit 0c07a8235f
15 files changed
+1000 -69

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
function createSelectChain(rows: unknown[]) {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
}
function createUpdateChain(row: unknown) {
return {
set(values: unknown) {
return {
where() {
return {
returning() {
return Promise.resolve([{ ...(row as Record<string, unknown>), ...(values as Record<string, unknown>) }]);
},
};
},
};
},
};
}
function createDb(row: Record<string, unknown>) {
return {
select: vi.fn(() => createSelectChain([row])),
update: vi.fn(() => createUpdateChain(row)),
} as any;
}
async function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
const [{ authRoutes }, { errorHandler }] = await Promise.all([
import("../routes/auth.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api/auth", authRoutes(createDb(row)));
app.use(errorHandler);
return app;
}
describe("auth routes", () => {
const baseUser = {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: "https://example.com/jane.png",
};
beforeEach(() => {
vi.resetModules();
});
it("returns the persisted user profile in the session payload", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body).toEqual({
session: {
id: "paperclip:session:user-1",
userId: "user-1",
},
user: baseUser,
});
});
it("updates the signed-in profile", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "local_implicit",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Board Operator", image: "" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "user-1",
name: "Board Operator",
email: "jane@example.com",
image: null,
});
});
it("accepts Paperclip asset paths for avatars", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Jane Example", image: "/api/assets/asset-1/content" });
expect(res.status).toBe(200);
expect(res.body.image).toBe("/api/assets/asset-1/content");
});
it("rejects invalid avatar image references", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Jane Example", image: "not-a-url" });
expect(res.status).toBe(400);
});
});
@@ -34,7 +34,7 @@ function createDbStub() {
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: { humanRole: "viewer" },
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
+2 -2
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { companyInviteExpiresAt } from "../routes/access.js";
describe("companyInviteExpiresAt", () => {
it("sets invite expiration to 10 minutes after invite creation time", () => {
it("sets invite expiration to 72 hours after invite creation time", () => {
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
const expiresAt = companyInviteExpiresAt(createdAtMs);
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z");
});
});
@@ -5,13 +5,17 @@ import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
const query = {
leftJoin() {
return query;
},
where() {
return Promise.resolve(rows);
},
};
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
return query;
},
};
}
@@ -48,7 +52,7 @@ function createApp(db: Record<string, unknown>) {
}
describe("GET /invites/:token", () => {
it("returns companyName in the invite summary response", async () => {
it("returns company branding in the invite summary response", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
@@ -64,7 +68,11 @@ describe("GET /invites/:token", () => {
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
createDbStub([invite], [{ name: "Acme Robotics" }]),
createDbStub([invite], [{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
}]),
);
const res = await request(app).get("/api/invites/pcp_invite_test");
@@ -72,6 +80,8 @@ describe("GET /invites/:token", () => {
expect(res.status).toBe(200);
expect(res.body.companyId).toBe("company-1");
expect(res.body.companyName).toBe("Acme Robotics");
expect(res.body.companyBrandColor).toBe("#114488");
expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content");
expect(res.body.inviteType).toBe("company_join");
});
});
@@ -154,7 +154,6 @@ describe("issue feedback trace routes", () => {
});
const res = await request(app).get("/api/feedback-traces/trace-1");
expect(res.status).toBe(403);
expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled();
});
@@ -76,7 +76,8 @@ function createDbStub() {
"feedbackDataSharingEnabled" in table;
const select = vi.fn((selection?: unknown) => ({
from(table: unknown) {
return {
const query = {
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(() => {
if (isInvitesTable(table)) {
return Promise.resolve([createdInvite]);
@@ -85,11 +86,16 @@ function createDbStub() {
(selection && typeof selection === "object" && "name" in selection) ||
isCompaniesTable(table)
) {
return Promise.resolve([{ name: "Acme AI" }]);
return Promise.resolve([{
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
}]);
}
return Promise.resolve([]);
}),
};
return query;
},
}));
return {
@@ -208,6 +214,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
expect(res.status).toBe(200);
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.companyBrandColor).toBe("#225577");
expect(res.body.companyLogoUrl).toBe("/api/assets/logo-1/content");
expect(res.body.inviteType).toBe("company_join");
expect(res.body.allowedJoinTypes).toBe("agent");
});
+2 -17
View File
@@ -27,6 +27,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { authRoutes } from "./routes/auth.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
@@ -116,23 +117,7 @@ export async function createApp(
resolveSession: opts.resolveSession,
}),
);
app.get("/api/auth/get-session", (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
res.json({
session: {
id: `paperclip:${req.actor.source}:${req.actor.userId}`,
userId: req.actor.userId,
},
user: {
id: req.actor.userId,
email: null,
name: req.actor.source === "local_implicit" ? "Local Board" : null,
},
});
});
app.use("/api/auth", authRoutes(db));
if (opts.betterAuthHandler) {
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
}
+537 -39
View File
@@ -9,14 +9,17 @@ 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,
authUsers,
companies,
companyLogos,
companyMemberships,
invites,
joinRequests
joinRequests,
principalPermissionGrants,
} from "@paperclipai/db";
import {
acceptInviteSchema,
@@ -24,8 +27,11 @@ import {
claimJoinRequestApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listCompanyInvitesQuerySchema,
listJoinRequestsQuerySchema,
resolveCliAuthChallengeSchema,
searchAdminUsersQuerySchema,
updateCompanyMemberSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
PERMISSION_KEYS
@@ -48,6 +54,11 @@ import {
logActivity,
notifyHireApproved
} from "../services/index.js";
import {
grantsForHumanRole,
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
import { assertCompanyAccess } from "./authz.js";
import {
claimBoardOwnership,
@@ -62,7 +73,7 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_";
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
const INVITE_TOKEN_MAX_RETRIES = 5;
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
function createInviteToken() {
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
@@ -858,8 +869,18 @@ function toInviteSummaryResponse(
req: Request,
token: string,
invite: typeof invites.$inferSelect,
companyName: string | null = null
company:
| string
| {
name: string | null;
brandColor: string | null;
logoUrl: string | null;
}
| null = null
) {
const companyInfo = typeof company === "string"
? { name: company, brandColor: null, logoUrl: null }
: company;
const baseUrl = requestBaseUrl(req);
const invitePath = `/invite/${token}`;
const onboardingPath = `/api/invites/${token}/onboarding`;
@@ -868,9 +889,12 @@ function toInviteSummaryResponse(
return {
id: invite.id,
companyId: invite.companyId,
companyName,
companyName: companyInfo?.name ?? null,
companyLogoUrl: companyInfo?.logoUrl ?? null,
companyBrandColor: companyInfo?.brandColor ?? null,
inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes,
humanRole: extractInviteHumanRole(invite),
expiresAt: invite.expiresAt,
invitePath,
inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath,
@@ -888,6 +912,261 @@ function toInviteSummaryResponse(
};
}
async function loadUsersById(db: Db, userIds: string[]) {
if (userIds.length === 0) return new Map<string, ReturnType<typeof toUserProfile>>();
const rows = await db
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.where(inArray(authUsers.id, userIds));
return new Map(rows.map((row) => [row.id, toUserProfile(row)]));
}
async function loadCompanyAccessSummary(
req: Request,
access: ReturnType<typeof accessService>,
companyId: string,
) {
if (req.actor.type !== "board") {
return {
currentUserRole: null,
canManageMembers: false,
canInviteUsers: false,
canApproveJoinRequests: false,
};
}
if (isLocalImplicit(req)) {
return {
currentUserRole: "owner" as const,
canManageMembers: true,
canInviteUsers: true,
canApproveJoinRequests: true,
};
}
const userId = req.actor.userId ?? null;
const membership =
userId ? await access.getMembership(companyId, "user", userId) : null;
const [canManageMembers, canInviteUsers, canApproveJoinRequests] =
await Promise.all([
access.canUser(companyId, userId, "users:manage_permissions"),
access.canUser(companyId, userId, "users:invite"),
access.canUser(companyId, userId, "joins:approve"),
]);
return {
currentUserRole:
membership?.status === "active" && membership.membershipRole
? normalizeHumanRole(membership.membershipRole, "operator")
: null,
canManageMembers,
canInviteUsers,
canApproveJoinRequests,
};
}
async function loadCompanyMemberRecords(db: Db, companyId: string) {
const members = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
),
)
.orderBy(desc(companyMemberships.updatedAt));
const userIds = [...new Set(members.map((member) => member.principalId))];
const [userMap, grants] = await Promise.all([
loadUsersById(db, userIds),
userIds.length > 0
? db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, "user"),
inArray(principalPermissionGrants.principalId, userIds),
),
)
: Promise.resolve([]),
]);
const grantsByPrincipalId = new Map<string, typeof grants>();
for (const grant of grants) {
const existing = grantsByPrincipalId.get(grant.principalId) ?? [];
existing.push(grant);
grantsByPrincipalId.set(grant.principalId, existing);
}
return members.map((member) => ({
...member,
principalType: "user" as const,
membershipRole: member.membershipRole
? normalizeHumanRole(member.membershipRole, "operator")
: null,
user: userMap.get(member.principalId) ?? null,
grants: grantsByPrincipalId.get(member.principalId) ?? [],
}));
}
async function loadCompanyInviteRecords(db: Db, companyId: string) {
const rows = await db
.select()
.from(invites)
.where(eq(invites.companyId, companyId))
.orderBy(desc(invites.createdAt));
const userIds = [
...new Set(
rows
.map((invite) => invite.invitedByUserId)
.filter((value): value is string => Boolean(value)),
),
];
const [userMap, joinRows, companyName] = await Promise.all([
loadUsersById(db, userIds),
db
.select({ id: joinRequests.id, inviteId: joinRequests.inviteId })
.from(joinRequests)
.where(eq(joinRequests.companyId, companyId)),
db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, companyId))
.then((companyRows) => companyRows[0]?.name ?? null),
]);
const joinRequestIdByInviteId = new Map(
joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]),
);
return rows.map((invite) => ({
...invite,
companyName,
humanRole: extractInviteHumanRole(invite),
inviteMessage: extractInviteMessage(invite),
state: inviteState(invite),
invitedByUser: invite.invitedByUserId
? userMap.get(invite.invitedByUserId) ?? null
: null,
relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null,
}));
}
async function loadJoinRequestRecords(db: Db, companyId: string) {
const rows = await db
.select()
.from(joinRequests)
.where(eq(joinRequests.companyId, companyId))
.orderBy(desc(joinRequests.createdAt));
const inviteIds = [...new Set(rows.map((row) => row.inviteId))];
const inviteRows = inviteIds.length
? await db
.select()
.from(invites)
.where(inArray(invites.id, inviteIds))
: [];
const userIds = [
...new Set(
[
...rows.map((row) => row.requestingUserId),
...rows.map((row) => row.approvedByUserId),
...rows.map((row) => row.rejectedByUserId),
...inviteRows.map((invite) => invite.invitedByUserId),
].filter((value): value is string => Boolean(value)),
),
];
const userMap = await loadUsersById(db, userIds);
const inviteMap = new Map(inviteRows.map((invite) => [invite.id, invite]));
return rows.map((row) => {
const invite = inviteMap.get(row.inviteId) ?? null;
return {
...toJoinRequestResponse(row),
requesterUser: row.requestingUserId
? userMap.get(row.requestingUserId) ?? null
: null,
approvedByUser: row.approvedByUserId
? userMap.get(row.approvedByUserId) ?? null
: null,
rejectedByUser: row.rejectedByUserId
? userMap.get(row.rejectedByUserId) ?? null
: null,
invite: invite
? {
id: invite.id,
inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes,
humanRole: extractInviteHumanRole(invite),
inviteMessage: extractInviteMessage(invite),
createdAt: invite.createdAt,
expiresAt: invite.expiresAt,
revokedAt: invite.revokedAt,
acceptedAt: invite.acceptedAt,
invitedByUser: invite.invitedByUserId
? userMap.get(invite.invitedByUserId) ?? null
: null,
}
: null,
};
});
}
async function loadUserCompanyAccessResponse(
db: Db,
access: ReturnType<typeof accessService>,
userId: string,
) {
const [memberships, user, isInstanceAdmin] = await Promise.all([
access.listUserCompanyAccess(userId),
db
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null),
access.isInstanceAdmin(userId),
]);
const companyIds = [...new Set(memberships.map((membership) => membership.companyId))];
const companyRows = companyIds.length
? await db
.select({
id: companies.id,
name: companies.name,
status: companies.status,
})
.from(companies)
.where(inArray(companies.id, companyIds))
: [];
const companyMap = new Map(companyRows.map((company) => [company.id, company]));
return {
user: user
? {
...toUserProfile(user),
isInstanceAdmin,
}
: null,
companyAccess: memberships.map((membership) => {
const company = companyMap.get(membership.companyId) ?? null;
return {
...membership,
principalType: "user" as const,
companyName: company?.name ?? null,
companyStatus: company?.status ?? null,
};
}),
};
}
function buildOnboardingDiscoveryDiagnostics(input: {
apiBaseUrl: string;
deploymentMode: DeploymentMode;
@@ -1352,12 +1631,22 @@ function extractInviteMessage(
function mergeInviteDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
agentMessage: string | null
agentMessage: string | null,
humanRole: "owner" | "admin" | "operator" | "viewer" | null = null,
): Record<string, unknown> | null {
const merged =
defaultsPayload && typeof defaultsPayload === "object"
? { ...defaultsPayload }
: {};
if (humanRole) {
const existingHuman =
isPlainObject(merged.human) ? { ...(merged.human as Record<string, unknown>) } : {};
merged.human = {
...existingHuman,
role: humanRole,
grants: grantsForHumanRole(humanRole),
};
}
if (agentMessage) {
merged.agentMessage = agentMessage;
}
@@ -1377,10 +1666,44 @@ function inviteExpired(invite: typeof invites.$inferSelect) {
return invite.expiresAt.getTime() <= Date.now();
}
function inviteState(invite: typeof invites.$inferSelect) {
if (invite.revokedAt) return "revoked" as const;
if (invite.acceptedAt) return "accepted" as const;
if (inviteExpired(invite)) return "expired" as const;
return "active" as const;
}
function extractInviteHumanRole(invite: typeof invites.$inferSelect) {
if (invite.allowedJoinTypes === "agent") return null;
return resolveHumanInviteRole(
invite.defaultsPayload as Record<string, unknown> | null | undefined,
);
}
function isLocalImplicit(req: Request) {
return req.actor.type === "board" && req.actor.source === "local_implicit";
}
function toUserProfile(
user:
| {
id: string;
email: string | null;
name: string | null;
image?: string | null;
}
| null
| undefined,
) {
if (!user) return null;
return {
id: user.id,
email: user.email ?? null,
name: user.name ?? null,
image: user.image ?? null,
};
}
async function resolveActorEmail(db: Db, req: Request): Promise<string | null> {
if (isLocalImplicit(req)) return "local@paperclip.local";
const userId = req.actor.userId;
@@ -1851,6 +2174,7 @@ export function accessRoutes(
req: Request;
companyId: string;
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: "owner" | "admin" | "operator" | "viewer" | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}) {
@@ -1858,13 +2182,18 @@ export function accessRoutes(
typeof input.agentMessage === "string"
? input.agentMessage.trim() || null
: null;
const effectiveHumanRole =
input.allowedJoinTypes === "agent"
? null
: input.humanRole ?? "operator";
const insertValues = {
companyId: input.companyId,
inviteType: "company_join" as const,
allowedJoinTypes: input.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
input.defaultsPayload ?? null,
normalizedAgentMessage
normalizedAgentMessage,
effectiveHumanRole,
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: input.req.actor.userId ?? null
@@ -1899,14 +2228,25 @@ export function accessRoutes(
return { token, created, normalizedAgentMessage };
}
async function getInviteCompanyName(companyId: string | null) {
if (!companyId) return null;
async function getInviteCompanyBranding(companyId: string | null) {
if (!companyId) {
return { name: null, brandColor: null, logoUrl: null };
}
const company = await db
.select({ name: companies.name })
.select({
name: companies.name,
brandColor: companies.brandColor,
logoAssetId: companyLogos.assetId,
})
.from(companies)
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id))
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
return company?.name ?? null;
return {
name: company?.name ?? null,
brandColor: company?.brandColor ?? null,
logoUrl: company?.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
};
}
router.get("/skills/available", (_req, res) => {
@@ -1947,6 +2287,7 @@ export function accessRoutes(
req,
companyId,
allowedJoinTypes: req.body.allowedJoinTypes,
humanRole: req.body.humanRole ?? null,
defaultsPayload: req.body.defaultsPayload ?? null,
agentMessage: req.body.agentMessage ?? null
});
@@ -1965,23 +2306,24 @@ export function accessRoutes(
inviteType: created.inviteType,
allowedJoinTypes: created.allowedJoinTypes,
expiresAt: created.expiresAt.toISOString(),
humanRole: extractInviteHumanRole(created),
hasAgentMessage: Boolean(normalizedAgentMessage)
}
});
const companyName = await getInviteCompanyName(created.companyId);
const companyBranding = await getInviteCompanyBranding(created.companyId);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
companyName
companyBranding
);
res.status(201).json({
...created,
token,
invitePath: inviteSummary.invitePath,
inviteUrl: inviteSummary.inviteUrl,
companyName,
companyName: companyBranding.name,
onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage
@@ -2000,6 +2342,7 @@ export function accessRoutes(
req,
companyId,
allowedJoinTypes: "agent",
humanRole: null,
defaultsPayload: null,
agentMessage: req.body.agentMessage ?? null
});
@@ -2022,19 +2365,19 @@ export function accessRoutes(
}
});
const companyName = await getInviteCompanyName(created.companyId);
const companyBranding = await getInviteCompanyBranding(created.companyId);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
companyName
companyBranding
);
res.status(201).json({
...created,
token,
invitePath: inviteSummary.invitePath,
inviteUrl: inviteSummary.inviteUrl,
companyName,
companyName: companyBranding.name,
onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage
@@ -2059,8 +2402,8 @@ export function accessRoutes(
throw notFound("Invite not found");
}
const companyName = await getInviteCompanyName(invite.companyId);
res.json(toInviteSummaryResponse(req, token, invite, companyName));
const companyBranding = await getInviteCompanyBranding(invite.companyId);
res.json(toInviteSummaryResponse(req, token, invite, companyBranding));
});
router.get("/invites/:token/onboarding", async (req, res) => {
@@ -2075,10 +2418,10 @@ export function accessRoutes(
throw notFound("Invite not found");
}
const companyName = await getInviteCompanyName(invite.companyId);
const companyBranding = await getInviteCompanyBranding(invite.companyId);
res.json(buildInviteOnboardingManifest(req, token, invite, {
...opts,
companyName
companyName: companyBranding.name
}));
});
@@ -2094,13 +2437,13 @@ export function accessRoutes(
throw notFound("Invite not found");
}
const companyName = await getInviteCompanyName(invite.companyId);
const companyBranding = await getInviteCompanyBranding(invite.companyId);
res
.type("text/plain; charset=utf-8")
.send(
buildInviteOnboardingTextDocument(req, token, invite, {
...opts,
companyName
companyName: companyBranding.name
})
);
});
@@ -2512,14 +2855,14 @@ export function accessRoutes(
const response = toJoinRequestResponse(created);
if (claimSecret) {
const companyName = await getInviteCompanyName(invite.companyId);
const companyBranding = await getInviteCompanyBranding(invite.companyId);
const onboardingManifest = buildInviteOnboardingManifest(
req,
token,
invite,
{
...opts,
companyName
companyName: companyBranding.name
}
);
res.status(202).json({
@@ -2581,22 +2924,30 @@ export function accessRoutes(
res.json(revoked);
});
router.get("/companies/:companyId/invites", async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "users:invite");
const query = listCompanyInvitesQuerySchema.parse(req.query);
const invitesForCompany = await loadCompanyInviteRecords(db, companyId);
res.json(
invitesForCompany.filter((invite) =>
query.state ? invite.state === query.state : true,
),
);
});
router.get("/companies/:companyId/join-requests", async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "joins:approve");
const query = listJoinRequestsQuerySchema.parse(req.query);
const all = await db
.select()
.from(joinRequests)
.where(eq(joinRequests.companyId, companyId))
.orderBy(desc(joinRequests.createdAt));
const all = await loadJoinRequestRecords(db, companyId);
const filtered = all.filter((row) => {
if (query.status && row.status !== query.status) return false;
if (query.requestType && row.requestType !== query.requestType)
return false;
return true;
});
res.json(filtered.map(toJoinRequestResponse));
res.json(filtered);
});
router.post(
@@ -2631,11 +2982,14 @@ export function accessRoutes(
if (existing.requestType === "human") {
if (!existing.requestingUserId)
throw conflict("Join request missing user identity");
const membershipRole = resolveHumanInviteRole(
invite.defaultsPayload as Record<string, unknown> | null,
);
await access.ensureMembership(
companyId,
"user",
existing.requestingUserId,
"member",
membershipRole,
"active"
);
const grants = grantsFromDefaults(
@@ -2877,10 +3231,80 @@ 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);
const [members, currentAccess] = await Promise.all([
loadCompanyMemberRecords(db, companyId),
loadCompanyAccessSummary(req, access, companyId),
]);
res.json({
members,
access: currentAccess,
});
});
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;
await assertCompanyPermission(req, companyId, "users:manage_permissions");
const existing = await access.getMemberById(companyId, memberId);
if (!existing) throw notFound("Member not found");
const nextMembershipRole =
req.body.membershipRole !== undefined
? req.body.membershipRole
: existing.membershipRole;
const nextStatus = req.body.status ?? existing.status;
if (
existing.principalType === "user" &&
existing.status === "active" &&
existing.membershipRole === "owner" &&
(nextStatus !== "active" || nextMembershipRole !== "owner")
) {
const activeOwnerCount = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
eq(companyMemberships.membershipRole, "owner"),
),
)
.then((rows) => rows.length);
if (activeOwnerCount <= 1) {
throw conflict("Cannot remove the last active owner");
}
}
const updated = await access.updateMember(companyId, memberId, req.body);
if (!updated) throw notFound("Member not found");
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company_member.updated",
entityType: "company_membership",
entityId: memberId,
details: {
membershipRole: updated.membershipRole,
status: updated.status,
},
});
const member = (await loadCompanyMemberRecords(db, companyId)).find(
(entry) => entry.id === memberId,
);
if (!member) throw notFound("Member not found");
res.json(member);
}
);
router.patch(
"/companies/:companyId/members/:memberId/permissions",
validate(updateMemberPermissionsSchema),
@@ -2895,7 +3319,22 @@ export function accessRoutes(
req.actor.userId ?? null
);
if (!updated) throw notFound("Member not found");
res.json(updated);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company_member.permissions_updated",
entityType: "company_membership",
entityId: memberId,
details: {
grantCount: req.body.grants?.length ?? 0,
},
});
const member = (await loadCompanyMemberRecords(db, companyId)).find(
(entry) => entry.id === memberId,
);
if (!member) throw notFound("Member not found");
res.json(member);
}
);
@@ -2909,6 +3348,66 @@ export function accessRoutes(
}
);
router.get("/admin/users", async (req, res) => {
await assertInstanceAdmin(req);
const query = searchAdminUsersQuerySchema.parse(req.query);
const needle = query.query.trim().toLowerCase();
const users = await db
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.orderBy(desc(authUsers.updatedAt));
const filteredUsers = needle
? users.filter((user) =>
[user.name, user.email]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(needle)),
)
: users;
const userIds = filteredUsers.slice(0, 50).map((user) => user.id);
const memberships = userIds.length
? await db
.select({
principalId: companyMemberships.principalId,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
inArray(companyMemberships.principalId, userIds),
),
)
: [];
const membershipCountByUserId = new Map<string, number>();
for (const membership of memberships) {
membershipCountByUserId.set(
membership.principalId,
(membershipCountByUserId.get(membership.principalId) ?? 0) + 1,
);
}
const adminIds = new Set(
await Promise.all(
userIds.map(async (userId) =>
(await access.isInstanceAdmin(userId)) ? userId : null,
),
).then((values) => values.filter((value): value is string => Boolean(value))),
);
res.json(
filteredUsers.slice(0, 50).map((user) => ({
...toUserProfile(user),
isInstanceAdmin: adminIds.has(user.id),
activeCompanyMembershipCount:
membershipCountByUserId.get(user.id) ?? 0,
})),
);
});
router.post(
"/admin/users/:userId/demote-instance-admin",
async (req, res) => {
@@ -2923,8 +3422,7 @@ export function accessRoutes(
router.get("/admin/users/:userId/company-access", async (req, res) => {
await assertInstanceAdmin(req);
const userId = req.params.userId as string;
const memberships = await access.listUserCompanyAccess(userId);
res.json(memberships);
res.json(await loadUserCompanyAccessResponse(db, access, userId));
});
router.put(
@@ -2933,11 +3431,11 @@ export function accessRoutes(
async (req, res) => {
await assertInstanceAdmin(req);
const userId = req.params.userId as string;
const memberships = await access.setUserCompanyAccess(
await access.setUserCompanyAccess(
userId,
req.body.companyIds ?? []
);
res.json(memberships);
res.json(await loadUserCompanyAccessResponse(db, access, userId));
}
);
+100
View File
@@ -0,0 +1,100 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { authUsers } from "@paperclipai/db";
import {
authSessionSchema,
currentUserProfileSchema,
updateCurrentUserProfileSchema,
} from "@paperclipai/shared";
import { unauthorized } from "../errors.js";
import { validate } from "../middleware/validate.js";
async function loadCurrentUserProfile(db: Db, userId: string) {
const user = await db
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null);
if (!user) {
throw unauthorized("Signed-in user not found");
}
return currentUserProfileSchema.parse({
id: user.id,
email: user.email ?? null,
name: user.name ?? null,
image: user.image ?? null,
});
}
export function authRoutes(db: Db) {
const router = Router();
router.get("/get-session", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const user = await loadCurrentUserProfile(db, req.actor.userId);
res.json(authSessionSchema.parse({
session: {
id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`,
userId: req.actor.userId,
},
user,
}));
});
router.get("/profile", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
res.json(await loadCurrentUserProfile(db, req.actor.userId));
});
router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const patch = updateCurrentUserProfileSchema.parse(req.body);
const now = new Date();
const updated = await db
.update(authUsers)
.set({
name: patch.name,
image: patch.image ?? null,
updatedAt: now,
})
.where(eq(authUsers.id, req.actor.userId))
.returning({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.then((rows) => rows[0] ?? null);
if (!updated) {
throw unauthorized("Signed-in user not found");
}
res.json(currentUserProfileSchema.parse({
id: updated.id,
email: updated.email ?? null,
name: updated.name ?? null,
image: updated.image ?? null,
}));
});
return router;
}