Skip to content

Commit 7eaab4e

Browse files
authored
Wire role management tab to RBAC APIs (#524)
* Wire role management to RBAC APIs * fix API proxy handling for no-content responses * Update role permission chip colors * Address role management review comments * Address role management review feedback
1 parent 6bdc9eb commit 7eaab4e

13 files changed

Lines changed: 827 additions & 92 deletions

File tree

web/src/app/(portal)/admin/users/roles/PermissionMatrixEditor.tsx

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,38 +17,71 @@
1717

1818
"use client";
1919

20-
import { PermissionRW } from "@/shared/users-admin/PermissionRW";
20+
import { cn } from "@/lib/utils";
2121
import type { PermissionKey } from "@/shared/users-admin/permissions";
22-
import { rwStateFor } from "@/shared/users-admin/permissions";
22+
import { permissionRowsFor } from "@/shared/users-admin/permissions";
23+
24+
const ACTION_CHIP_CLASSES: Record<string, string> = {
25+
read: "bg-[color:var(--tone-info-bg)] text-[color:var(--tone-info-fg)]",
26+
write: "bg-[color:var(--tone-ok-bg)] text-[color:var(--tone-ok-fg)]",
27+
};
28+
const ACTION_CHIP_FALLBACK =
29+
"bg-[color:var(--tone-accent-bg)] text-[color:var(--tone-accent-fg)]";
30+
const INACTIVE_CHIP_CLASS = "border border-border text-muted-foreground";
2331

2432
export function PermissionMatrixEditor({
2533
permissions,
34+
catalog,
2635
onTogglePermission,
2736
editable = true,
2837
}: {
2938
permissions: PermissionKey[];
30-
onTogglePermission: (permission: PermissionKey) => void;
39+
catalog?: readonly PermissionKey[];
40+
onTogglePermission?: (permission: PermissionKey) => void;
3141
editable?: boolean;
3242
}) {
33-
const rwPermissions = rwStateFor(permissions);
43+
const rows = permissionRowsFor(permissions, catalog);
44+
const canEdit = editable && onTogglePermission;
3445

3546
return (
3647
<div>
3748
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
3849
Effective Privileges
3950
</h4>
4051
<ul className="space-y-2">
41-
{rwPermissions.map((p) => (
42-
<li key={p.section} className="flex items-center justify-between text-sm">
43-
<span className="font-mono text-foreground">{p.section}</span>
44-
<PermissionRW
45-
read={p.read}
46-
write={p.write}
47-
onToggleRead={editable ? () => onTogglePermission(`${p.section}:read`) : undefined}
48-
onToggleWrite={
49-
editable ? () => onTogglePermission(`${p.section}:write`) : undefined
50-
}
51-
/>
52+
{rows.map((row) => (
53+
<li key={row.section} className="flex items-center justify-between gap-3 text-sm">
54+
<span className="font-mono text-foreground">{row.section}</span>
55+
<div className="flex flex-wrap justify-end gap-1">
56+
{row.actions.map((privilege) => {
57+
const className = cn(
58+
"inline-flex h-6 items-center justify-center rounded px-2 text-xs font-medium",
59+
privilege.active
60+
? ACTION_CHIP_CLASSES[privilege.action] ?? ACTION_CHIP_FALLBACK
61+
: INACTIVE_CHIP_CLASS,
62+
canEdit && "cursor-pointer transition-transform hover:scale-105",
63+
);
64+
if (!canEdit) {
65+
return (
66+
<span key={privilege.key} title={privilege.key} className={className}>
67+
{privilege.action}
68+
</span>
69+
);
70+
}
71+
return (
72+
<button
73+
key={privilege.key}
74+
type="button"
75+
title={privilege.key}
76+
aria-pressed={privilege.active}
77+
onClick={() => onTogglePermission(privilege.key)}
78+
className={className}
79+
>
80+
{privilege.action}
81+
</button>
82+
);
83+
})}
84+
</div>
5285
</li>
5386
))}
5487
</ul>

web/src/app/(portal)/admin/users/roles/RoleCard.tsx

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,13 @@
1818
"use client";
1919

2020
import { Pencil, ShieldCheck } from "lucide-react";
21+
import type { RoleRow } from "@/features/core/roles/schemas";
2122
import { Badge } from "@/shared/ui/badge";
2223
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
23-
import { PermissionRW } from "@/shared/users-admin/PermissionRW";
24-
import { rwStateFor } from "@/shared/users-admin/permissions";
25-
import type { RoleRow } from "@/shared/users-admin/types";
24+
import { PermissionMatrixEditor } from "./PermissionMatrixEditor";
2625
import { RoleFormDialog } from "./RoleFormDialog";
2726

28-
export function RoleCard({ role, memberCount }: { role: RoleRow; memberCount: number }) {
29-
const rwPermissions = rwStateFor(role.permissions).filter((p) => p.read || p.write);
30-
27+
export function RoleCard({ role }: { role: RoleRow }) {
3128
return (
3229
<Card>
3330
<CardHeader>
@@ -45,30 +42,17 @@ export function RoleCard({ role, memberCount }: { role: RoleRow; memberCount: nu
4542
<p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
4643
</div>
4744
<Badge variant="secondary" className="shrink-0">
48-
{memberCount} {memberCount === 1 ? "member" : "members"}
45+
{role.memberCount} {role.memberCount === 1 ? "member" : "members"}
4946
</Badge>
5047
</div>
5148
</CardHeader>
5249
<CardContent className="space-y-4">
5350
<div className="border-t border-border" />
5451

55-
<div>
56-
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
57-
Effective Privileges
58-
</h4>
59-
{rwPermissions.length === 0 ? (
60-
<p className="text-sm text-muted-foreground">No privileges granted.</p>
61-
) : (
62-
<ul className="space-y-2">
63-
{rwPermissions.map((p) => (
64-
<li key={p.section} className="flex items-center justify-between text-sm">
65-
<span className="font-mono text-foreground">{p.section}</span>
66-
<PermissionRW read={p.read} write={p.write} />
67-
</li>
68-
))}
69-
</ul>
70-
)}
71-
</div>
52+
<PermissionMatrixEditor
53+
permissions={role.privileges}
54+
editable={false}
55+
/>
7256

7357
<div className="border-t border-border" />
7458

web/src/app/(portal)/admin/users/roles/RoleFormDialog.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
"use client";
1919

2020
import * as React from "react";
21+
import { toast } from "sonner";
22+
import { useCreateRole, usePrivilegeCatalog, useUpdateRole } from "@/features/core/roles/queries";
23+
import type { RoleRow } from "@/features/core/roles/schemas";
2124
import { Button } from "@/shared/ui/button";
2225
import {
2326
Dialog,
@@ -32,8 +35,8 @@ import { Input } from "@/shared/ui/input";
3235
import { Label } from "@/shared/ui/label";
3336
import { togglePermission } from "@/shared/users-admin/permissions";
3437
import type { PermissionKey } from "@/shared/users-admin/permissions";
35-
import type { RoleRow, UserRow } from "@/shared/users-admin/types";
3638
import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
39+
import type { UserRow } from "@/shared/users-admin/types";
3740
import { PermissionMatrixEditor } from "./PermissionMatrixEditor";
3841

3942
function fullNameFor(user: UserRow): string {
@@ -51,8 +54,11 @@ export function RoleFormDialog({
5154
triggerRender: React.ReactElement;
5255
triggerContent: React.ReactNode;
5356
}) {
54-
const { users, addRole, updateRole } = useUsersAdmin();
5557
const isEdit = Boolean(role);
58+
const catalogQuery = usePrivilegeCatalog();
59+
const createRole = useCreateRole();
60+
const updateRole = useUpdateRole();
61+
const { users } = useUsersAdmin();
5662
const [open, setOpen] = React.useState(false);
5763
const [name, setName] = React.useState("");
5864
const [description, setDescription] = React.useState("");
@@ -65,13 +71,9 @@ export function RoleFormDialog({
6571
if (next) {
6672
setName(role?.name ?? "");
6773
setDescription(role?.description ?? "");
68-
setPermissions(role?.permissions ?? []);
74+
setPermissions(role?.privileges ?? []);
6975
setUserSearch("");
70-
setSelectedUserIds(
71-
new Set(
72-
role ? users.filter((u) => u.roles.some((r) => r.id === role.id)).map((u) => u.id ?? "") : [],
73-
),
74-
);
76+
setSelectedUserIds(new Set(role?.holderIds ?? []));
7577
}
7678
}
7779

@@ -84,17 +86,26 @@ export function RoleFormDialog({
8486
});
8587
}
8688

87-
function handleSubmit() {
89+
async function handleSubmit() {
8890
if (!name.trim()) return;
89-
const input = { name: name.trim(), description: description.trim(), permissions };
90-
if (isEdit && role?.id) {
91-
updateRole(role.id, input, Array.from(selectedUserIds));
92-
} else {
93-
addRole(input, Array.from(selectedUserIds));
91+
const input = { name: name.trim(), description: description.trim(), privileges: permissions };
92+
const memberUserIds = Array.from(selectedUserIds);
93+
try {
94+
if (isEdit && role?.id) {
95+
await updateRole.mutateAsync({ role, input, memberUserIds });
96+
toast.success("Role updated");
97+
} else {
98+
await createRole.mutateAsync({ ...input, memberUserIds });
99+
toast.success("Role created");
100+
}
101+
setOpen(false);
102+
} catch (err) {
103+
toast.error(err instanceof Error ? err.message : "Role update failed");
94104
}
95-
setOpen(false);
96105
}
97106

107+
const saving = createRole.isPending || updateRole.isPending;
108+
const catalog = catalogQuery.data ?? [];
98109
const needle = userSearch.trim().toLowerCase();
99110
const matchingUsers = needle
100111
? users.filter((u) => `${fullNameFor(u)} ${u.email ?? ""}`.toLowerCase().includes(needle))
@@ -108,8 +119,8 @@ export function RoleFormDialog({
108119
<DialogTitle>{isEdit ? "Edit role" : "Create role"}</DialogTitle>
109120
<DialogDescription>
110121
{isEdit
111-
? `Update what ${role?.name} can see and do, and who holds it.`
112-
: "Define a name, choose the permissions it grants, and optionally assign it to users right away."}
122+
? `Update what ${role?.name} can see and do.`
123+
: "Define a name and choose the permissions it grants."}
113124
</DialogDescription>
114125
</DialogHeader>
115126

@@ -140,6 +151,7 @@ export function RoleFormDialog({
140151

141152
<PermissionMatrixEditor
142153
permissions={permissions}
154+
catalog={catalog}
143155
onTogglePermission={(key) => setPermissions((prev) => togglePermission(prev, key))}
144156
/>
145157

@@ -186,11 +198,11 @@ export function RoleFormDialog({
186198
</div>
187199

188200
<DialogFooter>
189-
<Button variant="outline" onClick={() => setOpen(false)} type="button">
201+
<Button variant="outline" onClick={() => setOpen(false)} type="button" disabled={saving}>
190202
Cancel
191203
</Button>
192-
<Button onClick={handleSubmit} disabled={!name.trim()} type="button">
193-
{isEdit ? "Save changes" : "Create role"}
204+
<Button onClick={handleSubmit} disabled={!name.trim() || saving} type="button">
205+
{saving ? "Saving..." : isEdit ? "Save changes" : "Create role"}
194206
</Button>
195207
</DialogFooter>
196208
</DialogContent>

web/src/app/(portal)/admin/users/roles/RolesGrid.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,56 @@
1717

1818
"use client";
1919

20-
import { useUsersAdmin } from "@/shared/users-admin/UsersAdminContext";
20+
import { useRoleRows } from "@/features/core/roles/queries";
21+
import { Button } from "@/shared/ui/button";
2122
import { RoleCard } from "./RoleCard";
2223

2324
export function RolesGrid() {
24-
const { roles, users } = useUsersAdmin();
25+
const rolesQuery = useRoleRows();
26+
27+
if (rolesQuery.isPending) {
28+
return (
29+
<div className="rounded-md border bg-card p-6 text-sm text-muted-foreground">
30+
Loading roles...
31+
</div>
32+
);
33+
}
34+
35+
if (rolesQuery.isError) {
36+
const error = rolesQuery.error;
37+
return (
38+
<div className="space-y-3 rounded-md border bg-card p-6">
39+
<p className="text-sm text-destructive">
40+
{error instanceof Error ? error.message : "Could not load roles."}
41+
</p>
42+
<Button
43+
type="button"
44+
variant="outline"
45+
size="sm"
46+
onClick={() => {
47+
rolesQuery.refetch();
48+
}}
49+
>
50+
Retry
51+
</Button>
52+
</div>
53+
);
54+
}
55+
56+
const roles = rolesQuery.data;
57+
58+
if (roles.length === 0) {
59+
return (
60+
<div className="rounded-md border bg-card p-6 text-sm text-muted-foreground">
61+
No roles have been created yet.
62+
</div>
63+
);
64+
}
2565

2666
return (
2767
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
2868
{roles.map((role) => (
29-
<RoleCard
30-
key={role.id}
31-
role={role}
32-
memberCount={users.filter((u) => u.roles.some((r) => r.id === role.id)).length}
33-
/>
69+
<RoleCard key={role.id} role={role} />
3470
))}
3571
</div>
3672
);
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import { NextRequest } from "next/server";
19+
import { afterEach, describe, expect, it, vi } from "vitest";
20+
21+
vi.mock("@/lib/env", () => ({
22+
serverEnv: {
23+
CUSTOS_CORE_API_BASE_URL: "https://core.example.org",
24+
},
25+
}));
26+
27+
vi.mock("@/shared/auth/session", () => ({
28+
getPortalSession: vi.fn(async () => ({ user: { email: "admin@custos.local" } })),
29+
pickBackendBearer: vi.fn(() => "access-token-abc"),
30+
}));
31+
32+
import { POST } from "../route";
33+
34+
const fetchMock = vi.fn();
35+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
36+
37+
const ctx = { params: Promise.resolve({ path: ["roles", "role-1", "privileges"] }) };
38+
39+
afterEach(() => {
40+
fetchMock.mockReset();
41+
});
42+
43+
describe("api v1 proxy route", () => {
44+
it("proxies no-content backend responses without constructing a response body", async () => {
45+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
46+
47+
const response = await POST(
48+
new NextRequest("http://localhost:3000/api/v1/roles/role-1/privileges", {
49+
method: "POST",
50+
body: JSON.stringify({ privilege: "core:roles:manage" }),
51+
headers: { "content-type": "application/json" },
52+
}),
53+
ctx,
54+
);
55+
56+
expect(response.status).toBe(204);
57+
expect(await response.text()).toBe("");
58+
});
59+
});

web/src/app/api/v1/[...path]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ async function proxy(request: NextRequest, ctx: Context) {
5757
const traceId = upstream.headers.get("x-trace-id");
5858
if (traceId) responseHeaders.set("x-trace-id", traceId);
5959

60-
return new NextResponse(await upstream.text(), {
60+
const hasNoBody = upstream.status === 204 || upstream.status === 205 || upstream.status === 304;
61+
62+
return new NextResponse(hasNoBody ? null : await upstream.text(), {
6163
status: upstream.status,
6264
headers: responseHeaders,
6365
});

0 commit comments

Comments
 (0)