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