mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +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
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue