mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
## 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>
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
instanceUserRoles,
|
|
issues,
|
|
principalPermissionGrants,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { accessService } from "../services/access.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
async function createCompanyWithOwner(db: ReturnType<typeof createDb>) {
|
|
const company = await db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Access Service ${randomUUID()}`,
|
|
issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
|
|
const owner = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `owner-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "owner",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
|
|
return { company, owner };
|
|
}
|
|
|
|
describeEmbeddedPostgres("access service", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issues);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(instanceUserRoles);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("rejects combined access updates that would demote the last active owner", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
|
|
await expect(
|
|
access.updateMemberAndPermissions(
|
|
company.id,
|
|
owner.id,
|
|
{ membershipRole: "admin", grants: [] },
|
|
"admin-user",
|
|
),
|
|
).rejects.toThrow("Cannot remove the last active owner");
|
|
|
|
const unchanged = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, owner.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(unchanged.membershipRole).toBe("owner");
|
|
});
|
|
|
|
it("rejects role-only updates that would suspend the last active owner", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
|
|
await expect(
|
|
access.updateMember(company.id, owner.id, { status: "suspended" }),
|
|
).rejects.toThrow("Cannot remove the last active owner");
|
|
|
|
const unchanged = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, owner.id))
|
|
.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");
|
|
});
|
|
});
|