mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +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
|
|
@ -9,7 +9,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
assets,
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
companies,
|
||||
companyLogos,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
invites,
|
||||
joinRequests,
|
||||
principalPermissionGrants,
|
||||
|
|
@ -34,11 +35,12 @@ import {
|
|||
searchAdminUsersQuerySchema,
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateCompanyMemberSchema,
|
||||
archiveCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
PERMISSION_KEYS
|
||||
} from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, PermissionKey } from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared";
|
||||
import {
|
||||
forbidden,
|
||||
conflict,
|
||||
|
|
@ -994,7 +996,11 @@ async function loadCompanyAccessSummary(
|
|||
};
|
||||
}
|
||||
|
||||
async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
||||
async function loadCompanyMemberRecords(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
options: { includeArchived?: boolean } = {},
|
||||
) {
|
||||
const members = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
|
|
@ -1002,6 +1008,7 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
|||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
options.includeArchived ? undefined : ne(companyMemberships.status, "archived"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(companyMemberships.updatedAt));
|
||||
|
|
@ -1041,6 +1048,116 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
|
|||
}));
|
||||
}
|
||||
|
||||
type CompanyMemberRecord = Awaited<ReturnType<typeof loadCompanyMemberRecords>>[number];
|
||||
|
||||
const humanRoleRank: Record<HumanCompanyMembershipRole, number> = {
|
||||
viewer: 1,
|
||||
operator: 2,
|
||||
admin: 3,
|
||||
owner: 4,
|
||||
};
|
||||
|
||||
async function resolveActorHumanRole(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
): Promise<HumanCompanyMembershipRole | null> {
|
||||
if (req.actor.type !== "board") return null;
|
||||
if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner";
|
||||
const userId = req.actor.userId ?? null;
|
||||
if (!userId) return null;
|
||||
const membership = await access.getMembership(companyId, "user", userId);
|
||||
if (membership?.status !== "active" || !membership.membershipRole) return null;
|
||||
return normalizeHumanRole(membership.membershipRole, "operator");
|
||||
}
|
||||
|
||||
async function getProtectedMemberReason(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
member: { principalId: string; principalType: string; membershipRole: string | null },
|
||||
opts?: {
|
||||
actorRole?: HumanCompanyMembershipRole | null;
|
||||
instanceAdminUserIds?: ReadonlySet<string>;
|
||||
operation?: "archive" | "update";
|
||||
},
|
||||
): Promise<string | null> {
|
||||
if (member.principalType !== "user") return "Only human company members can be removed.";
|
||||
if (req.actor.type !== "board") return "Board access is required to remove members.";
|
||||
if (member.principalId === req.actor.userId) return "You cannot remove yourself.";
|
||||
const isTargetInstanceAdmin = opts?.instanceAdminUserIds
|
||||
? opts.instanceAdminUserIds.has(member.principalId)
|
||||
: await access.isInstanceAdmin(member.principalId);
|
||||
if (isTargetInstanceAdmin) {
|
||||
return "Instance admins cannot be removed from company access.";
|
||||
}
|
||||
|
||||
const targetRole = member.membershipRole
|
||||
? normalizeHumanRole(member.membershipRole, "operator")
|
||||
: "operator";
|
||||
if (opts?.operation === "archive") {
|
||||
if (targetRole === "owner") return "Board owners cannot be removed from company access.";
|
||||
if (targetRole === "admin") return "Company admins cannot be removed from company access.";
|
||||
}
|
||||
|
||||
const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId);
|
||||
if (!actorRole) return "Only active company members can remove users.";
|
||||
if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) {
|
||||
return "You can only remove users below your company role.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assertCanManageCompanyMember(
|
||||
req: Request,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
member: { principalId: string; principalType: string; membershipRole: string | null },
|
||||
operation: "archive" | "update" = "update",
|
||||
) {
|
||||
const reason = await getProtectedMemberReason(req, access, companyId, member, { operation });
|
||||
if (reason) throw forbidden(reason);
|
||||
}
|
||||
|
||||
async function addCompanyMemberRemovalAccess(
|
||||
req: Request,
|
||||
db: Db,
|
||||
access: ReturnType<typeof accessService>,
|
||||
companyId: string,
|
||||
members: CompanyMemberRecord[],
|
||||
) {
|
||||
const actorRole = await resolveActorHumanRole(req, access, companyId);
|
||||
const userIds = [...new Set(members
|
||||
.filter((member) => member.principalType === "user")
|
||||
.map((member) => member.principalId))];
|
||||
const instanceAdminUserIds = userIds.length > 0
|
||||
? new Set(
|
||||
await db
|
||||
.select({ userId: instanceUserRoles.userId })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows.map((row) => row.userId)),
|
||||
)
|
||||
: new Set<string>();
|
||||
return Promise.all(
|
||||
members.map(async (member) => {
|
||||
const reason = await getProtectedMemberReason(req, access, companyId, member, {
|
||||
actorRole,
|
||||
instanceAdminUserIds,
|
||||
operation: "archive",
|
||||
});
|
||||
return {
|
||||
...member,
|
||||
removal: {
|
||||
canArchive: !reason,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCompanyUserDirectory(db: Db, companyId: string) {
|
||||
const members = await db
|
||||
.select({
|
||||
|
|
@ -3604,7 +3721,7 @@ export function accessRoutes(
|
|||
loadCompanyAccessSummary(req, access, companyId),
|
||||
]);
|
||||
res.json({
|
||||
members,
|
||||
members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members),
|
||||
access: currentAccess,
|
||||
});
|
||||
});
|
||||
|
|
@ -3623,6 +3740,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
|
|
@ -3717,6 +3837,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
|
|
@ -3834,6 +3957,47 @@ export function accessRoutes(
|
|||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/members/:memberId/archive",
|
||||
validate(archiveCompanyMemberSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToArchive = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToArchive) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToArchive, "archive");
|
||||
|
||||
const result = await access.archiveMember(companyId, memberId, {
|
||||
reassignment: req.body.reassignment ?? null,
|
||||
});
|
||||
if (!result) throw notFound("Member not found");
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "company_member.archived",
|
||||
entityType: "company_membership",
|
||||
entityId: memberId,
|
||||
details: {
|
||||
principalId: result.member.principalId,
|
||||
reassignedIssueCount: result.reassignedIssueCount,
|
||||
reassignment: req.body.reassignment ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find(
|
||||
(entry) => entry.id === memberId,
|
||||
);
|
||||
if (!member) throw notFound("Member not found");
|
||||
res.json({
|
||||
member,
|
||||
reassignedIssueCount: result.reassignedIssueCount,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/members/:memberId/permissions",
|
||||
validate(updateMemberPermissionsSchema),
|
||||
|
|
@ -3841,6 +4005,9 @@ export function accessRoutes(
|
|||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const memberToUpdate = await access.getMemberById(companyId, memberId);
|
||||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
const updated = await access.setMemberPermissions(
|
||||
companyId,
|
||||
memberId,
|
||||
|
|
@ -3962,7 +4129,8 @@ export function accessRoutes(
|
|||
const userId = req.params.userId as string;
|
||||
await access.setUserCompanyAccess(
|
||||
userId,
|
||||
req.body.companyIds ?? []
|
||||
req.body.companyIds ?? [],
|
||||
{ actorUserId: req.actor.userId ?? null },
|
||||
);
|
||||
res.json(await loadUserCompanyAccessResponse(db, access, userId));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue