[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
23 changed files with 2156 additions and 51 deletions
+125
View File
@@ -5,6 +5,8 @@ import {
companies,
companyMemberships,
createDb,
instanceUserRoles,
issues,
principalPermissionGrants,
} from "@paperclipai/db";
import {
@@ -51,7 +53,9 @@ describeEmbeddedPostgres("access service", () => {
}, 20_000);
afterEach(async () => {
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles);
await db.delete(companyMemberships);
await db.delete(companies);
});
@@ -96,4 +100,125 @@ describeEmbeddedPostgres("access service", () => {
.then((rows) => rows[0]!);
expect(unchanged.status).toBe("active");
});
it("archives members, clears grants, and reassigns open issues without deleting history", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const member = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `member-${randomUUID()}`,
status: "active",
membershipRole: "operator",
})
.returning()
.then((rows) => rows[0]!);
await db.insert(principalPermissionGrants).values({
companyId: company.id,
principalType: "user",
principalId: member.principalId,
permissionKey: "tasks:assign",
grantedByUserId: owner.principalId,
});
const openIssue = await db
.insert(issues)
.values({
companyId: company.id,
title: "Open assigned issue",
status: "in_progress",
assigneeUserId: member.principalId,
})
.returning()
.then((rows) => rows[0]!);
const doneIssue = await db
.insert(issues)
.values({
companyId: company.id,
title: "Historical assigned issue",
status: "done",
assigneeUserId: member.principalId,
})
.returning()
.then((rows) => rows[0]!);
const access = accessService(db);
const result = await access.archiveMember(company.id, member.id, {
reassignment: { assigneeUserId: owner.principalId },
});
expect(result?.reassignedIssueCount).toBe(1);
const archived = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.id, member.id))
.then((rows) => rows[0]!);
expect(archived.status).toBe("archived");
const remainingGrants = await db
.select()
.from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, member.principalId));
expect(remainingGrants).toHaveLength(0);
const reassignedIssue = await db
.select()
.from(issues)
.where(eq(issues.id, openIssue.id))
.then((rows) => rows[0]!);
expect(reassignedIssue.assigneeUserId).toBe(owner.principalId);
expect(reassignedIssue.status).toBe("todo");
const historicalIssue = await db
.select()
.from(issues)
.where(eq(issues.id, doneIssue.id))
.then((rows) => rows[0]!);
expect(historicalIssue.assigneeUserId).toBe(member.principalId);
});
it("rejects instance-level company access removal for self and protected users", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
await expect(
access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("You cannot remove yourself");
const admin = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `admin-${randomUUID()}`,
status: "active",
membershipRole: "admin",
})
.returning()
.then((rows) => rows[0]!);
await expect(
access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Owners and admins cannot be removed from company access");
const operator = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `operator-${randomUUID()}`,
status: "active",
membershipRole: "operator",
})
.returning()
.then((rows) => rows[0]!);
await db.insert(instanceUserRoles).values({
userId: operator.principalId,
role: "instance_admin",
});
await expect(
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
).rejects.toThrow("Instance admins cannot be removed from company access");
});
});
@@ -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 });
});
});