1- import { useQuery } from '@tanstack/react-query'
1+ import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
22import type { GetServerSideProps } from 'next'
33import Head from 'next/head'
44import { useCallback , useMemo } from 'react'
55import toast from 'react-hot-toast'
6- import { LuCircle , LuFileText , LuRadio } from 'react-icons/lu'
6+ import { LuCircle , LuFileText , LuRadio , LuShieldCheck , LuShieldOff } from 'react-icons/lu'
77
88import { AdminLayout } from '@/components/layout/AdminLayout'
99import { Header } from '@/components/layout/Header'
1010import { DataTable } from '@/components/tables/DataTable'
1111import { Avatar } from '@/components/ui/Avatar'
1212import { NotificationBadges } from '@/components/ui/NotificationBadges'
1313import { SearchInput } from '@/components/ui/SearchInput'
14+ import { useAdminAuth } from '@/hooks/useAdminAuth'
1415import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'
1516import { useTableParams } from '@/hooks/useTableParams'
16- import { fetchUserDocumentCounts } from '@/services/api'
17+ import { fetchAdminUserIds , fetchUserDocumentCounts , toggleAdminRole } from '@/services/api'
1718import { fetchUserNotificationSubscriptions , fetchUsers } from '@/services/supabase'
1819import type { User } from '@/types'
1920import { exportToCSV } from '@/utils/export'
@@ -30,9 +31,13 @@ type UserWithExtras = User & {
3031 notif_ios : boolean
3132 notif_android : boolean
3233 notif_email : boolean
34+ is_admin : boolean
3335}
3436
3537export default function UsersPage ( ) {
38+ const queryClient = useQueryClient ( )
39+ const { user : currentUser } = useAdminAuth ( )
40+
3641 // URL-synced table state
3742 const { page, search, sortKey, sortDirection, setPage, setSearch, handleSort } = useTableParams ( {
3843 defaultSortKey : 'created_at' ,
@@ -56,7 +61,27 @@ export default function UsersPage() {
5661 queryFn : fetchUserNotificationSubscriptions
5762 } )
5863
59- // Merge document counts and notification subs into user data
64+ // Fetch admin user IDs
65+ const { data : adminUsers } = useQuery ( {
66+ queryKey : [ 'admin' , 'users' , 'admin-ids' ] ,
67+ queryFn : fetchAdminUserIds
68+ } )
69+
70+ const adminIdSet = useMemo ( ( ) => new Set ( adminUsers ?. map ( ( a ) => a . user_id ) || [ ] ) , [ adminUsers ] )
71+
72+ // Toggle admin mutation
73+ const toggleAdminMutation = useMutation ( {
74+ mutationFn : toggleAdminRole ,
75+ onSuccess : ( result ) => {
76+ toast . success ( result . is_admin ? 'Admin role granted' : 'Admin role revoked' )
77+ queryClient . invalidateQueries ( { queryKey : [ 'admin' , 'users' , 'admin-ids' ] } )
78+ } ,
79+ onError : ( error : Error ) => {
80+ toast . error ( error . message || 'Failed to toggle admin role' )
81+ }
82+ } )
83+
84+ // Merge document counts, notification subs, and admin status into user data
6085 const usersWithExtras = useMemo ( ( ) => {
6186 if ( ! data ?. data ) return [ ]
6287 return data . data . map ( ( user ) => {
@@ -67,10 +92,11 @@ export default function UsersPage() {
6792 notif_web : subs ?. web ?? false ,
6893 notif_ios : subs ?. ios ?? false ,
6994 notif_android : subs ?. android ?? false ,
70- notif_email : subs ?. email ?? false
95+ notif_email : subs ?. email ?? false ,
96+ is_admin : adminIdSet . has ( user . id )
7197 }
7298 } )
73- } , [ data ?. data , docCounts , notifSubs ] )
99+ } , [ data ?. data , docCounts , notifSubs , adminIdSet ] )
74100
75101 // Real-time subscription to users table
76102 const handleRealtimeChange = useCallback ( ( ) => {
@@ -103,6 +129,43 @@ export default function UsersPage() {
103129 toast . success ( 'Exported users to CSV' )
104130 }
105131
132+ const handleToggleAdmin = useCallback (
133+ ( user : UserWithExtras ) => {
134+ const isSelf = user . id === currentUser ?. id
135+ if ( isSelf ) {
136+ toast . error ( 'Cannot change your own admin status' )
137+ return
138+ }
139+
140+ const action = user . is_admin ? 'revoke admin from' : 'grant admin to'
141+ toast (
142+ ( t ) => (
143+ < div className = "flex flex-col gap-2" >
144+ < p className = "text-sm font-medium" > { user . is_admin ? 'Revoke' : 'Grant' } admin role?</ p >
145+ < p className = "text-xs opacity-70" >
146+ This will { action } < strong > { user . username || user . email } </ strong > .
147+ </ p >
148+ < div className = "flex gap-2" >
149+ < button
150+ className = { `btn btn-xs ${ user . is_admin ? 'btn-warning' : 'btn-primary' } ` }
151+ onClick = { ( ) => {
152+ toggleAdminMutation . mutate ( user . id )
153+ toast . dismiss ( t . id )
154+ } } >
155+ Confirm
156+ </ button >
157+ < button className = "btn btn-ghost btn-xs" onClick = { ( ) => toast . dismiss ( t . id ) } >
158+ Cancel
159+ </ button >
160+ </ div >
161+ </ div >
162+ ) ,
163+ { duration : 10000 }
164+ )
165+ } ,
166+ [ currentUser ?. id , toggleAdminMutation ]
167+ )
168+
106169 const columns = [
107170 {
108171 key : 'username' ,
@@ -118,7 +181,15 @@ export default function UsersPage() {
118181 email = { user . email }
119182 size = "md"
120183 />
121- < span className = "font-medium" > { user . username || '-' } </ span >
184+ < div className = "flex items-center gap-2" >
185+ < span className = "font-medium" > { user . username || '-' } </ span >
186+ { user . is_admin && (
187+ < span className = "badge badge-primary badge-xs gap-1" >
188+ < LuShieldCheck className = "h-3 w-3" />
189+ Admin
190+ </ span >
191+ ) }
192+ </ div >
122193 </ div >
123194 )
124195 } ,
@@ -192,6 +263,32 @@ export default function UsersPage() {
192263 { user . online_at ? formatDateTime ( user . online_at ) : '-' }
193264 </ span >
194265 )
266+ } ,
267+ {
268+ key : 'actions' ,
269+ header : '' ,
270+ render : ( user : UserWithExtras ) => {
271+ const isSelf = user . id === currentUser ?. id
272+ return (
273+ < div
274+ className = "tooltip tooltip-left"
275+ data-tip = {
276+ isSelf ? 'Cannot change own role' : user . is_admin ? 'Revoke admin' : 'Make admin'
277+ } >
278+ < button
279+ type = "button"
280+ className = { `btn btn-ghost btn-sm btn-square ${ user . is_admin ? 'text-primary' : 'text-base-content/30 hover:text-primary' } ` }
281+ onClick = { ( ) => handleToggleAdmin ( user ) }
282+ disabled = { isSelf || toggleAdminMutation . isPending } >
283+ { user . is_admin ? (
284+ < LuShieldCheck className = "h-4 w-4" />
285+ ) : (
286+ < LuShieldOff className = "h-4 w-4" />
287+ ) }
288+ </ button >
289+ </ div >
290+ )
291+ }
195292 }
196293 ]
197294
0 commit comments