mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(access): add multi-user auth and access APIs
This commit is contained in:
1 parent
682ffe6795
commit
0c07a8235f
15 files changed
+1000
-69
No files matched your search
@@ -53,6 +53,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,
|
||||
@@ -125,6 +128,8 @@ export {
|
||||
type LiveEventType,
|
||||
type PrincipalType,
|
||||
type MembershipStatus,
|
||||
type CompanyMembershipRole,
|
||||
type HumanCompanyMembershipRole,
|
||||
type InstanceUserRole,
|
||||
type InviteType,
|
||||
type InviteJoinType,
|
||||
@@ -289,11 +294,20 @@ export type {
|
||||
ActivityEvent,
|
||||
SidebarBadges,
|
||||
InboxDismissal,
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
CompanyMembership,
|
||||
CompanyInviteRecord,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
JoinRequest,
|
||||
JoinRequestInviteSummary,
|
||||
JoinRequestRecord,
|
||||
InstanceUserRoleGrant,
|
||||
AdminUserDirectoryEntry,
|
||||
UserCompanyAccessEntry,
|
||||
UserCompanyAccessResponse,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityEnvInput,
|
||||
CompanyPortabilityFileEntry,
|
||||
@@ -525,12 +539,18 @@ export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listCompanyInvitesQuerySchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
currentUserProfileSchema,
|
||||
authSessionSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
updateCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
type CreateFinanceEvent,
|
||||
@@ -539,12 +559,18 @@ export {
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListCompanyInvitesQuery,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type CurrentUserProfile,
|
||||
type AuthSession,
|
||||
type UpdateCurrentUserProfile,
|
||||
type UpdateCompanyMember,
|
||||
type UpdateMemberPermissions,
|
||||
type SearchAdminUsersQuery,
|
||||
type UpdateUserCompanyAccess,
|
||||
companySkillSourceTypeSchema,
|
||||
companySkillTrustLevelSchema,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AgentAdapterType,
|
||||
CompanyStatus,
|
||||
HumanCompanyMembershipRole,
|
||||
InstanceUserRole,
|
||||
InviteJoinType,
|
||||
InviteType,
|
||||
@@ -33,6 +35,30 @@ export interface PrincipalPermissionGrant {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AccessUserProfile {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyMemberRecord extends CompanyMembership {
|
||||
principalType: "user";
|
||||
membershipRole: HumanCompanyMembershipRole | null;
|
||||
user: AccessUserProfile | null;
|
||||
grants: PrincipalPermissionGrant[];
|
||||
}
|
||||
|
||||
export interface CompanyMembersResponse {
|
||||
members: CompanyMemberRecord[];
|
||||
access: {
|
||||
currentUserRole: HumanCompanyMembershipRole | null;
|
||||
canManageMembers: boolean;
|
||||
canInviteUsers: boolean;
|
||||
canApproveJoinRequests: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
@@ -48,6 +74,17 @@ export interface Invite {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type InviteState = "active" | "revoked" | "accepted" | "expired";
|
||||
|
||||
export interface CompanyInviteRecord extends Invite {
|
||||
companyName: string | null;
|
||||
humanRole: HumanCompanyMembershipRole | null;
|
||||
inviteMessage: string | null;
|
||||
state: InviteState;
|
||||
invitedByUser: AccessUserProfile | null;
|
||||
relatedJoinRequestId: string | null;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
inviteId: string;
|
||||
@@ -72,6 +109,26 @@ export interface JoinRequest {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface JoinRequestInviteSummary {
|
||||
id: string;
|
||||
inviteType: InviteType;
|
||||
allowedJoinTypes: InviteJoinType;
|
||||
humanRole: HumanCompanyMembershipRole | null;
|
||||
inviteMessage: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
acceptedAt: Date | null;
|
||||
invitedByUser: AccessUserProfile | null;
|
||||
}
|
||||
|
||||
export interface JoinRequestRecord extends JoinRequest {
|
||||
requesterUser: AccessUserProfile | null;
|
||||
approvedByUser: AccessUserProfile | null;
|
||||
rejectedByUser: AccessUserProfile | null;
|
||||
invite: JoinRequestInviteSummary | null;
|
||||
}
|
||||
|
||||
export interface InstanceUserRoleGrant {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -79,3 +136,21 @@ export interface InstanceUserRoleGrant {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AdminUserDirectoryEntry extends AccessUserProfile {
|
||||
isInstanceAdmin: boolean;
|
||||
activeCompanyMembershipCount: number;
|
||||
}
|
||||
|
||||
export interface UserCompanyAccessEntry extends CompanyMembership {
|
||||
principalType: "user";
|
||||
companyName: string | null;
|
||||
companyStatus: CompanyStatus | null;
|
||||
}
|
||||
|
||||
export interface UserCompanyAccessResponse {
|
||||
user: (AccessUserProfile & {
|
||||
isInstanceAdmin: boolean;
|
||||
}) | null;
|
||||
companyAccess: UserCompanyAccessEntry[];
|
||||
}
|
||||
@@ -166,11 +166,20 @@ export type { ActivityEvent } from "./activity.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
CompanyMembership,
|
||||
CompanyInviteRecord,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
JoinRequest,
|
||||
JoinRequestInviteSummary,
|
||||
JoinRequestRecord,
|
||||
InstanceUserRoleGrant,
|
||||
AdminUserDirectoryEntry,
|
||||
UserCompanyAccessEntry,
|
||||
UserCompanyAccessResponse,
|
||||
} from "./access.js";
|
||||
export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
|
||||
export type {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
WorkspaceRuntimeService,
|
||||
} from "./workspace-runtime.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
|
||||
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||
|
||||
@@ -50,6 +50,12 @@ export const listJoinRequestsQuerySchema = z.object({
|
||||
|
||||
export type ListJoinRequestsQuery = z.infer<typeof listJoinRequestsQuerySchema>;
|
||||
|
||||
export const listCompanyInvitesQuerySchema = z.object({
|
||||
state: z.enum(["active", "revoked", "accepted", "expired"]).optional(),
|
||||
});
|
||||
|
||||
export type ListCompanyInvitesQuery = z.infer<typeof listCompanyInvitesQuerySchema>;
|
||||
|
||||
export const claimJoinRequestApiKeySchema = z.object({
|
||||
claimSecret: z.string().min(16).max(256),
|
||||
});
|
||||
@@ -103,3 +109,58 @@ export const updateUserCompanyAccessSchema = z.object({
|
||||
});
|
||||
|
||||
export type UpdateUserCompanyAccess = z.infer<typeof updateUserCompanyAccessSchema>;
|
||||
|
||||
export const searchAdminUsersQuerySchema = z.object({
|
||||
query: z.string().trim().max(120).optional().default(""),
|
||||
});
|
||||
|
||||
export type SearchAdminUsersQuery = z.infer<typeof searchAdminUsersQuerySchema>;
|
||||
|
||||
const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/;
|
||||
|
||||
function isValidProfileImage(value: string): boolean {
|
||||
if (profileImageAssetPathPattern.test(value)) return true;
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const profileImageSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(4000)
|
||||
.refine(isValidProfileImage, { message: "Invalid profile image URL" });
|
||||
|
||||
export const currentUserProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
email: z.string().email().nullable(),
|
||||
name: z.string().min(1).max(120).nullable(),
|
||||
image: profileImageSchema.nullable(),
|
||||
});
|
||||
|
||||
export type CurrentUserProfile = z.infer<typeof currentUserProfileSchema>;
|
||||
|
||||
export const authSessionSchema = z.object({
|
||||
session: z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
}),
|
||||
user: currentUserProfileSchema,
|
||||
});
|
||||
|
||||
export type AuthSession = z.infer<typeof authSessionSchema>;
|
||||
|
||||
export const updateCurrentUserProfileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
image: z
|
||||
.union([profileImageSchema, z.literal(""), z.null()])
|
||||
.optional()
|
||||
.transform((value) => value === "" ? null : value),
|
||||
});
|
||||
|
||||
export type UpdateCurrentUserProfile = z.infer<typeof updateCurrentUserProfileSchema>;
|
||||
@@ -248,22 +248,34 @@ export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listCompanyInvitesQuerySchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
currentUserProfileSchema,
|
||||
authSessionSchema,
|
||||
updateCurrentUserProfileSchema,
|
||||
updateCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListCompanyInvitesQuery,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type CurrentUserProfile,
|
||||
type AuthSession,
|
||||
type UpdateCurrentUserProfile,
|
||||
type UpdateCompanyMember,
|
||||
type UpdateMemberPermissions,
|
||||
type SearchAdminUsersQuery,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user