1- import React , { useState } from 'react' ;
1+ import React , { useCallback , useEffect , useState } from 'react' ;
22import { useClient } from 'urql' ;
3- import { Search , ShieldCheck , TriangleAlert } from 'lucide-react' ;
3+ import { RefreshCw , Search , ShieldCheck , TriangleAlert } from 'lucide-react' ;
44import { Button } from './ui/button' ;
55import { Input } from './ui/input' ;
66import { Label } from './ui/label' ;
7+ import { Skeleton } from './ui/skeleton' ;
78import {
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.
2931const 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