mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
[codex] Add access cleanup and user profile page (#4088)
## Thinking Path > - Paperclip is moving from a solo local operator model toward teams supervising AI-agent companies. > - Human access management and human-visible profile surfaces are part of that multiple-user path. > - The branch included related access cleanup, archived-member removal, permission protection, and a user profile page. > - These changes share company membership, user attribution, and access-service behavior. > - This pull request groups those human access/profile changes into one standalone branch. > - The benefit is safer member removal behavior and a first profile surface for user work, activity, and cost attribution. ## What Changed - Added archived company member removal support across shared contracts, server routes/services, and UI. - Protected company member removal with stricter permission checks and tests. - Added company user profile API, shared types, route wiring, client API, route, and UI page. - Simplified the user profile page visual design to a neutral typography-led layout. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/user-profile-routes.test.ts ui/src/pages/CompanyAccess.test.tsx --hookTimeout=30000` - `pnpm exec vitest run server/src/__tests__/user-profile-routes.test.ts --testTimeout=30000 --hookTimeout=30000` after an initial local embedded-Postgres hook timeout in the combined run. - Split integration check: merged after runtime/governance and dev-infra/backups with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: changes member removal permissions and adds a new user profile route with cross-table stats. - The profile page is a new UI surface and may need visual follow-up in browser QA. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89d3f7e11
commit
d8b63a18e7
23 changed files with 2156 additions and 51 deletions
|
|
@ -380,7 +380,7 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||||
export const PRINCIPAL_TYPES = ["user", "agent"] as const;
|
export const PRINCIPAL_TYPES = ["user", "agent"] as const;
|
||||||
export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
|
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 type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
|
||||||
|
|
||||||
export const COMPANY_MEMBERSHIP_ROLES = [
|
export const COMPANY_MEMBERSHIP_ROLES = [
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,14 @@ export type {
|
||||||
LiveEvent,
|
LiveEvent,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
|
UserProfileActivitySummary,
|
||||||
|
UserProfileAgentUsage,
|
||||||
|
UserProfileDailyPoint,
|
||||||
|
UserProfileIdentity,
|
||||||
|
UserProfileIssueSummary,
|
||||||
|
UserProfileProviderUsage,
|
||||||
|
UserProfileResponse,
|
||||||
|
UserProfileWindowStats,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
SidebarOrderPreference,
|
SidebarOrderPreference,
|
||||||
InboxDismissal,
|
InboxDismissal,
|
||||||
|
|
@ -600,6 +608,7 @@ export {
|
||||||
updateCurrentUserProfileSchema,
|
updateCurrentUserProfileSchema,
|
||||||
updateCompanyMemberSchema,
|
updateCompanyMemberSchema,
|
||||||
updateCompanyMemberWithPermissionsSchema,
|
updateCompanyMemberWithPermissionsSchema,
|
||||||
|
archiveCompanyMemberSchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
searchAdminUsersQuerySchema,
|
searchAdminUsersQuerySchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
|
|
@ -621,6 +630,7 @@ export {
|
||||||
type UpdateCurrentUserProfile,
|
type UpdateCurrentUserProfile,
|
||||||
type UpdateCompanyMember,
|
type UpdateCompanyMember,
|
||||||
type UpdateCompanyMemberWithPermissions,
|
type UpdateCompanyMemberWithPermissions,
|
||||||
|
type ArchiveCompanyMember,
|
||||||
type UpdateMemberPermissions,
|
type UpdateMemberPermissions,
|
||||||
type SearchAdminUsersQuery,
|
type SearchAdminUsersQuery,
|
||||||
type UpdateUserCompanyAccess,
|
type UpdateUserCompanyAccess,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ export interface CompanyMemberRecord extends CompanyMembership {
|
||||||
membershipRole: HumanCompanyMembershipRole | null;
|
membershipRole: HumanCompanyMembershipRole | null;
|
||||||
user: AccessUserProfile | null;
|
user: AccessUserProfile | null;
|
||||||
grants: PrincipalPermissionGrant[];
|
grants: PrincipalPermissionGrant[];
|
||||||
|
removal?: {
|
||||||
|
canArchive: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyMembersResponse {
|
export interface CompanyMembersResponse {
|
||||||
|
|
@ -59,6 +63,11 @@ export interface CompanyMembersResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArchiveCompanyMemberResponse {
|
||||||
|
member: CompanyMemberRecord;
|
||||||
|
reassignedIssueCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string | null;
|
companyId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,16 @@ export type {
|
||||||
export type { LiveEvent } from "./live.js";
|
export type { LiveEvent } from "./live.js";
|
||||||
export type { DashboardSummary } from "./dashboard.js";
|
export type { DashboardSummary } from "./dashboard.js";
|
||||||
export type { ActivityEvent } from "./activity.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 { SidebarBadges } from "./sidebar-badges.js";
|
||||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||||
|
|
@ -176,6 +186,7 @@ export type {
|
||||||
AccessUserProfile,
|
AccessUserProfile,
|
||||||
CompanyMemberRecord,
|
CompanyMemberRecord,
|
||||||
CompanyMembersResponse,
|
CompanyMembersResponse,
|
||||||
|
ArchiveCompanyMemberResponse,
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
CompanyInviteListResponse,
|
CompanyInviteListResponse,
|
||||||
CompanyInviteRecord,
|
CompanyInviteRecord,
|
||||||
|
|
|
||||||
88
packages/shared/src/types/user-profile.ts
Normal file
88
packages/shared/src/types/user-profile.ts
Normal file
|
|
@ -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<string, unknown> | 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[];
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
INVITE_JOIN_TYPES,
|
INVITE_JOIN_TYPES,
|
||||||
JOIN_REQUEST_STATUSES,
|
JOIN_REQUEST_STATUSES,
|
||||||
JOIN_REQUEST_TYPES,
|
JOIN_REQUEST_TYPES,
|
||||||
MEMBERSHIP_STATUSES,
|
|
||||||
PERMISSION_KEYS,
|
PERMISSION_KEYS,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
|
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
|
||||||
|
|
@ -97,9 +96,11 @@ export const updateMemberPermissionsSchema = z.object({
|
||||||
|
|
||||||
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
|
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
|
||||||
|
|
||||||
|
const editableMembershipStatuses = ["pending", "active", "suspended"] as const;
|
||||||
|
|
||||||
export const updateCompanyMemberSchema = z.object({
|
export const updateCompanyMemberSchema = z.object({
|
||||||
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
|
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, {
|
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
|
||||||
message: "membershipRole or status is required",
|
message: "membershipRole or status is required",
|
||||||
});
|
});
|
||||||
|
|
@ -108,7 +109,7 @@ export type UpdateCompanyMember = z.infer<typeof updateCompanyMemberSchema>;
|
||||||
|
|
||||||
export const updateCompanyMemberWithPermissionsSchema = z.object({
|
export const updateCompanyMemberWithPermissionsSchema = z.object({
|
||||||
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
|
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([]),
|
grants: updateMemberPermissionsSchema.shape.grants.default([]),
|
||||||
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
|
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
|
||||||
message: "membershipRole or status is required",
|
message: "membershipRole or status is required",
|
||||||
|
|
@ -116,6 +117,26 @@ export const updateCompanyMemberWithPermissionsSchema = z.object({
|
||||||
|
|
||||||
export type UpdateCompanyMemberWithPermissions = z.infer<typeof updateCompanyMemberWithPermissionsSchema>;
|
export type UpdateCompanyMemberWithPermissions = z.infer<typeof updateCompanyMemberWithPermissionsSchema>;
|
||||||
|
|
||||||
|
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<typeof archiveCompanyMemberSchema>;
|
||||||
|
|
||||||
export const updateUserCompanyAccessSchema = z.object({
|
export const updateUserCompanyAccessSchema = z.object({
|
||||||
companyIds: z.array(z.string().uuid()).default([]),
|
companyIds: z.array(z.string().uuid()).default([]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,7 @@ export {
|
||||||
updateCurrentUserProfileSchema,
|
updateCurrentUserProfileSchema,
|
||||||
updateCompanyMemberSchema,
|
updateCompanyMemberSchema,
|
||||||
updateCompanyMemberWithPermissionsSchema,
|
updateCompanyMemberWithPermissionsSchema,
|
||||||
|
archiveCompanyMemberSchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
searchAdminUsersQuerySchema,
|
searchAdminUsersQuerySchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
|
|
@ -283,6 +284,7 @@ export {
|
||||||
type UpdateCurrentUserProfile,
|
type UpdateCurrentUserProfile,
|
||||||
type UpdateCompanyMember,
|
type UpdateCompanyMember,
|
||||||
type UpdateCompanyMemberWithPermissions,
|
type UpdateCompanyMemberWithPermissions,
|
||||||
|
type ArchiveCompanyMember,
|
||||||
type UpdateMemberPermissions,
|
type UpdateMemberPermissions,
|
||||||
type SearchAdminUsersQuery,
|
type SearchAdminUsersQuery,
|
||||||
type UpdateUserCompanyAccess,
|
type UpdateUserCompanyAccess,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import {
|
||||||
companies,
|
companies,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
createDb,
|
createDb,
|
||||||
|
instanceUserRoles,
|
||||||
|
issues,
|
||||||
principalPermissionGrants,
|
principalPermissionGrants,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -51,7 +53,9 @@ describeEmbeddedPostgres("access service", () => {
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
await db.delete(issues);
|
||||||
await db.delete(principalPermissionGrants);
|
await db.delete(principalPermissionGrants);
|
||||||
|
await db.delete(instanceUserRoles);
|
||||||
await db.delete(companyMemberships);
|
await db.delete(companyMemberships);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
});
|
});
|
||||||
|
|
@ -96,4 +100,125 @@ describeEmbeddedPostgres("access service", () => {
|
||||||
.then((rows) => rows[0]!);
|
.then((rows) => rows[0]!);
|
||||||
expect(unchanged.status).toBe("active");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
205
server/src/__tests__/user-profile-routes.test.ts
Normal file
205
server/src/__tests__/user-profile-routes.test.ts
Normal file
|
|
@ -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<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -23,6 +23,7 @@ import { secretRoutes } from "./routes/secrets.js";
|
||||||
import { costRoutes } from "./routes/costs.js";
|
import { costRoutes } from "./routes/costs.js";
|
||||||
import { activityRoutes } from "./routes/activity.js";
|
import { activityRoutes } from "./routes/activity.js";
|
||||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||||
|
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||||
|
|
@ -195,6 +196,7 @@ export async function createApp(
|
||||||
api.use(costRoutes(db));
|
api.use(costRoutes(db));
|
||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
|
api.use(userProfileRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
api.use(sidebarPreferenceRoutes(db));
|
api.use(sidebarPreferenceRoutes(db));
|
||||||
api.use(inboxDismissalRoutes(db));
|
api.use(inboxDismissalRoutes(db));
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Request } 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 type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
assets,
|
assets,
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
companies,
|
companies,
|
||||||
companyLogos,
|
companyLogos,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
|
instanceUserRoles,
|
||||||
invites,
|
invites,
|
||||||
joinRequests,
|
joinRequests,
|
||||||
principalPermissionGrants,
|
principalPermissionGrants,
|
||||||
|
|
@ -34,11 +35,12 @@ import {
|
||||||
searchAdminUsersQuerySchema,
|
searchAdminUsersQuerySchema,
|
||||||
updateCompanyMemberWithPermissionsSchema,
|
updateCompanyMemberWithPermissionsSchema,
|
||||||
updateCompanyMemberSchema,
|
updateCompanyMemberSchema,
|
||||||
|
archiveCompanyMemberSchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
PERMISSION_KEYS
|
PERMISSION_KEYS
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import type { DeploymentExposure, DeploymentMode, PermissionKey } from "@paperclipai/shared";
|
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
forbidden,
|
forbidden,
|
||||||
conflict,
|
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
|
const members = await db
|
||||||
.select()
|
.select()
|
||||||
.from(companyMemberships)
|
.from(companyMemberships)
|
||||||
|
|
@ -1002,6 +1008,7 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
||||||
and(
|
and(
|
||||||
eq(companyMemberships.companyId, companyId),
|
eq(companyMemberships.companyId, companyId),
|
||||||
eq(companyMemberships.principalType, "user"),
|
eq(companyMemberships.principalType, "user"),
|
||||||
|
options.includeArchived ? undefined : ne(companyMemberships.status, "archived"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(companyMemberships.updatedAt));
|
.orderBy(desc(companyMemberships.updatedAt));
|
||||||
|
|
@ -1041,6 +1048,116 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompanyMemberRecord = Awaited<ReturnType<typeof loadCompanyMemberRecords>>[number];
|
||||||
|
|
||||||
|
const humanRoleRank: Record<HumanCompanyMembershipRole, number> = {
|
||||||
|
viewer: 1,
|
||||||
|
operator: 2,
|
||||||
|
admin: 3,
|
||||||
|
owner: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveActorHumanRole(
|
||||||
|
req: Request,
|
||||||
|
access: ReturnType<typeof accessService>,
|
||||||
|
companyId: string,
|
||||||
|
): Promise<HumanCompanyMembershipRole | null> {
|
||||||
|
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<typeof accessService>,
|
||||||
|
companyId: string,
|
||||||
|
member: { principalId: string; principalType: string; membershipRole: string | null },
|
||||||
|
opts?: {
|
||||||
|
actorRole?: HumanCompanyMembershipRole | null;
|
||||||
|
instanceAdminUserIds?: ReadonlySet<string>;
|
||||||
|
operation?: "archive" | "update";
|
||||||
|
},
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<typeof accessService>,
|
||||||
|
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<typeof accessService>,
|
||||||
|
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<string>();
|
||||||
|
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) {
|
async function loadCompanyUserDirectory(db: Db, companyId: string) {
|
||||||
const members = await db
|
const members = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -3604,7 +3721,7 @@ export function accessRoutes(
|
||||||
loadCompanyAccessSummary(req, access, companyId),
|
loadCompanyAccessSummary(req, access, companyId),
|
||||||
]);
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
members,
|
members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members),
|
||||||
access: currentAccess,
|
access: currentAccess,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -3623,6 +3740,9 @@ export function accessRoutes(
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const memberId = req.params.memberId as string;
|
const memberId = req.params.memberId as string;
|
||||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
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) => {
|
const updated = await db.transaction(async (tx) => {
|
||||||
await tx.execute(sql`
|
await tx.execute(sql`
|
||||||
|
|
@ -3717,6 +3837,9 @@ export function accessRoutes(
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const memberId = req.params.memberId as string;
|
const memberId = req.params.memberId as string;
|
||||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
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) => {
|
const updated = await db.transaction(async (tx) => {
|
||||||
await tx.execute(sql`
|
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(
|
router.patch(
|
||||||
"/companies/:companyId/members/:memberId/permissions",
|
"/companies/:companyId/members/:memberId/permissions",
|
||||||
validate(updateMemberPermissionsSchema),
|
validate(updateMemberPermissionsSchema),
|
||||||
|
|
@ -3841,6 +4005,9 @@ export function accessRoutes(
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const memberId = req.params.memberId as string;
|
const memberId = req.params.memberId as string;
|
||||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
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(
|
const updated = await access.setMemberPermissions(
|
||||||
companyId,
|
companyId,
|
||||||
memberId,
|
memberId,
|
||||||
|
|
@ -3962,7 +4129,8 @@ export function accessRoutes(
|
||||||
const userId = req.params.userId as string;
|
const userId = req.params.userId as string;
|
||||||
await access.setUserCompanyAccess(
|
await access.setUserCompanyAccess(
|
||||||
userId,
|
userId,
|
||||||
req.body.companyIds ?? []
|
req.body.companyIds ?? [],
|
||||||
|
{ actorUserId: req.actor.userId ?? null },
|
||||||
);
|
);
|
||||||
res.json(await loadUserCompanyAccessResponse(db, access, userId));
|
res.json(await loadUserCompanyAccessResponse(db, access, userId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
436
server/src/routes/user-profiles.ts
Normal file
436
server/src/routes/user-profiles.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
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<CompanyUserRow | null> {
|
||||||
|
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<boolean>`
|
||||||
|
(
|
||||||
|
${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<typeof sql>) {
|
||||||
|
return sql<string>`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<number>`coalesce(sum(${column}), 0)::double precision`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWindowStats(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
userId: string,
|
||||||
|
key: UserProfileWindowStats["key"],
|
||||||
|
label: string,
|
||||||
|
from: Date | null,
|
||||||
|
): Promise<UserProfileWindowStats> {
|
||||||
|
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<number>`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||||
|
createdIssues: sql<number>`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||||
|
completedIssues: sql<number>`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||||
|
assignedOpenIssues: sql<number>`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<number>`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<number>`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<number>`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<UserProfileDailyPoint[]> {
|
||||||
|
const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000));
|
||||||
|
const points = new Map<string, UserProfileDailyPoint>();
|
||||||
|
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<number>`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<number>`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;
|
||||||
|
}
|
||||||
|
|
@ -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 type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
|
agents,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
instanceUserRoles,
|
instanceUserRoles,
|
||||||
|
issues,
|
||||||
principalPermissionGrants,
|
principalPermissionGrants,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||||
|
|
@ -14,6 +16,13 @@ type GrantInput = {
|
||||||
scope?: Record<string, unknown> | null;
|
scope?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MemberArchiveInput = {
|
||||||
|
reassignment?: {
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
assigneeUserId?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function accessService(db: Db) {
|
export function accessService(db: Db) {
|
||||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||||
if (!userId) return false;
|
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<Db, "select">,
|
||||||
|
) {
|
||||||
|
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<Db, "select">,
|
||||||
|
) {
|
||||||
|
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) {
|
async function promoteInstanceAdmin(userId: string) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -272,19 +451,81 @@ export function accessService(db: Db) {
|
||||||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
.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 existing = await listUserCompanyAccess(userId);
|
||||||
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
||||||
const target = new Set(companyIds);
|
const target = new Set(companyIds);
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id);
|
const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived");
|
||||||
if (toDelete.length > 0) {
|
if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) {
|
||||||
await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete));
|
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) {
|
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({
|
await tx.insert(companyMemberships).values({
|
||||||
companyId,
|
companyId,
|
||||||
principalType: "user",
|
principalType: "user",
|
||||||
|
|
@ -535,6 +776,7 @@ export function accessService(db: Db) {
|
||||||
listMembers,
|
listMembers,
|
||||||
listActiveUserMemberships,
|
listActiveUserMemberships,
|
||||||
copyActiveUserMemberships,
|
copyActiveUserMemberships,
|
||||||
|
archiveMember,
|
||||||
setMemberPermissions,
|
setMemberPermissions,
|
||||||
updateMemberAndPermissions,
|
updateMemberAndPermissions,
|
||||||
promoteInstanceAdmin,
|
promoteInstanceAdmin,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Issues } from "./pages/Issues";
|
||||||
import { IssueDetail } from "./pages/IssueDetail";
|
import { IssueDetail } from "./pages/IssueDetail";
|
||||||
import { Routines } from "./pages/Routines";
|
import { Routines } from "./pages/Routines";
|
||||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||||
|
import { UserProfile } from "./pages/UserProfile";
|
||||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||||
import { Goals } from "./pages/Goals";
|
import { Goals } from "./pages/Goals";
|
||||||
import { GoalDetail } from "./pages/GoalDetail";
|
import { GoalDetail } from "./pages/GoalDetail";
|
||||||
|
|
@ -117,6 +118,7 @@ function boardRoutes() {
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||||
|
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||||
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
||||||
|
|
@ -277,6 +279,7 @@ export function App() {
|
||||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,21 @@ export type CompanyMember = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
principalType: "user";
|
principalType: "user";
|
||||||
principalId: string;
|
principalId: string;
|
||||||
status: "pending" | "active" | "suspended";
|
status: "pending" | "active" | "suspended" | "archived";
|
||||||
membershipRole: HumanCompanyRole | null;
|
membershipRole: HumanCompanyRole | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
|
||||||
grants: CompanyMemberGrant[];
|
grants: CompanyMemberGrant[];
|
||||||
|
removal?: {
|
||||||
|
canArchive: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArchiveCompanyMemberResponse = {
|
||||||
|
member: CompanyMember;
|
||||||
|
reassignedIssueCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanyMembersResponse = {
|
export type CompanyMembersResponse = {
|
||||||
|
|
@ -205,7 +214,7 @@ export type UserCompanyAccessEntry = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
principalType: "user";
|
principalType: "user";
|
||||||
principalId: string;
|
principalId: string;
|
||||||
status: "pending" | "active" | "suspended";
|
status: "pending" | "active" | "suspended" | "archived";
|
||||||
membershipRole: HumanCompanyRole | "member" | null;
|
membershipRole: HumanCompanyRole | "member" | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -341,6 +350,17 @@ export const accessApi = {
|
||||||
},
|
},
|
||||||
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
||||||
|
|
||||||
|
archiveMember: (
|
||||||
|
companyId: string,
|
||||||
|
memberId: string,
|
||||||
|
input: {
|
||||||
|
reassignment?: {
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
assigneeUserId?: string | null;
|
||||||
|
} | null;
|
||||||
|
} = {},
|
||||||
|
) => api.post<ArchiveCompanyMemberResponse>(`/companies/${companyId}/members/${memberId}/archive`, input),
|
||||||
|
|
||||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||||
|
|
||||||
|
|
|
||||||
9
ui/src/api/userProfiles.ts
Normal file
9
ui/src/api/userProfiles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { UserProfileResponse } from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const userProfilesApi = {
|
||||||
|
get: (companyId: string, userSlug: string) =>
|
||||||
|
api.get<UserProfileResponse>(
|
||||||
|
`/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Moon,
|
Moon,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserRound,
|
||||||
Sun,
|
Sun,
|
||||||
UserRoundPen,
|
UserRoundPen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -45,6 +46,20 @@ function deriveInitials(name: string) {
|
||||||
return name.slice(0, 2).toUpperCase();
|
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) {
|
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||||
const className =
|
const className =
|
||||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
|
"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");
|
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||||
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
||||||
const initials = deriveInitials(displayName);
|
const initials = deriveInitials(displayName);
|
||||||
|
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
|
||||||
|
|
||||||
function closeNavigationChrome() {
|
function closeNavigationChrome() {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -164,6 +180,13 @@ export function SidebarAccountMenu({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-1">
|
<div className="mt-4 space-y-1">
|
||||||
|
<MenuAction
|
||||||
|
label="View profile"
|
||||||
|
description="Open your activity, task, and usage ledger."
|
||||||
|
icon={UserRound}
|
||||||
|
href={profileHref}
|
||||||
|
onClick={closeNavigationChrome}
|
||||||
|
/>
|
||||||
<MenuAction
|
<MenuAction
|
||||||
label="Edit profile"
|
label="Edit profile"
|
||||||
description="Update your display name and avatar."
|
description="Update your display name and avatar."
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||||
"usage",
|
"usage",
|
||||||
"activity",
|
"activity",
|
||||||
"inbox",
|
"inbox",
|
||||||
|
"u",
|
||||||
"design-guide",
|
"design-guide",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ export const queryKeys = {
|
||||||
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
||||||
},
|
},
|
||||||
dashboard: (companyId: string) => ["dashboard", 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,
|
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||||
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
|
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
|
||||||
activity: (companyId: string) => ["activity", companyId] as const,
|
activity: (companyId: string) => ["activity", companyId] as const,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { CompanyAccess } from "./CompanyAccess";
|
||||||
const listMembersMock = vi.hoisted(() => vi.fn());
|
const listMembersMock = vi.hoisted(() => vi.fn());
|
||||||
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
||||||
const updateMemberAccessMock = 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", () => ({
|
vi.mock("@/api/access", () => ({
|
||||||
accessApi: {
|
accessApi: {
|
||||||
|
|
@ -18,11 +21,25 @@ vi.mock("@/api/access", () => ({
|
||||||
updateMemberPermissions: vi.fn(),
|
updateMemberPermissions: vi.fn(),
|
||||||
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
||||||
updateMemberAccessMock(companyId, memberId, input),
|
updateMemberAccessMock(companyId, memberId, input),
|
||||||
|
archiveMember: (companyId: string, memberId: string, input: unknown) =>
|
||||||
|
archiveMemberMock(companyId, memberId, input),
|
||||||
approveJoinRequest: vi.fn(),
|
approveJoinRequest: vi.fn(),
|
||||||
rejectJoinRequest: 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", () => ({
|
vi.mock("@/context/CompanyContext", () => ({
|
||||||
useCompany: () => ({
|
useCompany: () => ({
|
||||||
selectedCompanyId: "company-1",
|
selectedCompanyId: "company-1",
|
||||||
|
|
@ -73,6 +90,23 @@ describe("CompanyAccess", () => {
|
||||||
},
|
},
|
||||||
grants: [],
|
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: {
|
access: {
|
||||||
currentUserRole: "owner",
|
currentUserRole: "owner",
|
||||||
|
|
@ -113,6 +147,23 @@ describe("CompanyAccess", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
updateMemberAccessMock.mockResolvedValue({});
|
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(() => {
|
afterEach(() => {
|
||||||
|
|
@ -216,4 +267,119 @@ describe("CompanyAccess", () => {
|
||||||
root.unmount();
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<CompanyAccess />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<CompanyAccess />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared";
|
import {
|
||||||
import { ShieldCheck, Users } from "lucide-react";
|
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 { accessApi, type CompanyMember } from "@/api/access";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
|
import { issuesApi } from "@/api/issues";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,6 +48,9 @@ const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>,
|
||||||
viewer: [],
|
viewer: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
|
||||||
|
type EditableMemberStatus = "pending" | "active" | "suspended";
|
||||||
|
|
||||||
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
||||||
return role ? implicitRoleGrantMap[role] : [];
|
return role ? implicitRoleGrantMap[role] : [];
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +61,10 @@ export function CompanyAccess() {
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||||
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||||
|
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||||
const [draftStatus, setDraftStatus] = useState<CompanyMember["status"]>("active");
|
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
|
||||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -69,6 +81,12 @@ export function CompanyAccess() {
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agentsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const joinRequestsQuery = useQuery({
|
const joinRequestsQuery = useQuery({
|
||||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
|
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
|
||||||
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
|
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
|
||||||
|
|
@ -83,7 +101,7 @@ export function CompanyAccess() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMemberMutation = useMutation({
|
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, {
|
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||||
membershipRole: input.membershipRole,
|
membershipRole: input.membershipRole,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
|
|
@ -147,14 +165,70 @@ export function CompanyAccess() {
|
||||||
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
|
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
|
||||||
[editingMemberId, membersQuery.data?.members],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!editingMember) return;
|
if (!editingMember) return;
|
||||||
setDraftRole(editingMember.membershipRole);
|
setDraftRole(editingMember.membershipRole);
|
||||||
setDraftStatus(editingMember.status);
|
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
|
||||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||||
}, [editingMember]);
|
}, [editingMember]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!removingMember) return;
|
||||||
|
setReassignmentTarget("__unassigned");
|
||||||
|
}, [removingMember]);
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
|
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +255,14 @@ export function CompanyAccess() {
|
||||||
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
||||||
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
||||||
const implicitGrantSet = new Set(implicitGrantKeys);
|
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 (
|
return (
|
||||||
<div className="max-w-6xl space-y-8">
|
<div className="max-w-6xl space-y-8">
|
||||||
|
|
@ -256,7 +338,7 @@ export function CompanyAccess() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border border-border">
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<div>User account</div>
|
<div>User account</div>
|
||||||
<div>Role</div>
|
<div>Role</div>
|
||||||
<div>Status</div>
|
<div>Status</div>
|
||||||
|
|
@ -266,33 +348,52 @@ export function CompanyAccess() {
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
|
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
|
||||||
) : (
|
) : (
|
||||||
members.map((member) => (
|
members.map((member) => {
|
||||||
<div
|
const removalReason = member.removal?.reason ?? null;
|
||||||
key={member.id}
|
const canArchive = member.removal?.canArchive ?? true;
|
||||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="min-w-0">
|
key={member.id}
|
||||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||||
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{member.membershipRole
|
||||||
|
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||||
|
: "Unset"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||||
|
{member.status.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||||
|
<div className="space-y-1 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRemovingMemberId(member.id)}
|
||||||
|
disabled={!canArchive}
|
||||||
|
title={removalReason ?? undefined}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{removalReason ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{removalReason}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
);
|
||||||
{member.membershipRole
|
})
|
||||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
|
||||||
: "Unset"}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
|
||||||
{member.status.replace("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -331,7 +432,7 @@ export function CompanyAccess() {
|
||||||
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
||||||
value={draftStatus}
|
value={draftStatus}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraftStatus(event.target.value as CompanyMember["status"])
|
setDraftStatus(event.target.value as EditableMemberStatus)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
|
|
@ -423,10 +524,109 @@ export function CompanyAccess() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!removingMember} onOpenChange={(open) => !open && setRemovingMemberId(null)}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove member</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{removingMember && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-lg border border-border px-3 py-3">
|
||||||
|
<div className="text-sm font-medium">{memberDisplayName(removingMember)}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{removingMember.user?.email || removingMember.principalId}</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{assignedIssuesQuery.isLoading
|
||||||
|
? "Checking assigned issues..."
|
||||||
|
: `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignedIssues.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Issue reassignment</div>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
value={reassignmentTarget}
|
||||||
|
onChange={(event) => setReassignmentTarget(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="__unassigned">Leave unassigned</option>
|
||||||
|
{activeReassignmentUsers.length > 0 ? (
|
||||||
|
<optgroup label="Humans">
|
||||||
|
{activeReassignmentUsers.map((member) => (
|
||||||
|
<option key={member.id} value={`user:${member.principalId}`}>
|
||||||
|
{memberDisplayName(member)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
|
{activeReassignmentAgents.length > 0 ? (
|
||||||
|
<optgroup label="Agents">
|
||||||
|
{activeReassignmentAgents.map((agent) => (
|
||||||
|
<option key={agent.id} value={`agent:${agent.id}`}>
|
||||||
|
{agent.name} ({agent.role})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
<div className="max-h-36 overflow-auto rounded-lg border border-border">
|
||||||
|
{assignedIssues.slice(0, 6).map((issue) => (
|
||||||
|
<div key={issue.id} className="border-b border-border px-3 py-2 text-sm last:border-b-0">
|
||||||
|
<div className="font-medium">{issue.identifier ?? issue.id.slice(0, 8)}</div>
|
||||||
|
<div className="truncate text-muted-foreground">{issue.title}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{assignedIssues.length > 6 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (!removingMember) return;
|
||||||
|
archiveMemberMutation.mutate({
|
||||||
|
memberId: removingMember.id,
|
||||||
|
target: reassignmentTarget,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={archiveMemberMutation.isPending || assignedIssuesQuery.isLoading}
|
||||||
|
>
|
||||||
|
{archiveMemberMutation.isPending ? "Removing..." : "Remove member"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
function PendingJoinRequestCard({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,11 @@ export function InstanceAccess() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userAccessQuery.data) return;
|
if (!userAccessQuery.data) return;
|
||||||
setSelectedCompanyIds(
|
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]);
|
}, [userAccessQuery.data]);
|
||||||
|
|
||||||
|
|
|
||||||
358
ui/src/pages/UserProfile.tsx
Normal file
358
ui/src/pages/UserProfile.tsx
Normal file
|
|
@ -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<UserProfileWindowStats, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
|
||||||
|
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 (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-2xl font-semibold tabular-nums sm:text-3xl">{value}</div>
|
||||||
|
<div className="mt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
{hint ? <div className="mt-0.5 text-xs text-muted-foreground/70">{hint}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
|
||||||
|
const tokens = totalTokens(stats);
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-4 border-l border-border pl-5 first:border-l-0 first:pl-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<h2 className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{stats.label}</h2>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">{completionRate(stats)} done</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-5 gap-y-3">
|
||||||
|
<Metric value={String(stats.touchedIssues)} label="Touched" />
|
||||||
|
<Metric value={String(stats.completedIssues)} label="Completed" />
|
||||||
|
<Metric value={String(stats.commentCount)} label="Comments" />
|
||||||
|
<Metric value={String(stats.activityCount)} label="Actions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-5 gap-y-1.5 pt-3 text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>Tokens</span>
|
||||||
|
<span className="text-right text-foreground">{formatTokens(tokens)}</span>
|
||||||
|
<span>Spend</span>
|
||||||
|
<span className="text-right text-foreground">{formatCents(stats.costCents)}</span>
|
||||||
|
<span>Created</span>
|
||||||
|
<span className="text-right text-foreground">{stats.createdIssues}</span>
|
||||||
|
<span>Open</span>
|
||||||
|
<span className="text-right text-foreground">{stats.assignedOpenIssues}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ value, label }: { value: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-xl font-semibold tabular-nums">{value}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-foreground">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-semibold">Last 14 days</h2>
|
||||||
|
<div className="flex items-baseline gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="tabular-nums text-foreground">{formatTokens(totalTokensSum)}</span>
|
||||||
|
<span>tokens total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-[repeat(14,minmax(0,1fr))] items-end gap-1.5 sm:gap-2">
|
||||||
|
{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 (
|
||||||
|
<div key={point.date} className="group flex h-36 flex-col justify-end">
|
||||||
|
<div
|
||||||
|
className="w-full bg-foreground/80 transition-opacity group-hover:bg-foreground"
|
||||||
|
style={{ height: `${heightPct}%`, minHeight: tokens === 0 ? 1 : undefined }}
|
||||||
|
title={`${formatShortDate(point.date)}: ${formatTokens(tokens)} tokens, ${point.completedIssues} completed`}
|
||||||
|
/>
|
||||||
|
{completedPct > 0 ? (
|
||||||
|
<div
|
||||||
|
className="mt-1 w-full rounded-full bg-emerald-500/80"
|
||||||
|
style={{ height: 2, opacity: Math.min(1, 0.35 + completedPct / 100) }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1.5 text-[10px] tabular-nums text-muted-foreground sm:gap-2">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<div key={point.date} className="text-center">
|
||||||
|
{index === 0 || index === 6 || index === 13 ? formatShortDate(point.date) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 bg-foreground/80" /> tokens / day
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-[3px] w-4 rounded-full bg-emerald-500/80" /> completions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="pt-4 text-sm text-muted-foreground">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.key} className="grid gap-2 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{row.label}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{row.sublabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-4 text-xs tabular-nums sm:justify-end">
|
||||||
|
<span className="text-muted-foreground">{formatTokens(totalTokens(row))}</span>
|
||||||
|
<span className="font-medium">{formatCents(row.costCents)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<UsageRow[]>(
|
||||||
|
() =>
|
||||||
|
(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<UsageRow[]>(
|
||||||
|
() =>
|
||||||
|
(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 <EmptyState icon={UserRound} message="Select a company to view user profiles." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageSkeleton variant="dashboard" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return <EmptyState icon={AlertCircle} message="User profile not found for this company." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTimeTokens = allTime ? totalTokens(allTime) : 0;
|
||||||
|
const metaParts = [
|
||||||
|
data.user.membershipRole ?? "member",
|
||||||
|
data.user.membershipStatus,
|
||||||
|
`joined ${formatDate(data.user.joinedAt)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 pb-10">
|
||||||
|
<section className="flex flex-col gap-7 border-b border-border pb-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-5">
|
||||||
|
<Avatar className="size-16 border border-border" size="lg">
|
||||||
|
{data.user.image ? <AvatarImage src={data.user.image} alt={displayName} /> : null}
|
||||||
|
<AvatarFallback className="text-lg font-semibold">{initials(displayName)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h1 className="truncate text-2xl font-semibold">{displayName}</h1>
|
||||||
|
<span className="text-sm text-muted-foreground">@{data.user.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{data.user.email ? <span className="truncate">{data.user.email}</span> : null}
|
||||||
|
{data.user.email ? <span aria-hidden>·</span> : null}
|
||||||
|
<span>{metaParts.join(" · ")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<HeroStat label="All-time tokens" value={formatTokens(allTimeTokens)} hint={formatCents(allTime?.costCents ?? 0) + " spent"} />
|
||||||
|
<HeroStat label="Completed" value={String(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
|
||||||
|
<HeroStat label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} hint={`${allTime?.createdIssues ?? 0} created`} />
|
||||||
|
<HeroStat label="7-day actions" value={String(last7?.activityCount ?? 0)} hint={`${last7?.commentCount ?? 0} comments`} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-8 border-b border-border pb-8 lg:grid-cols-3">
|
||||||
|
{data.stats.map((entry) => <WindowColumn key={entry.key} stats={entry} />)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<UsageChart points={data.daily} />
|
||||||
|
|
||||||
|
<div className="grid gap-10 pt-2 xl:grid-cols-2">
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-semibold">Recent tasks</h2>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{data.recentIssues.length}</span>
|
||||||
|
</div>
|
||||||
|
{data.recentIssues.length === 0 ? (
|
||||||
|
<div className="pt-4 text-sm text-muted-foreground">No touched tasks yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{data.recentIssues.map((issue) => (
|
||||||
|
<li key={issue.id}>
|
||||||
|
<Link
|
||||||
|
to={issueUrl(issue)}
|
||||||
|
className="grid gap-2 py-2.5 transition-colors hover:bg-accent/40 sm:grid-cols-[auto_1fr_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||||
|
<span className="truncate text-sm">{issue.title}</span>
|
||||||
|
<span className="flex items-center gap-3 sm:justify-end">
|
||||||
|
<StatusBadge status={issue.status} />
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-semibold">Recent activity</h2>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{data.recentActivity.length}</span>
|
||||||
|
</div>
|
||||||
|
{data.recentActivity.length === 0 ? (
|
||||||
|
<div className="pt-4 text-sm text-muted-foreground">No direct user actions recorded yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{data.recentActivity.map((event) => (
|
||||||
|
<li key={event.id} className="grid gap-2 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm">{event.action.replaceAll("_", " ")}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{event.entityType} · {event.entityId.slice(0, 12)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">{relativeTime(event.createdAt)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-10 xl:grid-cols-2">
|
||||||
|
<UsageList title="Agent attribution" empty="No issue-linked token usage yet." rows={agentUsageRows} />
|
||||||
|
<UsageList title="Provider mix" empty="No provider usage attributed yet." rows={providerUsageRows} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue