diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f40f0c83..65cf53cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -380,7 +380,7 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; export const PRINCIPAL_TYPES = ["user", "agent"] as const; export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; -export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; +export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended", "archived"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; export const COMPANY_MEMBERSHIP_ROLES = [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4f84ab6..82d90755 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -316,6 +316,14 @@ export type { LiveEvent, DashboardSummary, ActivityEvent, + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, SidebarBadges, SidebarOrderPreference, InboxDismissal, @@ -600,6 +608,7 @@ export { updateCurrentUserProfileSchema, updateCompanyMemberSchema, updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, @@ -621,6 +630,7 @@ export { type UpdateCurrentUserProfile, type UpdateCompanyMember, type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, type SearchAdminUsersQuery, type UpdateUserCompanyAccess, diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index 25f605ba..015d5179 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -47,6 +47,10 @@ export interface CompanyMemberRecord extends CompanyMembership { membershipRole: HumanCompanyMembershipRole | null; user: AccessUserProfile | null; grants: PrincipalPermissionGrant[]; + removal?: { + canArchive: boolean; + reason: string | null; + }; } export interface CompanyMembersResponse { @@ -59,6 +63,11 @@ export interface CompanyMembersResponse { }; } +export interface ArchiveCompanyMemberResponse { + member: CompanyMemberRecord; + reassignedIssueCount: number; +} + export interface Invite { id: string; companyId: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0089c92d..600d97a8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -169,6 +169,16 @@ export type { export type { LiveEvent } from "./live.js"; export type { DashboardSummary } from "./dashboard.js"; export type { ActivityEvent } from "./activity.js"; +export type { + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, +} from "./user-profile.js"; export type { SidebarBadges } from "./sidebar-badges.js"; export type { SidebarOrderPreference } from "./sidebar-preferences.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; @@ -176,6 +186,7 @@ export type { AccessUserProfile, CompanyMemberRecord, CompanyMembersResponse, + ArchiveCompanyMemberResponse, CompanyMembership, CompanyInviteListResponse, CompanyInviteRecord, diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts new file mode 100644 index 00000000..2ed483da --- /dev/null +++ b/packages/shared/src/types/user-profile.ts @@ -0,0 +1,88 @@ +import type { IssuePriority, IssueStatus } from "../constants.js"; + +export interface UserProfileIdentity { + id: string; + slug: string; + name: string | null; + email: string | null; + image: string | null; + membershipRole: string | null; + membershipStatus: string; + joinedAt: Date; +} + +export interface UserProfileWindowStats { + key: "last7" | "last30" | "all"; + label: string; + touchedIssues: number; + createdIssues: number; + completedIssues: number; + assignedOpenIssues: number; + commentCount: number; + activityCount: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + costEventCount: number; +} + +export interface UserProfileDailyPoint { + date: string; + activityCount: number; + completedIssues: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileIssueSummary { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; + updatedAt: Date; + completedAt: Date | null; +} + +export interface UserProfileActivitySummary { + id: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: Date; +} + +export interface UserProfileAgentUsage { + agentId: string; + agentName: string | null; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileProviderUsage { + provider: string; + biller: string; + model: string; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileResponse { + user: UserProfileIdentity; + stats: UserProfileWindowStats[]; + daily: UserProfileDailyPoint[]; + recentIssues: UserProfileIssueSummary[]; + recentActivity: UserProfileActivitySummary[]; + topAgents: UserProfileAgentUsage[]; + topProviders: UserProfileProviderUsage[]; +} diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 8cd8f5c3..e104bdec 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -5,7 +5,6 @@ import { INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, - MEMBERSHIP_STATUSES, PERMISSION_KEYS, } from "../constants.js"; import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; @@ -97,9 +96,11 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +const editableMembershipStatuses = ["pending", "active", "suspended"] as const; + export const updateCompanyMemberSchema = z.object({ membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), - status: z.enum(MEMBERSHIP_STATUSES).optional(), + status: z.enum(editableMembershipStatuses).optional(), }).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { message: "membershipRole or status is required", }); @@ -108,7 +109,7 @@ export type UpdateCompanyMember = z.infer; export const updateCompanyMemberWithPermissionsSchema = z.object({ membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), - status: z.enum(MEMBERSHIP_STATUSES).optional(), + status: z.enum(editableMembershipStatuses).optional(), grants: updateMemberPermissionsSchema.shape.grants.default([]), }).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { message: "membershipRole or status is required", @@ -116,6 +117,26 @@ export const updateCompanyMemberWithPermissionsSchema = z.object({ export type UpdateCompanyMemberWithPermissions = z.infer; +export const archiveCompanyMemberSchema = z.object({ + reassignment: z + .object({ + assigneeAgentId: z.string().uuid().optional().nullable(), + assigneeUserId: z.string().uuid().optional().nullable(), + }) + .optional() + .nullable(), +}).superRefine((value, ctx) => { + if (value.reassignment?.assigneeAgentId && value.reassignment.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Choose either an agent or user reassignment target", + path: ["reassignment"], + }); + } +}); + +export type ArchiveCompanyMember = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ac99dcd5..4a2a4232 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -266,6 +266,7 @@ export { updateCurrentUserProfileSchema, updateCompanyMemberSchema, updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, @@ -283,6 +284,7 @@ export { type UpdateCurrentUserProfile, type UpdateCompanyMember, type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, type SearchAdminUsersQuery, type UpdateUserCompanyAccess, diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index de839370..f9ac3e64 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -5,6 +5,8 @@ import { companies, companyMemberships, createDb, + instanceUserRoles, + issues, principalPermissionGrants, } from "@paperclipai/db"; import { @@ -51,7 +53,9 @@ describeEmbeddedPostgres("access service", () => { }, 20_000); afterEach(async () => { + await db.delete(issues); await db.delete(principalPermissionGrants); + await db.delete(instanceUserRoles); await db.delete(companyMemberships); await db.delete(companies); }); @@ -96,4 +100,125 @@ describeEmbeddedPostgres("access service", () => { .then((rows) => rows[0]!); expect(unchanged.status).toBe("active"); }); + + it("archives members, clears grants, and reassigns open issues without deleting history", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `member-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign", + grantedByUserId: owner.principalId, + }); + const openIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Open assigned issue", + status: "in_progress", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + const doneIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Historical assigned issue", + status: "done", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + const result = await access.archiveMember(company.id, member.id, { + reassignment: { assigneeUserId: owner.principalId }, + }); + + expect(result?.reassignedIssueCount).toBe(1); + const archived = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, member.id)) + .then((rows) => rows[0]!); + expect(archived.status).toBe("archived"); + + const remainingGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, member.principalId)); + expect(remainingGrants).toHaveLength(0); + + const reassignedIssue = await db + .select() + .from(issues) + .where(eq(issues.id, openIssue.id)) + .then((rows) => rows[0]!); + expect(reassignedIssue.assigneeUserId).toBe(owner.principalId); + expect(reassignedIssue.status).toBe("todo"); + + const historicalIssue = await db + .select() + .from(issues) + .where(eq(issues.id, doneIssue.id)) + .then((rows) => rows[0]!); + expect(historicalIssue.assigneeUserId).toBe(member.principalId); + }); + + it("rejects instance-level company access removal for self and protected users", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("You cannot remove yourself"); + + const admin = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + await expect( + access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Owners and admins cannot be removed from company access"); + + const operator = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(instanceUserRoles).values({ + userId: operator.principalId, + role: "instance_admin", + }); + + await expect( + access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Instance admins cannot be removed from company access"); + }); }); diff --git a/server/src/__tests__/user-profile-routes.test.ts b/server/src/__tests__/user-profile-routes.test.ts new file mode 100644 index 00000000..75057786 --- /dev/null +++ b/server/src/__tests__/user-profile-routes.test.ts @@ -0,0 +1,205 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + authUsers, + companies, + companyMemberships, + costEvents, + createDb, + issueComments, + issues, +} from "@paperclipai/db"; +import { errorHandler } from "../middleware/index.js"; +import { userProfileRoutes } from "../routes/user-profiles.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + let userId!: string; + let agentId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-user-profile-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + userId = randomUUID(); + agentId = randomUUID(); + const now = new Date(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values({ + id: userId, + name: "Dotta", + email: "dotta@example.com", + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "owner", + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + }); + + afterEach(async () => { + await db.delete(costEvents); + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(authUsers); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + }; + next(); + }); + app.use("/api", userProfileRoutes(db)); + app.use(errorHandler); + return app; + } + + it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => { + const doneIssueId = randomUUID(); + const openIssueId = randomUUID(); + const now = new Date(); + const older = new Date(now.getTime() - 60_000); + + await db.insert(issues).values([ + { + id: doneIssueId, + companyId, + title: "Ship profile page", + status: "done", + priority: "high", + createdByUserId: userId, + identifier: "USR-1", + completedAt: now, + createdAt: now, + updatedAt: now, + }, + { + id: openIssueId, + companyId, + title: "Review profile copy", + status: "in_progress", + priority: "medium", + assigneeUserId: userId, + identifier: "USR-2", + createdAt: older, + updatedAt: older, + }, + ]); + await db.insert(issueComments).values({ + companyId, + issueId: openIssueId, + authorUserId: userId, + body: "Looks good.", + createdAt: now, + updatedAt: now, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: userId, + action: "issue.updated", + entityType: "issue", + entityId: doneIssueId, + createdAt: now, + }); + await db.insert(costEvents).values({ + companyId, + agentId, + issueId: doneIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-test", + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costCents: 42, + occurredAt: now, + }); + + const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`); + + expect(response.status).toBe(200); + expect(response.body.user.slug).toBe("dotta"); + expect(response.body.user.membershipRole).toBe("owner"); + expect(response.body.stats).toHaveLength(3); + + const all = response.body.stats.find((entry: { key: string }) => entry.key === "all"); + expect(all).toMatchObject({ + touchedIssues: 2, + createdIssues: 1, + completedIssues: 1, + assignedOpenIssues: 1, + commentCount: 1, + activityCount: 1, + costCents: 42, + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costEventCount: 1, + }); + expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]); + expect(response.body.recentActivity[0].action).toBe("issue.updated"); + expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 }); + expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index cd0d88db..b1389b74 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,6 +23,7 @@ import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; @@ -195,6 +196,7 @@ export async function createApp( api.use(costRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); api.use(inboxDismissalRoutes(db)); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index c86637ca..aeebd615 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { assets, @@ -18,6 +18,7 @@ import { companies, companyLogos, companyMemberships, + instanceUserRoles, invites, joinRequests, principalPermissionGrants, @@ -34,11 +35,12 @@ import { searchAdminUsersQuerySchema, updateCompanyMemberWithPermissionsSchema, updateCompanyMemberSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared"; -import type { DeploymentExposure, DeploymentMode, PermissionKey } from "@paperclipai/shared"; +import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared"; import { forbidden, conflict, @@ -994,7 +996,11 @@ async function loadCompanyAccessSummary( }; } -async function loadCompanyMemberRecords(db: Db, companyId: string) { +async function loadCompanyMemberRecords( + db: Db, + companyId: string, + options: { includeArchived?: boolean } = {}, +) { const members = await db .select() .from(companyMemberships) @@ -1002,6 +1008,7 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) { and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), + options.includeArchived ? undefined : ne(companyMemberships.status, "archived"), ), ) .orderBy(desc(companyMemberships.updatedAt)); @@ -1041,6 +1048,116 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) { })); } +type CompanyMemberRecord = Awaited>[number]; + +const humanRoleRank: Record = { + viewer: 1, + operator: 2, + admin: 3, + owner: 4, +}; + +async function resolveActorHumanRole( + req: Request, + access: ReturnType, + companyId: string, +): Promise { + if (req.actor.type !== "board") return null; + if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner"; + const userId = req.actor.userId ?? null; + if (!userId) return null; + const membership = await access.getMembership(companyId, "user", userId); + if (membership?.status !== "active" || !membership.membershipRole) return null; + return normalizeHumanRole(membership.membershipRole, "operator"); +} + +async function getProtectedMemberReason( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + opts?: { + actorRole?: HumanCompanyMembershipRole | null; + instanceAdminUserIds?: ReadonlySet; + operation?: "archive" | "update"; + }, +): Promise { + if (member.principalType !== "user") return "Only human company members can be removed."; + if (req.actor.type !== "board") return "Board access is required to remove members."; + if (member.principalId === req.actor.userId) return "You cannot remove yourself."; + const isTargetInstanceAdmin = opts?.instanceAdminUserIds + ? opts.instanceAdminUserIds.has(member.principalId) + : await access.isInstanceAdmin(member.principalId); + if (isTargetInstanceAdmin) { + return "Instance admins cannot be removed from company access."; + } + + const targetRole = member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : "operator"; + if (opts?.operation === "archive") { + if (targetRole === "owner") return "Board owners cannot be removed from company access."; + if (targetRole === "admin") return "Company admins cannot be removed from company access."; + } + + const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId); + if (!actorRole) return "Only active company members can remove users."; + if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) { + return "You can only remove users below your company role."; + } + + return null; +} + +async function assertCanManageCompanyMember( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + operation: "archive" | "update" = "update", +) { + const reason = await getProtectedMemberReason(req, access, companyId, member, { operation }); + if (reason) throw forbidden(reason); +} + +async function addCompanyMemberRemovalAccess( + req: Request, + db: Db, + access: ReturnType, + companyId: string, + members: CompanyMemberRecord[], +) { + const actorRole = await resolveActorHumanRole(req, access, companyId); + const userIds = [...new Set(members + .filter((member) => member.principalType === "user") + .map((member) => member.principalId))]; + const instanceAdminUserIds = userIds.length > 0 + ? new Set( + await db + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows.map((row) => row.userId)), + ) + : new Set(); + return Promise.all( + members.map(async (member) => { + const reason = await getProtectedMemberReason(req, access, companyId, member, { + actorRole, + instanceAdminUserIds, + operation: "archive", + }); + return { + ...member, + removal: { + canArchive: !reason, + reason, + }, + }; + }), + ); +} + async function loadCompanyUserDirectory(db: Db, companyId: string) { const members = await db .select({ @@ -3604,7 +3721,7 @@ export function accessRoutes( loadCompanyAccessSummary(req, access, companyId), ]); res.json({ - members, + members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members), access: currentAccess, }); }); @@ -3623,6 +3740,9 @@ export function accessRoutes( const companyId = req.params.companyId as string; const memberId = req.params.memberId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); const updated = await db.transaction(async (tx) => { await tx.execute(sql` @@ -3717,6 +3837,9 @@ export function accessRoutes( const companyId = req.params.companyId as string; const memberId = req.params.memberId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); const updated = await db.transaction(async (tx) => { await tx.execute(sql` @@ -3834,6 +3957,47 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/members/:memberId/archive", + validate(archiveCompanyMemberSchema), + 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 memberToArchive = await access.getMemberById(companyId, memberId); + if (!memberToArchive) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToArchive, "archive"); + + const result = await access.archiveMember(companyId, memberId, { + reassignment: req.body.reassignment ?? null, + }); + if (!result) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.archived", + entityType: "company_membership", + entityId: memberId, + details: { + principalId: result.member.principalId, + reassignedIssueCount: result.reassignedIssueCount, + reassignment: req.body.reassignment ?? null, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json({ + member, + reassignedIssueCount: result.reassignedIssueCount, + }); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), @@ -3841,6 +4005,9 @@ export function accessRoutes( const companyId = req.params.companyId as string; const memberId = req.params.memberId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); const updated = await access.setMemberPermissions( companyId, memberId, @@ -3962,7 +4129,8 @@ export function accessRoutes( const userId = req.params.userId as string; await access.setUserCompanyAccess( userId, - req.body.companyIds ?? [] + req.body.companyIds ?? [], + { actorUserId: req.actor.userId ?? null }, ); res.json(await loadUserCompanyAccessResponse(db, access, userId)); } diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts new file mode 100644 index 00000000..7b50e94a --- /dev/null +++ b/server/src/routes/user-profiles.ts @@ -0,0 +1,436 @@ +import { Router } from "express"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + activityLog, + agents, + authUsers, + companyMemberships, + costEvents, + issueComments, + issues, +} from "@paperclipai/db"; +import type { + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileResponse, + UserProfileWindowStats, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +type CompanyUserRow = { + id: string; + principalId: string; + status: string; + membershipRole: string | null; + createdAt: Date; + userId: string | null; + name: string | null; + email: string | null; + image: string | null; +}; + +const PROFILE_WINDOWS = [ + { key: "last7", label: "Last 7 days", days: 7 }, + { key: "last30", label: "Last 30 days", days: 30 }, + { key: "all", label: "All time", days: null }, +] as const; + +function slugifyUserPart(value: string | null | undefined) { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || null; +} + +function userSlugCandidates(row: CompanyUserRow) { + const candidates = new Set(); + const add = (value: string | null | undefined) => { + const slug = slugifyUserPart(value); + if (slug) candidates.add(slug); + }; + add(row.name); + add(row.email?.split("@")[0]); + add(row.email); + add(row.principalId); + return [...candidates]; +} + +async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise { + const slug = slugifyUserPart(rawSlug); + if (!slug) return null; + + const rows = await db + .select({ + id: companyMemberships.id, + principalId: companyMemberships.principalId, + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + createdAt: companyMemberships.createdAt, + userId: authUsers.id, + name: authUsers.name, + email: authUsers.email, + image: authUsers.image, + }) + .from(companyMemberships) + .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)) + .limit(200); + + return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null; +} + +function userIssueInvolvementSql(companyId: string, userId: string) { + return sql` + ( + ${issues.createdByUserId} = ${userId} + OR ${issues.assigneeUserId} = ${userId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.companyId} = ${companyId} + AND ${issueComments.issueId} = ${issues.id} + AND ${issueComments.authorUserId} = ${userId} + ) + ) + `; +} + +function windowStart(days: number | null) { + if (!days) return null; + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function isoDay(date: Date) { + return startOfUtcDay(date).toISOString().slice(0, 10); +} + +function dayKeyExpr(dateSql: ReturnType) { + return sql`to_char(date_trunc('day', ${dateSql}), 'YYYY-MM-DD')`; +} + +function sumNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + +async function loadWindowStats( + db: Db, + companyId: string, + userId: string, + key: UserProfileWindowStats["key"], + label: string, + from: Date | null, +): Promise { + const involvement = userIssueInvolvementSql(companyId, userId); + const openStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked"]; + const fromIso = from?.toISOString(); + + const [issueStats] = await db + .select({ + touchedIssues: sql`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + createdIssues: sql`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + completedIssues: sql`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + assignedOpenIssues: sql`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt))); + + const commentConditions = [ + eq(issueComments.companyId, companyId), + eq(issueComments.authorUserId, userId), + ]; + if (from) commentConditions.push(gte(issueComments.createdAt, from)); + const [commentStats] = await db + .select({ count: sql`count(*)::int` }) + .from(issueComments) + .where(and(...commentConditions)); + + const activityConditions = [ + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ]; + if (from) activityConditions.push(gte(activityLog.createdAt, from)); + const [activityStats] = await db + .select({ count: sql`count(*)::int` }) + .from(activityLog) + .where(and(...activityConditions)); + + const costConditions = [ + eq(costEvents.companyId, companyId), + userIssueInvolvementSql(companyId, userId), + ]; + if (from) costConditions.push(gte(costEvents.occurredAt, from)); + const [costStats] = await db + .select({ + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + costEventCount: sql`count(${costEvents.id})::int`, + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(...costConditions)); + + return { + key, + label, + touchedIssues: Number(issueStats?.touchedIssues ?? 0), + createdIssues: Number(issueStats?.createdIssues ?? 0), + completedIssues: Number(issueStats?.completedIssues ?? 0), + assignedOpenIssues: Number(issueStats?.assignedOpenIssues ?? 0), + commentCount: Number(commentStats?.count ?? 0), + activityCount: Number(activityStats?.count ?? 0), + costCents: Number(costStats?.costCents ?? 0), + inputTokens: Number(costStats?.inputTokens ?? 0), + cachedInputTokens: Number(costStats?.cachedInputTokens ?? 0), + outputTokens: Number(costStats?.outputTokens ?? 0), + costEventCount: Number(costStats?.costEventCount ?? 0), + }; +} + +async function loadDailyStats(db: Db, companyId: string, userId: string): Promise { + const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000)); + const points = new Map(); + for (let index = 0; index < 14; index += 1) { + const date = new Date(firstDay.getTime() + index * 24 * 60 * 60 * 1000); + points.set(isoDay(date), { + date: isoDay(date), + activityCount: 0, + completedIssues: 0, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }); + } + + const activityDay = dayKeyExpr(sql`${activityLog.createdAt}`); + const activityRows = await db + .select({ + date: activityDay, + count: sql`count(*)::int`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + gte(activityLog.createdAt, firstDay), + ), + ) + .groupBy(activityDay); + + for (const row of activityRows) { + const point = points.get(row.date); + if (point) point.activityCount = Number(row.count); + } + + const completedDay = dayKeyExpr(sql`${issues.completedAt}`); + const completedRows = await db + .select({ + date: completedDay, + count: sql`count(distinct ${issues.id})::int`, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + eq(issues.status, "done"), + gte(issues.completedAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(completedDay); + + for (const row of completedRows) { + const point = points.get(row.date); + if (point) point.completedIssues = Number(row.count); + } + + const costDay = dayKeyExpr(sql`${costEvents.occurredAt}`); + const costRows = await db + .select({ + date: costDay, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where( + and( + eq(costEvents.companyId, companyId), + gte(costEvents.occurredAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(costDay); + + for (const row of costRows) { + const point = points.get(row.date); + if (!point) continue; + point.costCents = Number(row.costCents); + point.inputTokens = Number(row.inputTokens); + point.cachedInputTokens = Number(row.cachedInputTokens); + point.outputTokens = Number(row.outputTokens); + } + + return [...points.values()]; +} + +export function userProfileRoutes(db: Db) { + const router = Router(); + + router.get("/companies/:companyId/users/:userSlug/profile", async (req, res) => { + const companyId = req.params.companyId as string; + const userSlug = req.params.userSlug as string; + assertCompanyAccess(req, companyId); + + const row = await resolveCompanyUser(db, companyId, userSlug); + if (!row) throw notFound("User not found"); + const canonicalSlug = userSlugCandidates(row)[0] ?? row.principalId; + const userId = row.userId ?? row.principalId; + + const [stats, daily, recentIssues, recentActivity, topAgents, topProviders] = await Promise.all([ + Promise.all( + PROFILE_WINDOWS.map((entry) => + loadWindowStats(db, companyId, userId, entry.key, entry.label, windowStart(entry.days)), + ), + ), + loadDailyStats(db, companyId, userId), + db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + updatedAt: issues.updatedAt, + completedAt: issues.completedAt, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + userIssueInvolvementSql(companyId, userId), + ), + ) + .orderBy(desc(issues.updatedAt)) + .limit(8), + db + .select({ + id: activityLog.id, + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ), + ) + .orderBy(desc(activityLog.createdAt)) + .limit(12), + db + .select({ + agentId: costEvents.agentId, + agentName: agents.name, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .leftJoin(agents, eq(agents.id, costEvents.agentId)) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.agentId, agents.name) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + db + .select({ + provider: costEvents.provider, + biller: costEvents.biller, + model: costEvents.model, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.provider, costEvents.biller, costEvents.model) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + ]); + + const user: UserProfileIdentity = { + id: userId, + slug: canonicalSlug, + name: row.name, + email: row.email, + image: row.image, + membershipRole: row.membershipRole, + membershipStatus: row.status, + joinedAt: row.createdAt, + }; + + const payload: UserProfileResponse = { + user, + stats, + daily, + recentIssues: recentIssues.map((issue) => ({ + ...issue, + status: issue.status as UserProfileResponse["recentIssues"][number]["status"], + priority: issue.priority as UserProfileResponse["recentIssues"][number]["priority"], + })), + recentActivity, + topAgents: topAgents.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + topProviders: topProviders.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + }; + + res.json(payload); + }); + + return router; +} diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 792c28f4..faac70da 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + agents, companyMemberships, instanceUserRoles, + issues, principalPermissionGrants, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; @@ -14,6 +16,13 @@ type GrantInput = { scope?: Record | null; }; +type MemberArchiveInput = { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; +}; + export function accessService(db: Db) { async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; @@ -239,6 +248,176 @@ export function accessService(db: Db) { }); } + async function assertCanRemoveActiveOwner( + companyId: string, + principalType: PrincipalType, + status: string, + membershipRole: string | null, + tx: Pick, + ) { + if ( + principalType !== "user" || + status !== "active" || + membershipRole !== "owner" + ) { + return; + } + + const activeOwnerCount = await tx + .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"); + } + } + + async function assertAssignableArchiveTarget( + companyId: string, + input: MemberArchiveInput["reassignment"], + tx: Pick, + ) { + if (!input?.assigneeAgentId && !input?.assigneeUserId) return; + if (input.assigneeAgentId && input.assigneeUserId) { + throw conflict("Choose either an agent or user reassignment target"); + } + if (input.assigneeUserId) { + const membership = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, input.assigneeUserId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!membership) { + throw conflict("Replacement user must be an active company member"); + } + return; + } + + const agent = await tx + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, input.assigneeAgentId!)) + .then((rows) => rows[0] ?? null); + if (!agent || agent.companyId !== companyId) { + throw conflict("Replacement agent must belong to the same company"); + } + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Replacement agent must be assignable"); + } + } + + async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (existing.principalType !== "user") { + throw conflict("Only human company members can be archived"); + } + if (existing.status === "archived") { + return { member: existing, reassignedIssueCount: 0 }; + } + if (input.reassignment?.assigneeUserId === existing.principalId) { + throw conflict("Replacement user cannot be the archived member"); + } + + await assertCanRemoveActiveOwner( + companyId, + existing.principalType, + existing.status, + existing.membershipRole, + tx, + ); + await assertAssignableArchiveTarget(companyId, input.reassignment, tx); + + const now = new Date(); + const assignmentPatch = { + assigneeAgentId: input.reassignment?.assigneeAgentId ?? null, + assigneeUserId: input.reassignment?.assigneeUserId ?? null, + updatedAt: now, + }; + const assignedOpenIssueWhere = and( + eq(issues.companyId, companyId), + eq(issues.assigneeUserId, existing.principalId), + sql`${issues.status} not in ('done', 'cancelled')`, + ); + const resetInProgress = await tx + .update(issues) + .set({ + ...assignmentPatch, + status: "todo", + startedAt: null, + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + }) + .where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress"))) + .returning({ id: issues.id }); + const reassigned = await tx + .update(issues) + .set(assignmentPatch) + .where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress"))) + .returning({ id: issues.id }); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const archived = await tx + .update(companyMemberships) + .set({ + status: "archived", + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + return { + member: archived, + reassignedIssueCount: resetInProgress.length + reassigned.length, + }; + }); + } + async function promoteInstanceAdmin(userId: string) { const existing = await db .select() @@ -272,19 +451,81 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } - async function setUserCompanyAccess(userId: string, companyIds: string[]) { + async function setUserCompanyAccess( + userId: string, + companyIds: string[], + options: { actorUserId?: string | null } = {}, + ) { const existing = await listUserCompanyAccess(userId); const existingByCompany = new Map(existing.map((row) => [row.companyId, row])); const target = new Set(companyIds); await db.transaction(async (tx) => { - const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id); - if (toDelete.length > 0) { - await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete)); + const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived"); + if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) { + throw conflict("You cannot remove yourself"); + } + if (toArchive.length > 0 && (await isInstanceAdmin(userId))) { + throw conflict("Instance admins cannot be removed from company access"); + } + const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin"); + if (protectedArchives.length > 0) { + throw conflict("Owners and admins cannot be removed from company access"); + } + const activeOwnerArchives = toArchive.filter( + (row) => row.status === "active" && row.membershipRole === "owner", + ); + if (activeOwnerArchives.length > 0) { + const activeOwnerRows = await tx + .select({ companyId: companyMemberships.companyId, id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)), + ), + ); + for (const row of activeOwnerArchives) { + const remainingOwners = + activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1; + if (remainingOwners <= 0) { + throw conflict("Cannot remove the last active owner"); + } + } + } + if (toArchive.length > 0) { + await tx + .update(companyMemberships) + .set({ status: "archived", updatedAt: new Date() }) + .where(inArray(companyMemberships.id, toArchive.map((row) => row.id))); + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, userId), + inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)), + ), + ); } for (const companyId of target) { - if (existingByCompany.has(companyId)) continue; + const existingMembership = existingByCompany.get(companyId); + if (existingMembership) { + if (existingMembership.status !== "active") { + await tx + .update(companyMemberships) + .set({ + status: "active", + membershipRole: existingMembership.membershipRole ?? "operator", + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existingMembership.id)); + } + continue; + } await tx.insert(companyMemberships).values({ companyId, principalType: "user", @@ -535,6 +776,7 @@ export function accessService(db: Db) { listMembers, listActiveUserMemberships, copyActiveUserMemberships, + archiveMember, setMemberPermissions, updateMemberAndPermissions, promoteInstanceAdmin, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9fe1f9d9..ddf6d1bb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,6 +14,7 @@ import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; import { RoutineDetail } from "./pages/RoutineDetail"; +import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; import { GoalDetail } from "./pages/GoalDetail"; @@ -117,6 +118,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -277,6 +279,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 917a049f..c8f83134 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -120,12 +120,21 @@ export type CompanyMember = { companyId: string; principalType: "user"; principalId: string; - status: "pending" | "active" | "suspended"; + status: "pending" | "active" | "suspended" | "archived"; membershipRole: HumanCompanyRole | null; createdAt: string; updatedAt: string; user: { id: string; email: string | null; name: string | null; image: string | null } | null; grants: CompanyMemberGrant[]; + removal?: { + canArchive: boolean; + reason: string | null; + }; +}; + +export type ArchiveCompanyMemberResponse = { + member: CompanyMember; + reassignedIssueCount: number; }; export type CompanyMembersResponse = { @@ -205,7 +214,7 @@ export type UserCompanyAccessEntry = { companyId: string; principalType: "user"; principalId: string; - status: "pending" | "active" | "suspended"; + status: "pending" | "active" | "suspended" | "archived"; membershipRole: HumanCompanyRole | "member" | null; createdAt: string; updatedAt: string; @@ -341,6 +350,17 @@ export const accessApi = { }, ) => api.patch(`/companies/${companyId}/members/${memberId}/role-and-grants`, input), + archiveMember: ( + companyId: string, + memberId: string, + input: { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; + } = {}, + ) => api.post(`/companies/${companyId}/members/${memberId}/archive`, input), + approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), diff --git a/ui/src/api/userProfiles.ts b/ui/src/api/userProfiles.ts new file mode 100644 index 00000000..6c1c13c8 --- /dev/null +++ b/ui/src/api/userProfiles.ts @@ -0,0 +1,9 @@ +import type { UserProfileResponse } from "@paperclipai/shared"; +import { api } from "./client"; + +export const userProfilesApi = { + get: (companyId: string, userSlug: string) => + api.get( + `/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`, + ), +}; diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 452a41ea..ec6e1367 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -6,6 +6,7 @@ import { type LucideIcon, Moon, Settings, + UserRound, Sun, UserRoundPen, } from "lucide-react"; @@ -45,6 +46,20 @@ function deriveInitials(name: string) { return name.slice(0, 2).toUpperCase(); } +function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) { + const candidates = [name, email?.split("@")[0], email, id]; + for (const candidate of candidates) { + const slug = candidate + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (slug) return slug; + } + return "me"; +} + function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) { const className = "flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60"; @@ -112,6 +127,7 @@ export function SidebarAccountMenu({ session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board"); const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local"; const initials = deriveInitials(displayName); + const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`; function closeNavigationChrome() { setOpen(false); @@ -164,6 +180,13 @@ export function SidebarAccountMenu({
+ ["secret-providers", companyId] as const, }, dashboard: (companyId: string) => ["dashboard", companyId] as const, + userProfile: (companyId: string, userSlug: string) => + ["user-profile", companyId, userSlug] as const, sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const, inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const, activity: (companyId: string) => ["activity", companyId] as const, diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index 4d6af17c..eefb7d73 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -9,6 +9,9 @@ import { CompanyAccess } from "./CompanyAccess"; const listMembersMock = vi.hoisted(() => vi.fn()); const listJoinRequestsMock = vi.hoisted(() => vi.fn()); const updateMemberAccessMock = vi.hoisted(() => vi.fn()); +const archiveMemberMock = vi.hoisted(() => vi.fn()); +const listAgentsMock = vi.hoisted(() => vi.fn()); +const listIssuesMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/access", () => ({ accessApi: { @@ -18,11 +21,25 @@ vi.mock("@/api/access", () => ({ updateMemberPermissions: vi.fn(), updateMemberAccess: (companyId: string, memberId: string, input: unknown) => updateMemberAccessMock(companyId, memberId, input), + archiveMember: (companyId: string, memberId: string, input: unknown) => + archiveMemberMock(companyId, memberId, input), approveJoinRequest: vi.fn(), rejectJoinRequest: vi.fn(), }, })); +vi.mock("@/api/agents", () => ({ + agentsApi: { + list: (companyId: string) => listAgentsMock(companyId), + }, +})); + +vi.mock("@/api/issues", () => ({ + issuesApi: { + list: (companyId: string, filters: unknown) => listIssuesMock(companyId, filters), + }, +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -73,6 +90,23 @@ describe("CompanyAccess", () => { }, grants: [], }, + { + id: "member-2", + companyId: "company-1", + principalType: "user", + principalId: "user-2", + status: "active", + membershipRole: "operator", + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-10T00:00:00.000Z", + user: { + id: "user-2", + email: "board@paperclip.local", + name: "Board User", + image: null, + }, + grants: [], + }, ], access: { currentUserRole: "owner", @@ -113,6 +147,23 @@ describe("CompanyAccess", () => { }, ]); updateMemberAccessMock.mockResolvedValue({}); + archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 }); + listAgentsMock.mockResolvedValue([ + { + id: "agent-1", + name: "Codex Worker", + role: "engineer", + status: "active", + }, + ]); + listIssuesMock.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Assigned to removed user", + status: "todo", + }, + ]); }); afterEach(() => { @@ -216,4 +267,119 @@ describe("CompanyAccess", () => { root.unmount(); }); }); + + it("removes a member with an issue reassignment target", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const removeButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent?.includes("Remove"), + ); + expect(removeButtons.length).toBeGreaterThan(0); + + await act(async () => { + removeButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Remove member"); + expect(document.body.textContent).toContain("Assigned to removed user"); + + const reassignmentSelect = document.body.querySelector("select"); + expect(reassignmentSelect).toBeTruthy(); + await act(async () => { + reassignmentSelect!.value = "user:user-2"; + reassignmentSelect!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const confirmButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent === "Remove member", + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(archiveMemberMock).toHaveBeenCalledWith("company-1", "member-1", { + reassignment: { assigneeAgentId: null, assigneeUserId: "user-2" }, + }); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows protected member removal reasons from the API", async () => { + listMembersMock.mockResolvedValueOnce({ + members: [ + { + id: "member-admin", + companyId: "company-1", + principalType: "user", + principalId: "admin-user", + status: "active", + membershipRole: "admin", + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-10T00:00:00.000Z", + user: { + id: "admin-user", + email: "admin@paperclip.local", + name: "Admin User", + image: null, + }, + grants: [], + removal: { + canArchive: false, + reason: "Company admins cannot be removed from company access.", + }, + }, + ], + access: { + currentUserRole: "owner", + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: false, + }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Company admins cannot be removed from company access."); + const removeButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Remove"), + ); + expect(removeButton).toBeTruthy(); + expect(removeButton).toHaveProperty("disabled", true); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 9c65e45e..51ff8d69 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -1,9 +1,16 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared"; -import { ShieldCheck, Users } from "lucide-react"; +import { + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, + PERMISSION_KEYS, + type Agent, + type PermissionKey, +} from "@paperclipai/shared"; +import { ShieldCheck, Trash2, Users } from "lucide-react"; import { accessApi, type CompanyMember } from "@/api/access"; +import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; +import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -41,6 +48,9 @@ const implicitRoleGrantMap: Record, viewer: [], }; +const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; +type EditableMemberStatus = "pending" | "active" | "suspended"; + function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) { return role ? implicitRoleGrantMap[role] : []; } @@ -51,8 +61,10 @@ export function CompanyAccess() { const { pushToast } = useToast(); const queryClient = useQueryClient(); const [editingMemberId, setEditingMemberId] = useState(null); + const [removingMemberId, setRemovingMemberId] = useState(null); + const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); const [draftRole, setDraftRole] = useState(null); - const [draftStatus, setDraftStatus] = useState("active"); + const [draftStatus, setDraftStatus] = useState("active"); const [draftGrants, setDraftGrants] = useState>(new Set()); useEffect(() => { @@ -69,6 +81,12 @@ export function CompanyAccess() { enabled: !!selectedCompanyId, }); + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const joinRequestsQuery = useQuery({ queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"), queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"), @@ -83,7 +101,7 @@ export function CompanyAccess() { }; const updateMemberMutation = useMutation({ - mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => { + mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => { return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, { membershipRole: input.membershipRole, status: input.status, @@ -147,14 +165,70 @@ export function CompanyAccess() { () => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null, [editingMemberId, membersQuery.data?.members], ); + const removingMember = useMemo( + () => membersQuery.data?.members.find((member) => member.id === removingMemberId) ?? null, + [removingMemberId, membersQuery.data?.members], + ); + + const assignedIssuesQuery = useQuery({ + queryKey: ["access", "member-assigned-issues", selectedCompanyId ?? "", removingMember?.principalId ?? ""], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + assigneeUserId: removingMember!.principalId, + status: reassignmentIssueStatuses, + }), + enabled: !!selectedCompanyId && !!removingMember, + }); + + const archiveMemberMutation = useMutation({ + mutationFn: async (input: { memberId: string; target: string }) => { + const reassignment = + input.target.startsWith("agent:") + ? { assigneeAgentId: input.target.slice("agent:".length), assigneeUserId: null } + : input.target.startsWith("user:") + ? { assigneeAgentId: null, assigneeUserId: input.target.slice("user:".length) } + : null; + return accessApi.archiveMember(selectedCompanyId!, input.memberId, { reassignment }); + }, + onSuccess: async (result) => { + setRemovingMemberId(null); + setReassignmentTarget("__unassigned"); + await refreshAccessData(); + if (selectedCompanyId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listAssignedToMe(selectedCompanyId) }); + await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); + } + pushToast({ + title: "Member removed", + body: + result.reassignedIssueCount > 0 + ? `${result.reassignedIssueCount} assigned issue${result.reassignedIssueCount === 1 ? "" : "s"} cleaned up.` + : undefined, + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to remove member", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); useEffect(() => { if (!editingMember) return; setDraftRole(editingMember.membershipRole); - setDraftStatus(editingMember.status); + setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended"); setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey))); }, [editingMember]); + useEffect(() => { + if (!removingMember) return; + setReassignmentTarget("__unassigned"); + }, [removingMember]); + if (!selectedCompanyId) { return
Select a company to manage access.
; } @@ -181,6 +255,14 @@ export function CompanyAccess() { approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; const implicitGrantKeys = getImplicitGrantKeys(draftRole); const implicitGrantSet = new Set(implicitGrantKeys); + const activeReassignmentUsers = members.filter( + (member) => + member.status === "active" && + member.principalType === "user" && + member.id !== removingMemberId, + ); + const activeReassignmentAgents = (agentsQuery.data ?? []).filter(isAssignableAgent); + const assignedIssues = assignedIssuesQuery.data ?? []; return (
@@ -256,7 +338,7 @@ export function CompanyAccess() { ) : null}
-
+
User account
Role
Status
@@ -266,33 +348,52 @@ export function CompanyAccess() { {members.length === 0 ? (
No user memberships found for this company yet.
) : ( - members.map((member) => ( -
-
-
{member.user?.name?.trim() || member.user?.email || member.principalId}
-
{member.user?.email || member.principalId}
+ members.map((member) => { + const removalReason = member.removal?.reason ?? null; + const canArchive = member.removal?.canArchive ?? true; + return ( +
+
+
{member.user?.name?.trim() || member.user?.email || member.principalId}
+
{member.user?.email || member.principalId}
+
+
+ {member.membershipRole + ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole] + : "Unset"} +
+
+ + {member.status.replace("_", " ")} + +
+
{formatGrantSummary(member)}
+
+
+ + +
+ {removalReason ? ( +
{removalReason}
+ ) : null} +
-
- {member.membershipRole - ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole] - : "Unset"} -
-
- - {member.status.replace("_", " ")} - -
-
{formatGrantSummary(member)}
-
- -
-
- )) + ); + }) )}
@@ -331,7 +432,7 @@ export function CompanyAccess() { className="w-full rounded-md border border-border bg-background px-3 py-2" value={draftStatus} onChange={(event) => - setDraftStatus(event.target.value as CompanyMember["status"]) + setDraftStatus(event.target.value as EditableMemberStatus) } > @@ -423,10 +524,109 @@ export function CompanyAccess() { + + !open && setRemovingMemberId(null)}> + + + Remove member + + Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields. + + + {removingMember && ( +
+
+
{memberDisplayName(removingMember)}
+
{removingMember.user?.email || removingMember.principalId}
+
+ {assignedIssuesQuery.isLoading + ? "Checking assigned issues..." + : `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`} +
+
+ + {assignedIssues.length > 0 ? ( +
+
Issue reassignment
+ +
+ {assignedIssues.slice(0, 6).map((issue) => ( +
+
{issue.identifier ?? issue.id.slice(0, 8)}
+
{issue.title}
+
+ ))} + {assignedIssues.length > 6 ? ( +
+ {assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"} +
+ ) : null} +
+
+ ) : null} +
+ )} + + + + +
+
); } +function memberDisplayName(member: CompanyMember | null) { + if (!member) return "this member"; + return member.user?.name?.trim() || member.user?.email || member.principalId; +} + +function isAssignableAgent(agent: Agent) { + return agent.status !== "terminated" && agent.status !== "pending_approval"; +} + +function isEditableMemberStatus(status: CompanyMember["status"]): status is EditableMemberStatus { + return status === "pending" || status === "active" || status === "suspended"; +} + function PendingJoinRequestCard({ title, subtitle, diff --git a/ui/src/pages/InstanceAccess.tsx b/ui/src/pages/InstanceAccess.tsx index d40ea814..7ca2f673 100644 --- a/ui/src/pages/InstanceAccess.tsx +++ b/ui/src/pages/InstanceAccess.tsx @@ -51,7 +51,11 @@ export function InstanceAccess() { useEffect(() => { if (!userAccessQuery.data) return; setSelectedCompanyIds( - new Set(userAccessQuery.data.companyAccess.map((membership) => membership.companyId)), + new Set( + userAccessQuery.data.companyAccess + .filter((membership) => membership.status === "active") + .map((membership) => membership.companyId), + ), ); }, [userAccessQuery.data]); diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx new file mode 100644 index 00000000..ded2dd3e --- /dev/null +++ b/ui/src/pages/UserProfile.tsx @@ -0,0 +1,358 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, UserRound } from "lucide-react"; +import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared"; +import { Link, useParams } from "@/lib/router"; +import { userProfilesApi } from "../api/userProfiles"; +import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { StatusBadge } from "../components/StatusBadge"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; +import { + formatCents, + formatDate, + formatShortDate, + formatTokens, + issueUrl, + providerDisplayName, + relativeTime, +} from "../lib/utils"; + +const NO_COMPANY = "__none__"; + +function initials(name: string | null | undefined) { + const value = name?.trim() || "User"; + const parts = value.split(/\s+/).filter(Boolean); + if (parts.length > 1) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase(); + return value.slice(0, 2).toUpperCase(); +} + +function totalTokens(stats: Pick) { + return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens; +} + +function completionRate(stats: UserProfileWindowStats) { + if (stats.touchedIssues === 0) return "0%"; + return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`; +} + +function HeroStat({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( +
+
{value}
+
{label}
+ {hint ?
{hint}
: null} +
+ ); +} + +function WindowColumn({ stats }: { stats: UserProfileWindowStats }) { + const tokens = totalTokens(stats); + return ( +
+
+

{stats.label}

+ {completionRate(stats)} done +
+ +
+ + + + +
+ +
+ Tokens + {formatTokens(tokens)} + Spend + {formatCents(stats.costCents)} + Created + {stats.createdIssues} + Open + {stats.assignedOpenIssues} +
+
+ ); +} + +function Metric({ value, label }: { value: string; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function UsageChart({ points }: { points: UserProfileDailyPoint[] }) { + const totals = points.map((point) => totalTokens(point)); + const maxTokens = Math.max(1, ...totals); + const maxCompleted = Math.max(1, ...points.map((point) => point.completedIssues)); + const totalTokensSum = totals.reduce((sum, value) => sum + value, 0); + + return ( +
+
+

Last 14 days

+
+ {formatTokens(totalTokensSum)} + tokens total +
+
+
+ {points.map((point) => { + const tokens = totalTokens(point); + const heightPct = tokens === 0 ? 0 : Math.max(2, Math.round((tokens / maxTokens) * 100)); + const completedPct = point.completedIssues === 0 + ? 0 + : Math.max(8, Math.round((point.completedIssues / maxCompleted) * 36)); + return ( +
+
+ {completedPct > 0 ? ( +
+ ) : null} +
+ ); + })} +
+
+ {points.map((point, index) => ( +
+ {index === 0 || index === 6 || index === 13 ? formatShortDate(point.date) : null} +
+ ))} +
+
+ + tokens / day + + + completions + +
+
+ ); +} + +interface UsageRow { + key: string; + label: string; + sublabel: string; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +function UsageList({ + title, + empty, + rows, +}: { + title: string; + empty: string; + rows: UsageRow[]; +}) { + return ( +
+
+

{title}

+ {rows.length} +
+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
    + {rows.map((row) => ( +
  • +
    +
    {row.label}
    +
    {row.sublabel}
    +
    +
    + {formatTokens(totalTokens(row))} + {formatCents(row.costCents)} +
    +
  • + ))} +
+ )} +
+ ); +} + +export function UserProfile() { + const { userSlug = "" } = useParams<{ userSlug: string }>(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const companyId = selectedCompanyId ?? NO_COMPANY; + + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.userProfile(companyId, userSlug), + queryFn: () => userProfilesApi.get(companyId, userSlug), + enabled: !!selectedCompanyId && !!userSlug, + }); + + useEffect(() => { + setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]); + }, [data?.user.name, setBreadcrumbs, userSlug]); + + const allTime = data?.stats.find((entry) => entry.key === "all"); + const last7 = data?.stats.find((entry) => entry.key === "last7"); + const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User"; + + const agentUsageRows = useMemo( + () => + (data?.topAgents ?? []).map((row) => ({ + key: row.agentId ?? "unknown", + label: row.agentName ?? (row.agentId ? row.agentId.slice(0, 8) : "unknown"), + sublabel: "Issue-linked usage", + costCents: row.costCents, + inputTokens: row.inputTokens, + cachedInputTokens: row.cachedInputTokens, + outputTokens: row.outputTokens, + })), + [data?.topAgents], + ); + + const providerUsageRows = useMemo( + () => + (data?.topProviders ?? []).map((row) => ({ + key: `${row.provider}:${row.biller}:${row.model}`, + label: `${providerDisplayName(row.provider)} / ${row.model}`, + sublabel: `Billed through ${providerDisplayName(row.biller)}`, + costCents: row.costCents, + inputTokens: row.inputTokens, + cachedInputTokens: row.cachedInputTokens, + outputTokens: row.outputTokens, + })), + [data?.topProviders], + ); + + if (!selectedCompanyId) { + return ; + } + + if (isLoading) { + return ; + } + + if (error || !data) { + return ; + } + + const allTimeTokens = allTime ? totalTokens(allTime) : 0; + const metaParts = [ + data.user.membershipRole ?? "member", + data.user.membershipStatus, + `joined ${formatDate(data.user.joinedAt)}`, + ]; + + return ( +
+
+
+ + {data.user.image ? : null} + {initials(displayName)} + +
+
+

{displayName}

+ @{data.user.slug} +
+
+ {data.user.email ? {data.user.email} : null} + {data.user.email ? · : null} + {metaParts.join(" · ")} +
+
+
+ +
+ + + + +
+
+ +
+ {data.stats.map((entry) => )} +
+ + + +
+
+
+

Recent tasks

+ {data.recentIssues.length} +
+ {data.recentIssues.length === 0 ? ( +
No touched tasks yet.
+ ) : ( +
    + {data.recentIssues.map((issue) => ( +
  • + + {issue.identifier ?? issue.id.slice(0, 8)} + {issue.title} + + + {relativeTime(issue.updatedAt)} + + +
  • + ))} +
+ )} +
+ +
+
+

Recent activity

+ {data.recentActivity.length} +
+ {data.recentActivity.length === 0 ? ( +
No direct user actions recorded yet.
+ ) : ( +
    + {data.recentActivity.map((event) => ( +
  • +
    +
    {event.action.replaceAll("_", " ")}
    +
    + {event.entityType} · {event.entityId.slice(0, 12)} +
    +
    + {relativeTime(event.createdAt)} +
  • + ))} +
+ )} +
+
+ +
+ + +
+
+ ); +}