Skip to content

Commit e26c744

Browse files
committed
feat(admin): add toggle admin role action on Users page
- Role column shows admin badge for users in admin_users table - Actions column with Make Admin / Remove Admin toggle button - Self-demotion blocked (button disabled with tooltip) - Confirmation toast before role changes - Optimistic UI with react-query invalidation
1 parent d43bcd7 commit e26c744

1 file changed

Lines changed: 104 additions & 7 deletions

File tree

packages/admin-dashboard/src/pages/users.tsx

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1-
import { useQuery } from '@tanstack/react-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import type { GetServerSideProps } from 'next'
33
import Head from 'next/head'
44
import { useCallback, useMemo } from 'react'
55
import 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

88
import { AdminLayout } from '@/components/layout/AdminLayout'
99
import { Header } from '@/components/layout/Header'
1010
import { DataTable } from '@/components/tables/DataTable'
1111
import { Avatar } from '@/components/ui/Avatar'
1212
import { NotificationBadges } from '@/components/ui/NotificationBadges'
1313
import { SearchInput } from '@/components/ui/SearchInput'
14+
import { useAdminAuth } from '@/hooks/useAdminAuth'
1415
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'
1516
import { useTableParams } from '@/hooks/useTableParams'
16-
import { fetchUserDocumentCounts } from '@/services/api'
17+
import { fetchAdminUserIds, fetchUserDocumentCounts, toggleAdminRole } from '@/services/api'
1718
import { fetchUserNotificationSubscriptions, fetchUsers } from '@/services/supabase'
1819
import type { User } from '@/types'
1920
import { 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

3537
export 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

Comments
 (0)