[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:
Dotta 2026-04-20 06:10:20 -05:00 committed by GitHub
parent e89d3f7e11
commit d8b63a18e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2156 additions and 51 deletions

View file

@ -380,7 +380,7 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
export const PRINCIPAL_TYPES = ["user", "agent"] as const;
export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const;
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended", "archived"] as const;
export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
export const COMPANY_MEMBERSHIP_ROLES = [

View file

@ -316,6 +316,14 @@ export type {
LiveEvent,
DashboardSummary,
ActivityEvent,
UserProfileActivitySummary,
UserProfileAgentUsage,
UserProfileDailyPoint,
UserProfileIdentity,
UserProfileIssueSummary,
UserProfileProviderUsage,
UserProfileResponse,
UserProfileWindowStats,
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
@ -600,6 +608,7 @@ export {
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
updateCompanyMemberWithPermissionsSchema,
archiveCompanyMemberSchema,
updateMemberPermissionsSchema,
searchAdminUsersQuerySchema,
updateUserCompanyAccessSchema,
@ -621,6 +630,7 @@ export {
type UpdateCurrentUserProfile,
type UpdateCompanyMember,
type UpdateCompanyMemberWithPermissions,
type ArchiveCompanyMember,
type UpdateMemberPermissions,
type SearchAdminUsersQuery,
type UpdateUserCompanyAccess,

View file

@ -47,6 +47,10 @@ export interface CompanyMemberRecord extends CompanyMembership {
membershipRole: HumanCompanyMembershipRole | null;
user: AccessUserProfile | null;
grants: PrincipalPermissionGrant[];
removal?: {
canArchive: boolean;
reason: string | null;
};
}
export interface CompanyMembersResponse {
@ -59,6 +63,11 @@ export interface CompanyMembersResponse {
};
}
export interface ArchiveCompanyMemberResponse {
member: CompanyMemberRecord;
reassignedIssueCount: number;
}
export interface Invite {
id: string;
companyId: string | null;

View file

@ -169,6 +169,16 @@ export type {
export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js";
export type {
UserProfileActivitySummary,
UserProfileAgentUsage,
UserProfileDailyPoint,
UserProfileIdentity,
UserProfileIssueSummary,
UserProfileProviderUsage,
UserProfileResponse,
UserProfileWindowStats,
} from "./user-profile.js";
export type { SidebarBadges } from "./sidebar-badges.js";
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
@ -176,6 +186,7 @@ export type {
AccessUserProfile,
CompanyMemberRecord,
CompanyMembersResponse,
ArchiveCompanyMemberResponse,
CompanyMembership,
CompanyInviteListResponse,
CompanyInviteRecord,

View 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[];
}

View file

@ -5,7 +5,6 @@ import {
INVITE_JOIN_TYPES,
JOIN_REQUEST_STATUSES,
JOIN_REQUEST_TYPES,
MEMBERSHIP_STATUSES,
PERMISSION_KEYS,
} from "../constants.js";
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
@ -97,9 +96,11 @@ export const updateMemberPermissionsSchema = z.object({
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
const editableMembershipStatuses = ["pending", "active", "suspended"] as const;
export const updateCompanyMemberSchema = z.object({
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
status: z.enum(MEMBERSHIP_STATUSES).optional(),
status: z.enum(editableMembershipStatuses).optional(),
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
message: "membershipRole or status is required",
});
@ -108,7 +109,7 @@ export type UpdateCompanyMember = z.infer<typeof updateCompanyMemberSchema>;
export const updateCompanyMemberWithPermissionsSchema = z.object({
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
status: z.enum(MEMBERSHIP_STATUSES).optional(),
status: z.enum(editableMembershipStatuses).optional(),
grants: updateMemberPermissionsSchema.shape.grants.default([]),
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
message: "membershipRole or status is required",
@ -116,6 +117,26 @@ export const updateCompanyMemberWithPermissionsSchema = z.object({
export type UpdateCompanyMemberWithPermissions = z.infer<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({
companyIds: z.array(z.string().uuid()).default([]),
});

View file

@ -266,6 +266,7 @@ export {
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
updateCompanyMemberWithPermissionsSchema,
archiveCompanyMemberSchema,
updateMemberPermissionsSchema,
searchAdminUsersQuerySchema,
updateUserCompanyAccessSchema,
@ -283,6 +284,7 @@ export {
type UpdateCurrentUserProfile,
type UpdateCompanyMember,
type UpdateCompanyMemberWithPermissions,
type ArchiveCompanyMember,
type UpdateMemberPermissions,
type SearchAdminUsersQuery,
type UpdateUserCompanyAccess,