diff --git a/packages/api/src/controllers/user/organizations.ts b/packages/api/src/controllers/user/organizations.ts index eaf9851..c46e7e1 100644 --- a/packages/api/src/controllers/user/organizations.ts +++ b/packages/api/src/controllers/user/organizations.ts @@ -38,13 +38,17 @@ const OrganizationListItemSchema = OrganizationSchema.extend({ roles: z.array(RoleSummarySchema), }); +const MemberRoleSchema = RoleSummarySchema.extend({ + grantsOrgManage: z.boolean().optional(), +}); + const MemberSchema = z.object({ membershipId: z.string().uuid(), userSub: z.string(), status: z.string(), email: z.string().nullable().optional(), name: z.string().nullable().optional(), - roles: z.array(RoleSummarySchema), + roles: z.array(MemberRoleSchema), }); const AssignableRoleSchema = z.object({ diff --git a/packages/api/src/models/organizations.test.ts b/packages/api/src/models/organizations.test.ts index 18ee7a4..1547aa1 100644 --- a/packages/api/src/models/organizations.test.ts +++ b/packages/api/src/models/organizations.test.ts @@ -21,6 +21,7 @@ import { createOrganizationInvite, leaveOrganization, listOrganizationsForUser, + removeMemberRole, removeOrganizationMember, } from "./organizations.ts"; @@ -228,7 +229,7 @@ test("removeOrganizationMember rejects removing the last managing member", async () => removeOrganizationMember(context, "manager", organization.id, managerMembership.id), (error: unknown) => { assert.ok(error instanceof ValidationError); - assert.equal(error.message, "Organization must retain at least one managing member"); + assert.equal(error.message, "Organization must retain at least one administrator"); return true; } ); @@ -237,3 +238,89 @@ test("removeOrganizationMember rejects removing the last managing member", async fs.rmSync(directory, { recursive: true, force: true }); } }); + +test("removeMemberRole rejects removing the last administrator role", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-role-remove-test-")); + const { db, close } = await createPglite(directory); + const context = { db, logger: createLogger() } as Context; + + try { + await db.insert(users).values([ + { sub: "admin", email: "admin@example.com", name: "Admin" }, + { sub: "member", email: "member@example.com", name: "Member" }, + ]); + const [organization] = await db + .insert(organizations) + .values({ slug: "org-1", name: "Org One", createdByUserSub: "admin" }) + .returning(); + assert.ok(organization); + + const [adminMembership] = await db + .insert(organizationMembers) + .values({ organizationId: organization.id, userSub: "admin", status: "active" }) + .returning(); + const [memberMembership] = await db + .insert(organizationMembers) + .values({ organizationId: organization.id, userSub: "member", status: "active" }) + .returning(); + assert.ok(adminMembership); + assert.ok(memberMembership); + + const [adminRole] = await db + .insert(roles) + .values({ key: "org-admin", name: "Organization Admin", assignable: true }) + .returning(); + const [memberRole] = await db + .insert(roles) + .values({ key: "org-member", name: "Member", assignable: true }) + .returning(); + assert.ok(adminRole); + assert.ok(memberRole); + + await db + .insert(permissions) + .values({ key: "darkauth.org:manage", description: "Manage org" }) + .onConflictDoNothing(); + await db + .insert(rolePermissions) + .values({ roleId: adminRole.id, permissionKey: "darkauth.org:manage" }); + await db + .insert(organizationMemberRoles) + .values({ organizationMemberId: adminMembership.id, roleId: adminRole.id }); + await db + .insert(organizationMemberRoles) + .values({ organizationMemberId: memberMembership.id, roleId: memberRole.id }); + + await assert.rejects( + () => removeMemberRole(context, "admin", organization.id, adminMembership.id, adminRole.id), + (error: unknown) => { + assert.ok(error instanceof ValidationError); + assert.equal(error.message, "Organization must retain at least one administrator"); + return true; + } + ); + + // Granting a second administrator allows the role to be removed. + await db + .insert(organizationMemberRoles) + .values({ organizationMemberId: memberMembership.id, roleId: adminRole.id }); + + const result = await removeMemberRole( + context, + "admin", + organization.id, + adminMembership.id, + adminRole.id + ); + assert.deepEqual(result, { success: true }); + + const remaining = await db + .select({ roleId: organizationMemberRoles.roleId }) + .from(organizationMemberRoles) + .where(eq(organizationMemberRoles.organizationMemberId, adminMembership.id)); + assert.equal(remaining.length, 0); + } finally { + await close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/api/src/models/organizations.ts b/packages/api/src/models/organizations.ts index cf97ab5..a7f25c3 100644 --- a/packages/api/src/models/organizations.ts +++ b/packages/api/src/models/organizations.ts @@ -4,6 +4,7 @@ import { organizationMemberRoles, organizationMembers, organizations, + rolePermissions, roles, users, } from "../db/schema.ts"; @@ -35,6 +36,8 @@ const personalSlugWords = [ type DbLike = Pick; +const ORG_MANAGE_PERMISSION = "darkauth.org:manage"; + function cleanSlug(value: string) { return value .trim() @@ -413,11 +416,21 @@ export async function listOrganizationMembers( .innerJoin(roles, eq(organizationMemberRoles.roleId, roles.id)) .where(inArray(organizationMemberRoles.organizationMemberId, membershipIds)); - const rolesByMembership = new Map>(); + const manageRoleIds = await getOrgManageRoleIds(context); + + const rolesByMembership = new Map< + string, + Array<{ id: string; key: string; name: string; grantsOrgManage: boolean }> + >(); for (const row of roleRows) { const list = rolesByMembership.get(row.membershipId) || []; - list.push({ id: row.roleId, key: row.roleKey, name: row.roleName }); + list.push({ + id: row.roleId, + key: row.roleKey, + name: row.roleName, + grantsOrgManage: manageRoleIds.has(row.roleId), + }); rolesByMembership.set(row.membershipId, list); } @@ -515,6 +528,13 @@ export async function removeMemberRole( }); if (!member) throw new NotFoundError("Organization member not found"); + await assertRoleRemovalKeepsOrganizationManageAuthority( + context, + organizationId, + memberId, + roleId + ); + await context.db .delete(organizationMemberRoles) .where( @@ -553,20 +573,61 @@ async function userHasOrganizationManagePermission( organizationId: string ) { const access = await getUserOrgAccess(context, userSub, organizationId); - return access.permissions.includes("darkauth.org:manage"); + return access.permissions.includes(ORG_MANAGE_PERMISSION); } -async function assertRemovalKeepsOrganizationManageAuthority( +async function getOrgManageRoleIds(context: Context) { + const rows = await context.db + .select({ roleId: rolePermissions.roleId }) + .from(rolePermissions) + .where(eq(rolePermissions.permissionKey, ORG_MANAGE_PERMISSION)); + return new Set(rows.map((row) => row.roleId)); +} + +async function organizationHasOtherManagingMember( context: Context, organizationId: string, - removedMemberId: string + excludeMembershipId: string ) { const members = await activeMembersForOrganization(context, organizationId); for (const member of members) { - if (member.membershipId === removedMemberId) continue; - if (await userHasOrganizationManagePermission(context, member.userSub, organizationId)) return; + if (member.membershipId === excludeMembershipId) continue; + if (await userHasOrganizationManagePermission(context, member.userSub, organizationId)) + return true; } - throw new ValidationError("Organization must retain at least one managing member"); + return false; +} + +async function assertRoleRemovalKeepsOrganizationManageAuthority( + context: Context, + organizationId: string, + memberId: string, + roleId: string +) { + const manageRoleIds = await getOrgManageRoleIds(context); + if (!manageRoleIds.has(roleId)) return; + + const memberRoleRows = await context.db + .select({ roleId: organizationMemberRoles.roleId }) + .from(organizationMemberRoles) + .where(eq(organizationMemberRoles.organizationMemberId, memberId)); + const retainsManageItself = memberRoleRows.some( + (row) => row.roleId !== roleId && manageRoleIds.has(row.roleId) + ); + if (retainsManageItself) return; + + if (await organizationHasOtherManagingMember(context, organizationId, memberId)) return; + + throw new ValidationError("Organization must retain at least one administrator"); +} + +async function assertRemovalKeepsOrganizationManageAuthority( + context: Context, + organizationId: string, + removedMemberId: string +) { + if (await organizationHasOtherManagingMember(context, organizationId, removedMemberId)) return; + throw new ValidationError("Organization must retain at least one administrator"); } async function assertMemberCanBeRemoved( diff --git a/packages/user-ui/src/components/OrganizationDetail.module.css b/packages/user-ui/src/components/OrganizationDetail.module.css index dd7ea91..c5c6066 100644 --- a/packages/user-ui/src/components/OrganizationDetail.module.css +++ b/packages/user-ui/src/components/OrganizationDetail.module.css @@ -22,9 +22,49 @@ color: var(--da-color-text); } +.invite { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--da-space-3); + padding: var(--da-space-4); + margin-bottom: var(--da-space-4); + border: 1px solid var(--da-color-border); + border-radius: var(--da-surface-radius); + background: var(--da-color-surface-raised); +} + +.inviteFields { + display: flex; + flex-wrap: wrap; + gap: var(--da-space-3); + flex: 1 1 auto; +} + +.field { + display: grid; + gap: var(--da-space-2); + flex: 1 1 14rem; + min-width: min(100%, 14rem); + color: var(--da-color-text); + font-size: 0.875rem; + font-weight: 800; +} + +.field input, +.field select, +.roleAdd select { + min-height: 44px; + padding: 0.75rem 0.875rem; + border: 1px solid var(--da-color-border); + border-radius: var(--da-control-radius); + background: var(--da-color-surface); + color: var(--da-color-text); +} + .grid { display: grid; - gap: var(--da-space-4); + gap: var(--da-space-3); } .member { @@ -33,16 +73,58 @@ padding: var(--da-space-4); border: 1px solid var(--da-color-border); border-radius: var(--da-surface-radius); + transition: border-color 0.15s ease; +} + +.member:hover { + border-color: color-mix(in srgb, var(--da-color-action) 30%, var(--da-color-border)); } .memberHeader { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: var(--da-space-3); min-width: 0; } +.memberIdentity { + display: flex; + align-items: center; + gap: var(--da-space-3); + min-width: 0; +} + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--da-color-action) 16%, var(--da-color-surface-raised)); + color: var(--da-color-text); + font-size: 0.8125rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.memberMeta { + display: flex; + align-items: center; + gap: var(--da-space-2); + flex: 0 0 auto; +} + +.youTag { + color: var(--da-color-text-muted); + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + .memberTitle { display: grid; min-width: 0; @@ -73,7 +155,7 @@ align-items: center; gap: 0.35rem; min-height: 1.75rem; - padding: 0.25rem 0.625rem; + padding: 0.25rem 0.35rem 0.25rem 0.7rem; border: 1px solid var(--da-color-border); border-radius: 999px; background: var(--da-color-surface-raised); @@ -82,12 +164,50 @@ font-weight: 800; } +.roleAdmin { + border-color: color-mix(in srgb, var(--da-color-action) 45%, var(--da-color-border)); + background: color-mix(in srgb, var(--da-color-action) 12%, var(--da-color-surface-raised)); +} + .role button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; border: 0; + border-radius: 999px; background: transparent; - color: var(--da-color-danger); - font: inherit; + color: var(--da-color-text-muted); + font-size: 1rem; + line-height: 1; cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.role button:hover:not(:disabled) { + background: color-mix(in srgb, var(--da-color-danger) 16%, transparent); + color: var(--da-color-danger); +} + +.role button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.roleAdd { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--da-space-2); +} + +.roleAdd select { + flex: 1 1 16rem; + min-width: min(100%, 12rem); } .inlineForm { @@ -147,7 +267,15 @@ } @media (max-width: 640px) { - .memberHeader, + .memberHeader { + align-items: flex-start; + flex-direction: column; + } + + .memberMeta { + flex-wrap: wrap; + } + .inlineForm { align-items: stretch; flex-direction: column; diff --git a/packages/user-ui/src/components/OrganizationDetail.tsx b/packages/user-ui/src/components/OrganizationDetail.tsx index c05e1cc..e870e18 100644 --- a/packages/user-ui/src/components/OrganizationDetail.tsx +++ b/packages/user-ui/src/components/OrganizationDetail.tsx @@ -88,6 +88,30 @@ export default function OrganizationDetail({ [assignableRoles] ); + const adminMemberCount = useMemo( + () => members.filter((member) => member.roles.some((role) => role.grantsOrgManage)).length, + [members] + ); + + const memberInitials = (member: OrganizationMember) => { + const source = (member.name || member.email || member.userSub || "").trim(); + if (!source) return "?"; + const parts = source.split(/\s+/).filter(Boolean); + const letters = + parts.length >= 2 ? `${parts[0][0]}${parts[parts.length - 1][0]}` : source.slice(0, 2); + return letters.toUpperCase(); + }; + + const isLastAdminRole = (member: OrganizationMember, role: OrganizationRole) => { + if (!role.grantsOrgManage) return false; + if (adminMemberCount > 1) return false; + const adminRolesForMember = member.roles.filter((item) => item.grantsOrgManage).length; + return adminRolesForMember <= 1; + }; + + const isLastAdminMember = (member: OrganizationMember) => + adminMemberCount <= 1 && member.roles.some((role) => role.grantsOrgManage); + const switchToOrganization = async () => { if (!organization) return; try { @@ -264,34 +288,36 @@ export default function OrganizationDetail({ title="Members" description={`${members.length} member${members.length === 1 ? "" : "s"}`} > -
- - {assignableRoles.length > 0 ? ( -