Skip to content

Commit 8119fa7

Browse files
committed
feat(dashboard): permissions modal auto-loads the full list on open
The Users-table permissions modal now fetches everything the user can access the moment it opens — no filter input or button click required. The form is purely a narrowing filter (Apply filters / Refresh), skeleton rows show while loading, and all state resets on close so the next open starts fresh for any user.
1 parent 67ccb24 commit 8119fa7

1 file changed

Lines changed: 78 additions & 49 deletions

File tree

web/dashboard/src/components/UserPermissionsModal.tsx

Lines changed: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, { useState } from 'react';
1+
import React, { useCallback, useEffect, useState } from 'react';
22
import { useClient } from 'urql';
3-
import { Search, ShieldCheck, TriangleAlert } from 'lucide-react';
3+
import { RefreshCw, Search, ShieldCheck, TriangleAlert } from 'lucide-react';
44
import { Button } from './ui/button';
55
import { Input } from './ui/input';
66
import { Label } from './ui/label';
7+
import { Skeleton } from './ui/skeleton';
78
import {
89
Dialog,
910
DialogContent,
@@ -24,8 +25,9 @@ interface UserPermissionsModalProps {
2425
// UserPermissionsModal lets an admin answer "what can this user access?"
2526
// straight from the Users table. It calls the public list_permissions API with
2627
// an explicit subject — honored because the dashboard session is super-admin.
27-
// Both filters are optional: leaving them empty lists EVERY permission the
28-
// user holds across the authorization model.
28+
// The COMPLETE permission list loads automatically when the modal opens; the
29+
// form only narrows it (relation and/or object type), and all state resets on
30+
// close.
2931
const UserPermissionsModal = ({
3032
user,
3133
open,
@@ -39,45 +41,66 @@ const UserPermissionsModal = ({
3941
const [error, setError] = useState('');
4042
const [running, setRunning] = useState(false);
4143

42-
if (!user) return null;
44+
const userId = user?.id ?? '';
4345

44-
const handleList = async (e: React.FormEvent) => {
45-
e.preventDefault();
46-
setRunning(true);
47-
setError('');
48-
setPermissions(null);
49-
setTruncated(false);
50-
try {
51-
// Omit empty filters so the server enumerates every matching
52-
// (type, relation) pair of the model.
53-
const params: Record<string, string> = { user: `user:${user.id}` };
54-
if (relation.trim()) params.relation = relation.trim();
55-
if (objectType.trim()) params.object_type = objectType.trim();
56-
const res = await client
57-
.query<ListPermissionsResponse>(
58-
ListPermissionsQuery,
59-
{ params },
60-
{ requestPolicy: 'network-only' },
61-
)
62-
.toPromise();
63-
if (res.error) {
64-
setError(
65-
isFgaNotEnabledError(res.error)
66-
? 'Fine-grained authorization is not enabled on this instance.'
67-
: res.error.message.replace('[GraphQL] ', ''),
68-
);
69-
return;
46+
const fetchPermissions = useCallback(
47+
async (relationFilter: string, typeFilter: string) => {
48+
if (!userId) return;
49+
setRunning(true);
50+
setError('');
51+
setPermissions(null);
52+
setTruncated(false);
53+
try {
54+
// Omit empty filters so the server enumerates every matching
55+
// (type, relation) pair of the model.
56+
const params: Record<string, string> = { user: `user:${userId}` };
57+
if (relationFilter) params.relation = relationFilter;
58+
if (typeFilter) params.object_type = typeFilter;
59+
const res = await client
60+
.query<ListPermissionsResponse>(
61+
ListPermissionsQuery,
62+
{ params },
63+
{ requestPolicy: 'network-only' },
64+
)
65+
.toPromise();
66+
if (res.error) {
67+
setError(
68+
isFgaNotEnabledError(res.error)
69+
? 'Fine-grained authorization is not enabled on this instance.'
70+
: res.error.message.replace('[GraphQL] ', ''),
71+
);
72+
return;
73+
}
74+
setPermissions(res.data?.list_permissions?.permissions ?? []);
75+
setTruncated(res.data?.list_permissions?.truncated ?? false);
76+
} catch {
77+
setError('Failed to list permissions');
78+
} finally {
79+
setRunning(false);
7080
}
71-
setPermissions(res.data?.list_permissions?.permissions ?? []);
72-
setTruncated(res.data?.list_permissions?.truncated ?? false);
73-
} catch {
74-
setError('Failed to list permissions');
75-
} finally {
76-
setRunning(false);
81+
},
82+
[client, userId],
83+
);
84+
85+
// Load the complete list as soon as the modal opens — no filter or click
86+
// needed to see what the user can access.
87+
useEffect(() => {
88+
if (open && userId) {
89+
void fetchPermissions('', '');
7790
}
91+
}, [open, userId, fetchPermissions]);
92+
93+
if (!user) return null;
94+
95+
const handleFilter = async (e: React.FormEvent) => {
96+
e.preventDefault();
97+
await fetchPermissions(relation.trim(), objectType.trim());
7898
};
7999

80100
const close = () => {
101+
// Reset everything so the next open starts fresh for any user.
102+
setRelation('');
103+
setObjectType('');
81104
setPermissions(null);
82105
setTruncated(false);
83106
setError('');
@@ -95,16 +118,15 @@ const UserPermissionsModal = ({
95118
Permissions · {user.email || user.phone_number || user.id}
96119
</DialogTitle>
97120
<DialogDescription>
98-
List what{' '}
121+
Everything{' '}
99122
<code className="rounded bg-gray-100 px-1 py-0.5 text-xs">
100123
user:{user.id}
101124
</code>{' '}
102-
can access. Leave both fields empty to list everything, or narrow
103-
by permission (relation) and/or object type from your authorization
104-
model.
125+
can access. Narrow by permission (relation) and/or object type if
126+
the list is long.
105127
</DialogDescription>
106128
</DialogHeader>
107-
<form onSubmit={handleList} className="space-y-3">
129+
<form onSubmit={handleFilter} className="space-y-3">
108130
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
109131
<div className="space-y-1">
110132
<Label htmlFor="perm-relation">Permission (optional)</Label>
@@ -127,13 +149,13 @@ const UserPermissionsModal = ({
127149
/>
128150
</div>
129151
</div>
130-
<Button type="submit" disabled={running}>
131-
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
132-
{running
133-
? 'Listing…'
134-
: hasFilters
135-
? 'List matching permissions'
136-
: 'List all permissions'}
152+
<Button type="submit" variant="outline" disabled={running}>
153+
{hasFilters ? (
154+
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
155+
) : (
156+
<RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />
157+
)}
158+
{running ? 'Listing…' : hasFilters ? 'Apply filters' : 'Refresh'}
137159
</Button>
138160
</form>
139161
{error && (
@@ -148,6 +170,13 @@ const UserPermissionsModal = ({
148170
permission or object type to see the rest.
149171
</p>
150172
)}
173+
{running && permissions === null && !error && (
174+
<div className="space-y-2" aria-label="Loading permissions">
175+
<Skeleton className="h-8 w-full" />
176+
<Skeleton className="h-8 w-full" />
177+
<Skeleton className="h-8 w-2/3" />
178+
</div>
179+
)}
151180
{permissions !== null &&
152181
(permissions.length ? (
153182
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-200">

0 commit comments

Comments
 (0)