banu4prasad commited on
Commit
496b9bf
·
1 Parent(s): 685db57

code refactor: AdminUsers page

Browse files
frontend/src/components/admin/ResetLinkDialog.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Copy from 'lucide-react/dist/esm/icons/copy'
2
+ import { Button } from '@/components/ui/button'
3
+ import { Input } from '@/components/ui/input'
4
+ import {
5
+ Dialog, DialogContent, DialogHeader, DialogTitle,
6
+ DialogDescription, DialogFooter, DialogClose,
7
+ } from '@/components/ui/dialog'
8
+
9
+ export default function ResetLinkDialog({ resetLink, onClose, onCopy }) {
10
+ return (
11
+ <Dialog open={!!resetLink} onOpenChange={(open) => !open && onClose()}>
12
+ <DialogContent className="sm:max-w-lg">
13
+ <DialogHeader>
14
+ <DialogTitle>Password Reset Link</DialogTitle>
15
+ <DialogDescription>{resetLink?.full_name} · {resetLink?.email}</DialogDescription>
16
+ </DialogHeader>
17
+
18
+ <div className="flex flex-col gap-2">
19
+ <label className="text-sm font-medium text-muted-foreground">Reset Link</label>
20
+ <Input
21
+ value={resetLink?.reset_url || ''}
22
+ readOnly
23
+ className="font-mono text-xs"
24
+ aria-label="Password reset link"
25
+ />
26
+ <p className="text-xs text-muted-foreground">
27
+ Expires {resetLink && new Date(resetLink.expires_at).toLocaleString('en-IN', {
28
+ day: '2-digit', month: 'short', year: 'numeric',
29
+ hour: '2-digit', minute: '2-digit',
30
+ })}
31
+ </p>
32
+ </div>
33
+
34
+ <DialogFooter>
35
+ <DialogClose asChild>
36
+ <Button variant="ghost">Close</Button>
37
+ </DialogClose>
38
+ <Button onClick={onCopy}>
39
+ <Copy size={15} /> Copy Link
40
+ </Button>
41
+ </DialogFooter>
42
+ </DialogContent>
43
+ </Dialog>
44
+ )
45
+ }
frontend/src/components/admin/UserMobileCard.jsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react'
2
+ import KeyRound from 'lucide-react/dist/esm/icons/key-round'
3
+ import UserX from 'lucide-react/dist/esm/icons/user-x'
4
+ import UserCheck from 'lucide-react/dist/esm/icons/user-check'
5
+ import Spinner from '../shared/Spinner'
6
+ import { Card, CardContent } from '@/components/ui/card'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Badge } from '@/components/ui/badge'
9
+ import { ROLE_STYLE } from './roleStyles'
10
+
11
+ export default memo(function UserMobileCard({ user: u, updating, onChangeRole, onToggleStatus, onCreateResetLink }) {
12
+ return (
13
+ <Card>
14
+ <CardContent className="flex flex-col gap-3 p-4">
15
+ <div className="flex items-center gap-3">
16
+ <div className="w-10 h-10 rounded-full bg-sky-500/20 border border-sky-500/20 flex items-center justify-center flex-shrink-0">
17
+ <span className="text-sky-400 text-sm font-semibold">{u.full_name[0]?.toUpperCase()}</span>
18
+ </div>
19
+ <div className="flex-1 min-w-0">
20
+ <p className="font-medium text-sm text-slate-900 dark:text-white truncate">{u.full_name}</p>
21
+ <p className="text-xs text-slate-500 dark:text-slate-400 truncate">{u.email}</p>
22
+ </div>
23
+ </div>
24
+
25
+ <div className="flex items-center gap-2 flex-wrap">
26
+ <Badge variant="outline" className={ROLE_STYLE[u.role]}>{u.role}</Badge>
27
+ <Badge variant="outline" className={u.is_active
28
+ ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
29
+ : 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'
30
+ }>
31
+ {u.is_active ? 'Active' : 'Disabled'}
32
+ </Badge>
33
+ <span className="text-xs text-slate-500 dark:text-slate-400">
34
+ Joined {new Date(u.created_at).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}
35
+ </span>
36
+ </div>
37
+
38
+ <div className="flex items-center gap-2 flex-wrap pt-2 border-t border-slate-200 dark:border-slate-800">
39
+ {updating[u.id] ? <Spinner size={14} className="text-sky-400" /> : (
40
+ <>
41
+ {u.role === 'user' && (
42
+ <Button size="xs" variant="ghost"
43
+ className="bg-green-500/10 border border-green-500/20 text-green-400 hover:bg-green-500/20"
44
+ onClick={() => onChangeRole(u.id, 'aspirant')}>
45
+ <UserCheck size={13} /> Approve
46
+ </Button>
47
+ )}
48
+ {u.role === 'aspirant' && (
49
+ <Button size="xs" variant="ghost"
50
+ className="bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
51
+ onClick={() => onChangeRole(u.id, 'user')}>
52
+ <UserX size={13} /> Revoke
53
+ </Button>
54
+ )}
55
+ {u.role !== 'admin' && (
56
+ <Button size="xs" variant="ghost"
57
+ className="bg-slate-200 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600/50 text-slate-500 dark:text-slate-400"
58
+ onClick={() => onToggleStatus(u.id)}>
59
+ {u.is_active ? 'Disable' : 'Enable'}
60
+ </Button>
61
+ )}
62
+ <Button size="xs" variant="ghost"
63
+ className="bg-sky-500/10 border border-sky-500/20 text-sky-400 hover:bg-sky-500/20"
64
+ disabled={updating[`reset-${u.id}`]}
65
+ onClick={() => onCreateResetLink(u.id)}>
66
+ {updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
67
+ </Button>
68
+ </>
69
+ )}
70
+ </div>
71
+ </CardContent>
72
+ </Card>
73
+ )
74
+ })
frontend/src/components/admin/UserRow.jsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react'
2
+ import KeyRound from 'lucide-react/dist/esm/icons/key-round'
3
+ import UserX from 'lucide-react/dist/esm/icons/user-x'
4
+ import UserCheck from 'lucide-react/dist/esm/icons/user-check'
5
+ import Spinner from '../shared/Spinner'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Badge } from '@/components/ui/badge'
8
+ import { TableRow, TableCell } from '@/components/ui/table'
9
+ import { ROLE_STYLE } from './roleStyles'
10
+
11
+ export default memo(function UserRow({ user: u, updating, onChangeRole, onToggleStatus, onCreateResetLink }) {
12
+ return (
13
+ <TableRow className="hover:bg-slate-100 dark:hover:bg-slate-800/30">
14
+ <TableCell className="px-5 py-4">
15
+ <div className="flex items-center gap-3">
16
+ <div className="w-8 h-8 rounded-full bg-sky-500/20 border border-sky-500/20 flex items-center justify-center flex-shrink-0">
17
+ <span className="text-sky-400 text-xs font-semibold">{u.full_name[0]?.toUpperCase()}</span>
18
+ </div>
19
+ <span className="font-medium text-slate-700 dark:text-slate-200">{u.full_name}</span>
20
+ </div>
21
+ </TableCell>
22
+ <TableCell className="px-5 py-4 text-slate-500 dark:text-slate-400">{u.email}</TableCell>
23
+ <TableCell className="px-5 py-4">
24
+ <Badge variant="outline" className={ROLE_STYLE[u.role]}>{u.role}</Badge>
25
+ </TableCell>
26
+ <TableCell className="px-5 py-4">
27
+ <Badge variant="outline" className={u.is_active
28
+ ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
29
+ : 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'
30
+ }>
31
+ {u.is_active ? 'Active' : 'Disabled'}
32
+ </Badge>
33
+ </TableCell>
34
+ <TableCell className="px-5 py-4 text-slate-500 dark:text-slate-400">
35
+ {new Date(u.created_at).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}
36
+ </TableCell>
37
+ <TableCell className="px-5 py-4">
38
+ <div className="flex items-center gap-2">
39
+ {updating[u.id] ? <Spinner size={14} className="text-sky-400" /> : (
40
+ <>
41
+ {u.role === 'user' && (
42
+ <Button size="xs" variant="ghost"
43
+ className="bg-green-500/10 border border-green-500/20 text-green-400 hover:bg-green-500/20"
44
+ onClick={() => onChangeRole(u.id, 'aspirant')}>
45
+ <UserCheck size={13} /> Approve
46
+ </Button>
47
+ )}
48
+ {u.role === 'aspirant' && (
49
+ <Button size="xs" variant="ghost"
50
+ className="bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
51
+ onClick={() => onChangeRole(u.id, 'user')}>
52
+ <UserX size={13} /> Revoke
53
+ </Button>
54
+ )}
55
+ {u.role !== 'admin' && (
56
+ <Button size="xs" variant="ghost"
57
+ className="bg-slate-200 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600/50 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700"
58
+ onClick={() => onToggleStatus(u.id)}>
59
+ {u.is_active ? 'Disable' : 'Enable'}
60
+ </Button>
61
+ )}
62
+ <Button size="xs" variant="ghost"
63
+ className="bg-sky-500/10 border border-sky-500/20 text-sky-400 hover:bg-sky-500/20"
64
+ disabled={updating[`reset-${u.id}`]}
65
+ onClick={() => onCreateResetLink(u.id)}>
66
+ {updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
67
+ </Button>
68
+ </>
69
+ )}
70
+ </div>
71
+ </TableCell>
72
+ </TableRow>
73
+ )
74
+ })
frontend/src/components/admin/UsersPagination.jsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent } from '@/components/ui/card'
2
+ import { Button } from '@/components/ui/button'
3
+
4
+ export default function UsersPagination({
5
+ pageIndex,
6
+ showingFrom,
7
+ showingTo,
8
+ totalUsers,
9
+ hasPagination,
10
+ hasMore,
11
+ onPrev,
12
+ onNext,
13
+ mobile = false,
14
+ }) {
15
+ if (!hasPagination) return null
16
+
17
+ const controls = (
18
+ <>
19
+ <Button variant="ghost" size="sm" onClick={onPrev} disabled={pageIndex === 0}>Previous</Button>
20
+ <span className="text-sm text-slate-500">Showing {showingFrom} – {showingTo} of {totalUsers}</span>
21
+ <Button variant="ghost" size="sm" onClick={onNext} disabled={!hasMore}>Next</Button>
22
+ </>
23
+ )
24
+
25
+ if (mobile) {
26
+ return (
27
+ <Card>
28
+ <CardContent className="flex justify-between items-center gap-3 p-3">
29
+ {controls}
30
+ </CardContent>
31
+ </Card>
32
+ )
33
+ }
34
+
35
+ return (
36
+ <div className="flex justify-between items-center px-5 py-4 border-t border-slate-200 dark:border-slate-800">
37
+ {controls}
38
+ </div>
39
+ )
40
+ }
frontend/src/components/admin/UsersTableSkeleton.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent } from '@/components/ui/card'
2
+ import { Skeleton } from '@/components/ui/skeleton'
3
+
4
+ export default function UsersTableSkeleton() {
5
+ return (
6
+ <Card>
7
+ <CardContent className="flex flex-col gap-4 p-4">
8
+ {[1, 2, 3, 4, 5].map(i => (
9
+ <div key={i} className="flex items-center gap-4 border-b border-slate-200 dark:border-slate-800 pb-4 last:border-0 last:pb-0">
10
+ <Skeleton className="size-8 rounded-full" />
11
+ <div className="flex flex-col flex-1 gap-2">
12
+ <Skeleton className="h-4 w-48" />
13
+ <Skeleton className="h-3 w-32" />
14
+ </div>
15
+ <Skeleton className="h-5 w-20 rounded-full hidden sm:block" />
16
+ <Skeleton className="h-5 w-24 rounded-full hidden sm:block" />
17
+ </div>
18
+ ))}
19
+ </CardContent>
20
+ </Card>
21
+ )
22
+ }
frontend/src/components/admin/roleStyles.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const ROLE_STYLE = {
2
+ admin: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20',
3
+ aspirant: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20',
4
+ user: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20',
5
+ }
frontend/src/hooks/useAdminUsers.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import useSWR from 'swr'
3
+ import toast from 'react-hot-toast'
4
+ import { adminAPI, fetcher } from '../api/api'
5
+
6
+ export default function useAdminUsers() {
7
+ const [pageIndex, setPageIndex] = useState(0)
8
+ const [pageCursors, setPageCursors] = useState([null])
9
+ const [searchInput, setSearchInput] = useState('')
10
+ const [searchQuery, setSearchQuery] = useState('')
11
+ const [updating, setUpdating] = useState({})
12
+ const [resetLink, setResetLink] = useState(null)
13
+
14
+ const limit = 50
15
+
16
+ useEffect(() => {
17
+ const timer = setTimeout(() => {
18
+ setSearchQuery(searchInput.trim())
19
+ setPageIndex(0)
20
+ setPageCursors([null])
21
+ }, 300)
22
+
23
+ return () => clearTimeout(timer)
24
+ }, [searchInput])
25
+
26
+ const currentCursor = pageCursors[pageIndex]
27
+ const params = new URLSearchParams({ limit: String(limit) })
28
+
29
+ if (currentCursor) params.set('cursor', currentCursor)
30
+ if (searchQuery) params.set('q', searchQuery)
31
+
32
+ const { data: usersData, isLoading: loading, mutate } = useSWR(`/admin/users?${params.toString()}`, fetcher)
33
+ const safeUsersData = usersData || {}
34
+ const users = usersData?.items || []
35
+ const usersById = useMemo(() => new Map(users.map(user => [user.id, user])), [users])
36
+ const totalUsers = usersData?.total || 0
37
+ const pendingUsers = usersData?.pending_count || 0
38
+ const aspirantsCount = usersData?.aspirants_count || 0
39
+ const hasPagination = totalUsers > limit
40
+ const hasMore = usersData?.has_more ?? false
41
+ const showingFrom = totalUsers === 0 ? 0 : pageIndex * limit + 1
42
+ const showingTo = Math.min(pageIndex * limit + users.length, totalUsers)
43
+
44
+ const reload = useCallback(() => mutate(), [mutate])
45
+
46
+ const changeRole = useCallback(async (userId, role) => {
47
+ const oldRole = usersById.get(userId)?.role
48
+ setUpdating(u => ({ ...u, [userId]: true }))
49
+ try {
50
+ await adminAPI.updateRole(userId, role)
51
+
52
+ let newPending = pendingUsers
53
+ let newAspirants = aspirantsCount
54
+ if (oldRole === 'user' && role === 'aspirant') {
55
+ newPending--
56
+ newAspirants++
57
+ }
58
+ if (oldRole === 'aspirant' && role === 'user') {
59
+ newPending++
60
+ newAspirants--
61
+ }
62
+
63
+ mutate({
64
+ ...safeUsersData,
65
+ items: users.map(u => (u.id === userId ? { ...u, role } : u)),
66
+ pending_count: newPending,
67
+ aspirants_count: newAspirants,
68
+ }, false)
69
+ toast.success(`Role updated to ${role}`)
70
+ } catch (err) {
71
+ toast.error(err.response?.data?.detail || 'Failed')
72
+ } finally {
73
+ setUpdating(u => ({ ...u, [userId]: false }))
74
+ }
75
+ }, [usersById, pendingUsers, aspirantsCount, usersData, users, mutate])
76
+
77
+ const toggleStatus = useCallback(async (userId) => {
78
+ setUpdating(u => ({ ...u, [userId]: true }))
79
+ try {
80
+ await adminAPI.toggleStatus(userId)
81
+ mutate({ ...safeUsersData, items: users.map(u => (u.id === userId ? { ...u, is_active: !u.is_active } : u)) }, false)
82
+ toast.success('Status updated')
83
+ } catch {
84
+ toast.error('Failed')
85
+ } finally {
86
+ setUpdating(u => ({ ...u, [userId]: false }))
87
+ }
88
+ }, [usersData, users, mutate])
89
+
90
+ const createResetLink = useCallback(async (userId) => {
91
+ const key = `reset-${userId}`
92
+ setUpdating(u => ({ ...u, [key]: true }))
93
+ try {
94
+ const res = await adminAPI.createPasswordReset(userId)
95
+ setResetLink(res.data)
96
+ toast.success('Reset link created')
97
+ } catch (err) {
98
+ toast.error(err.response?.data?.detail || 'Failed to create reset link')
99
+ } finally {
100
+ setUpdating(u => ({ ...u, [key]: false }))
101
+ }
102
+ }, [])
103
+
104
+ const copyResetLink = useCallback(async () => {
105
+ if (!resetLink?.reset_url) return
106
+
107
+ try {
108
+ await navigator.clipboard.writeText(resetLink.reset_url)
109
+ toast.success('Reset link copied')
110
+ } catch {
111
+ toast.error('Could not copy link')
112
+ }
113
+ }, [resetLink])
114
+
115
+ const goToPreviousPage = useCallback(() => setPageIndex(i => Math.max(0, i - 1)), [])
116
+ const goToNextPage = useCallback(() => {
117
+ if (!usersData?.next_cursor) return
118
+
119
+ setPageCursors(cursors => [
120
+ ...cursors.slice(0, pageIndex + 1),
121
+ usersData.next_cursor,
122
+ ])
123
+ setPageIndex(i => i + 1)
124
+ }, [usersData?.next_cursor, pageIndex])
125
+
126
+ return {
127
+ pageIndex,
128
+ users,
129
+ loading,
130
+ totalUsers,
131
+ pendingUsers,
132
+ searchInput,
133
+ setSearchInput,
134
+ updating,
135
+ resetLink,
136
+ setResetLink,
137
+ reload,
138
+ changeRole,
139
+ toggleStatus,
140
+ createResetLink,
141
+ copyResetLink,
142
+ goToPreviousPage,
143
+ goToNextPage,
144
+ showingFrom,
145
+ showingTo,
146
+ hasPagination,
147
+ hasMore,
148
+ }
149
+ }
frontend/src/pages/AdminUsers.jsx CHANGED
@@ -1,319 +1,59 @@
1
- import { useMemo, useState, useEffect, memo, useCallback } from 'react'
2
  import Layout from '../components/shared/Layout'
3
- import { adminAPI, fetcher } from '../api/api'
4
- import useSWR from 'swr'
5
- import toast from 'react-hot-toast'
6
- import Copy from 'lucide-react/dist/esm/icons/copy'
7
- import KeyRound from 'lucide-react/dist/esm/icons/key-round'
8
  import Search from 'lucide-react/dist/esm/icons/search'
9
- import UserX from 'lucide-react/dist/esm/icons/user-x'
10
- import UserCheck from 'lucide-react/dist/esm/icons/user-check'
11
  import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw'
12
- import Spinner from '../components/shared/Spinner'
13
- import { Card, CardContent } from '@/components/ui/card'
14
  import { Button } from '@/components/ui/button'
15
- import { Badge } from '@/components/ui/badge'
16
  import { Input } from '@/components/ui/input'
17
- import { Skeleton } from '@/components/ui/skeleton'
18
  import {
19
  Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
20
  } from '@/components/ui/table'
21
- import {
22
- Dialog, DialogContent, DialogHeader, DialogTitle,
23
- DialogDescription, DialogFooter, DialogClose,
24
- } from '@/components/ui/dialog'
25
-
26
- /* ─── constants ─── */
27
- const ROLE_STYLE = {
28
- admin: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20',
29
- aspirant: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20',
30
- user: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20',
31
- }
32
-
33
- /* ─── memoised sub-components (React perf best-practice) ─── */
34
-
35
- /** Desktop table row – only re-renders when its own user or updating state changes. */
36
- const UserRow = memo(function UserRow({ user: u, updating, onChangeRole, onToggleStatus, onCreateResetLink }) {
37
- return (
38
- <TableRow className="hover:bg-slate-100 dark:hover:bg-slate-800/30">
39
- <TableCell className="px-5 py-4">
40
- <div className="flex items-center gap-3">
41
- <div className="w-8 h-8 rounded-full bg-sky-500/20 border border-sky-500/20 flex items-center justify-center flex-shrink-0">
42
- <span className="text-sky-400 text-xs font-semibold">{u.full_name[0]?.toUpperCase()}</span>
43
- </div>
44
- <span className="font-medium text-slate-700 dark:text-slate-200">{u.full_name}</span>
45
- </div>
46
- </TableCell>
47
- <TableCell className="px-5 py-4 text-slate-500 dark:text-slate-400">{u.email}</TableCell>
48
- <TableCell className="px-5 py-4">
49
- <Badge variant="outline" className={ROLE_STYLE[u.role]}>{u.role}</Badge>
50
- </TableCell>
51
- <TableCell className="px-5 py-4">
52
- <Badge variant="outline" className={u.is_active
53
- ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
54
- : 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'
55
- }>
56
- {u.is_active ? 'Active' : 'Disabled'}
57
- </Badge>
58
- </TableCell>
59
- <TableCell className="px-5 py-4 text-slate-500 dark:text-slate-400">
60
- {new Date(u.created_at).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}
61
- </TableCell>
62
- <TableCell className="px-5 py-4">
63
- <div className="flex items-center gap-2">
64
- {updating[u.id] ? <Spinner size={14} className="text-sky-400" /> : (
65
- <>
66
- {u.role === 'user' && (
67
- <Button size="xs" variant="ghost"
68
- className="bg-green-500/10 border border-green-500/20 text-green-400 hover:bg-green-500/20"
69
- onClick={() => onChangeRole(u.id, 'aspirant')}>
70
- <UserCheck size={13} /> Approve
71
- </Button>
72
- )}
73
- {u.role === 'aspirant' && (
74
- <Button size="xs" variant="ghost"
75
- className="bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
76
- onClick={() => onChangeRole(u.id, 'user')}>
77
- <UserX size={13} /> Revoke
78
- </Button>
79
- )}
80
- {u.role !== 'admin' && (
81
- <Button size="xs" variant="ghost"
82
- className="bg-slate-200 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600/50 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700"
83
- onClick={() => onToggleStatus(u.id)}>
84
- {u.is_active ? 'Disable' : 'Enable'}
85
- </Button>
86
- )}
87
- <Button size="xs" variant="ghost"
88
- className="bg-sky-500/10 border border-sky-500/20 text-sky-400 hover:bg-sky-500/20"
89
- disabled={updating[`reset-${u.id}`]}
90
- onClick={() => onCreateResetLink(u.id)}>
91
- {updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
92
- </Button>
93
- </>
94
- )}
95
- </div>
96
- </TableCell>
97
- </TableRow>
98
- )
99
- })
100
-
101
- /** Mobile card view – only re-renders when its own user or updating state changes. */
102
- const UserMobileCard = memo(function UserMobileCard({ user: u, updating, onChangeRole, onToggleStatus, onCreateResetLink }) {
103
- return (
104
- <Card>
105
- <CardContent className="flex flex-col gap-3 p-4">
106
- {/* User info */}
107
- <div className="flex items-center gap-3">
108
- <div className="w-10 h-10 rounded-full bg-sky-500/20 border border-sky-500/20 flex items-center justify-center flex-shrink-0">
109
- <span className="text-sky-400 text-sm font-semibold">{u.full_name[0]?.toUpperCase()}</span>
110
- </div>
111
- <div className="flex-1 min-w-0">
112
- <p className="font-medium text-sm text-slate-900 dark:text-white truncate">{u.full_name}</p>
113
- <p className="text-xs text-slate-500 dark:text-slate-400 truncate">{u.email}</p>
114
- </div>
115
- </div>
116
-
117
- {/* Badges */}
118
- <div className="flex items-center gap-2 flex-wrap">
119
- <Badge variant="outline" className={ROLE_STYLE[u.role]}>{u.role}</Badge>
120
- <Badge variant="outline" className={u.is_active
121
- ? 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'
122
- : 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'
123
- }>
124
- {u.is_active ? 'Active' : 'Disabled'}
125
- </Badge>
126
- <span className="text-xs text-slate-500 dark:text-slate-400">
127
- Joined {new Date(u.created_at).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}
128
- </span>
129
- </div>
130
-
131
- {/* Actions */}
132
- <div className="flex items-center gap-2 flex-wrap pt-2 border-t border-slate-200 dark:border-slate-800">
133
- {updating[u.id] ? <Spinner size={14} className="text-sky-400" /> : (
134
- <>
135
- {u.role === 'user' && (
136
- <Button size="xs" variant="ghost"
137
- className="bg-green-500/10 border border-green-500/20 text-green-400 hover:bg-green-500/20"
138
- onClick={() => onChangeRole(u.id, 'aspirant')}>
139
- <UserCheck size={13} /> Approve
140
- </Button>
141
- )}
142
- {u.role === 'aspirant' && (
143
- <Button size="xs" variant="ghost"
144
- className="bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
145
- onClick={() => onChangeRole(u.id, 'user')}>
146
- <UserX size={13} /> Revoke
147
- </Button>
148
- )}
149
- {u.role !== 'admin' && (
150
- <Button size="xs" variant="ghost"
151
- className="bg-slate-200 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600/50 text-slate-500 dark:text-slate-400"
152
- onClick={() => onToggleStatus(u.id)}>
153
- {u.is_active ? 'Disable' : 'Enable'}
154
- </Button>
155
- )}
156
- <Button size="xs" variant="ghost"
157
- className="bg-sky-500/10 border border-sky-500/20 text-sky-400 hover:bg-sky-500/20"
158
- disabled={updating[`reset-${u.id}`]}
159
- onClick={() => onCreateResetLink(u.id)}>
160
- {updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
161
- </Button>
162
- </>
163
- )}
164
- </div>
165
- </CardContent>
166
- </Card>
167
- )
168
- })
169
 
170
- /* ─── loading skeleton ─── */
171
- function UsersTableSkeleton() {
172
- return (
173
- <Card>
174
- <CardContent className="flex flex-col gap-4 p-4">
175
- {[1, 2, 3, 4, 5].map(i => (
176
- <div key={i} className="flex items-center gap-4 border-b border-slate-200 dark:border-slate-800 pb-4 last:border-0 last:pb-0">
177
- <Skeleton className="size-8 rounded-full" />
178
- <div className="flex flex-col flex-1 gap-2">
179
- <Skeleton className="h-4 w-48" />
180
- <Skeleton className="h-3 w-32" />
181
- </div>
182
- <Skeleton className="h-5 w-20 rounded-full hidden sm:block" />
183
- <Skeleton className="h-5 w-24 rounded-full hidden sm:block" />
184
- </div>
185
- ))}
186
- </CardContent>
187
- </Card>
188
- )
189
- }
190
 
191
- /* ─── main component ─── */
192
  export default function AdminUsers() {
193
- const [pageIndex, setPageIndex] = useState(0)
194
- const [pageCursors, setPageCursors] = useState([null])
195
- const [searchInput, setSearchInput] = useState('')
196
- const [searchQuery, setSearchQuery] = useState('')
197
- const limit = 50
198
-
199
- useEffect(() => {
200
- const timer = setTimeout(() => {
201
- setSearchQuery(searchInput.trim())
202
- setPageIndex(0)
203
- setPageCursors([null])
204
- }, 300)
205
- return () => clearTimeout(timer)
206
- }, [searchInput])
207
-
208
- const currentCursor = pageCursors[pageIndex]
209
- const params = new URLSearchParams({ limit: String(limit) })
210
- if (currentCursor) params.set('cursor', currentCursor)
211
- if (searchQuery) params.set('q', searchQuery)
212
-
213
- const { data: usersData, isLoading: loading, mutate } = useSWR(`/admin/users?${params.toString()}`, fetcher)
214
- const users = usersData?.items || []
215
- const usersById = useMemo(() => new Map(users.map(user => [user.id, user])), [users])
216
- const totalUsers = usersData?.total || 0
217
- const pendingUsers = usersData?.pending_count || 0
218
- const aspirantsCount = usersData?.aspirants_count || 0
219
-
220
- const [updating, setUpdating] = useState({})
221
- const [resetLink, setResetLink] = useState(null)
222
-
223
- const load = useCallback(() => mutate(), [mutate])
224
-
225
- const changeRole = useCallback(async (userId, role) => {
226
- const oldRole = usersById.get(userId)?.role
227
- setUpdating(u => ({ ...u, [userId]: true }))
228
- try {
229
- await adminAPI.updateRole(userId, role)
230
-
231
- let newPending = pendingUsers
232
- let newAspirants = aspirantsCount
233
- if (oldRole === 'user' && role === 'aspirant') { newPending--; newAspirants++; }
234
- if (oldRole === 'aspirant' && role === 'user') { newPending++; newAspirants--; }
235
-
236
- mutate({
237
- ...usersData,
238
- items: users.map(u => u.id === userId ? { ...u, role } : u),
239
- pending_count: newPending,
240
- aspirants_count: newAspirants
241
- }, false)
242
- toast.success(`Role updated to ${role}`)
243
- } catch (err) {
244
- toast.error(err.response?.data?.detail || 'Failed')
245
- } finally {
246
- setUpdating(u => ({ ...u, [userId]: false }))
247
- }
248
- }, [usersById, pendingUsers, aspirantsCount, usersData, users, mutate])
249
-
250
- const toggleStatus = useCallback(async (userId) => {
251
- setUpdating(u => ({ ...u, [userId]: true }))
252
- try {
253
- await adminAPI.toggleStatus(userId)
254
- mutate({ ...usersData, items: users.map(u => u.id === userId ? { ...u, is_active: !u.is_active } : u) }, false)
255
- toast.success('Status updated')
256
- } catch {
257
- toast.error('Failed')
258
- } finally {
259
- setUpdating(u => ({ ...u, [userId]: false }))
260
- }
261
- }, [usersData, users, mutate])
262
-
263
- const createResetLink = useCallback(async (userId) => {
264
- const key = `reset-${userId}`
265
- setUpdating(u => ({ ...u, [key]: true }))
266
- try {
267
- const res = await adminAPI.createPasswordReset(userId)
268
- setResetLink(res.data)
269
- toast.success('Reset link created')
270
- } catch (err) {
271
- toast.error(err.response?.data?.detail || 'Failed to create reset link')
272
- } finally {
273
- setUpdating(u => ({ ...u, [key]: false }))
274
- }
275
- }, [])
276
-
277
- const copyResetLink = useCallback(async () => {
278
- if (!resetLink?.reset_url) return
279
- try {
280
- await navigator.clipboard.writeText(resetLink.reset_url)
281
- toast.success('Reset link copied')
282
- } catch {
283
- toast.error('Could not copy link')
284
- }
285
- }, [resetLink])
286
-
287
- const goToPreviousPage = () => setPageIndex(i => Math.max(0, i - 1))
288
- const goToNextPage = () => {
289
- if (!usersData?.next_cursor) return
290
- setPageCursors(cursors => [
291
- ...cursors.slice(0, pageIndex + 1),
292
- usersData.next_cursor,
293
- ])
294
- setPageIndex(i => i + 1)
295
- }
296
- const showingFrom = totalUsers === 0 ? 0 : pageIndex * limit + 1
297
- const showingTo = Math.min(pageIndex * limit + users.length, totalUsers)
298
- const hasPagination = totalUsers > limit
299
-
300
- const TABLE_HEADERS = ['Name', 'Email', 'Role', 'Status', 'Joined', 'Actions']
301
 
302
  return (
303
  <Layout>
304
  <div className="flex flex-col gap-6 animate-fade-in">
305
- {/* Header */}
306
  <div className="flex items-center justify-between">
307
  <div>
308
  <h1 className="text-2xl font-bold text-slate-900 dark:text-white">Users</h1>
309
  <p className="text-slate-500 dark:text-slate-400 mt-1">{totalUsers} registered · {pendingUsers} pending approval</p>
310
  </div>
311
- <Button variant="ghost" size="sm" onClick={load}>
312
  <RefreshCw size={15} /> Refresh
313
  </Button>
314
  </div>
315
 
316
- {/* Search */}
317
  <div className="relative">
318
  <Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 dark:text-slate-400 pointer-events-none" />
319
  <Input
@@ -328,7 +68,6 @@ export default function AdminUsers() {
328
  <UsersTableSkeleton />
329
  ) : (
330
  <>
331
- {/* Desktop table */}
332
  <Card className="overflow-hidden hidden md:block">
333
  <Table>
334
  <TableHeader>
@@ -357,16 +96,18 @@ export default function AdminUsers() {
357
  </TableBody>
358
  </Table>
359
 
360
- {hasPagination && (
361
- <div className="flex justify-between items-center px-5 py-4 border-t border-slate-200 dark:border-slate-800">
362
- <Button variant="ghost" size="sm" onClick={goToPreviousPage} disabled={pageIndex === 0}>Previous</Button>
363
- <span className="text-sm text-slate-500">Showing {showingFrom} – {showingTo} of {totalUsers}</span>
364
- <Button variant="ghost" size="sm" onClick={goToNextPage} disabled={!usersData?.has_more}>Next</Button>
365
- </div>
366
- )}
 
 
 
367
  </Card>
368
 
369
- {/* Mobile card view */}
370
  <div className="flex flex-col gap-3 md:hidden">
371
  {users.map(u => (
372
  <UserMobileCard
@@ -381,53 +122,26 @@ export default function AdminUsers() {
381
  {users.length === 0 && (
382
  <p className="text-center py-12 text-muted-foreground">No users found</p>
383
  )}
384
- {hasPagination && (
385
- <Card>
386
- <CardContent className="flex justify-between items-center gap-3 p-3">
387
- <Button variant="ghost" size="sm" onClick={goToPreviousPage} disabled={pageIndex === 0}>Previous</Button>
388
- <span className="text-sm text-slate-500">Showing {showingFrom} – {showingTo} of {totalUsers}</span>
389
- <Button variant="ghost" size="sm" onClick={goToNextPage} disabled={!usersData?.has_more}>Next</Button>
390
- </CardContent>
391
- </Card>
392
- )}
 
 
393
  </div>
394
  </>
395
  )}
396
 
397
- {/* Password Reset Dialog */}
398
- <Dialog open={!!resetLink} onOpenChange={(open) => !open && setResetLink(null)}>
399
- <DialogContent className="sm:max-w-lg">
400
- <DialogHeader>
401
- <DialogTitle>Password Reset Link</DialogTitle>
402
- <DialogDescription>{resetLink?.full_name} · {resetLink?.email}</DialogDescription>
403
- </DialogHeader>
404
-
405
- <div className="flex flex-col gap-2">
406
- <label className="text-sm font-medium text-muted-foreground">Reset Link</label>
407
- <Input
408
- value={resetLink?.reset_url || ''}
409
- readOnly
410
- className="font-mono text-xs"
411
- aria-label="Password reset link"
412
- />
413
- <p className="text-xs text-muted-foreground">
414
- Expires {resetLink && new Date(resetLink.expires_at).toLocaleString('en-IN', {
415
- day: '2-digit', month: 'short', year: 'numeric',
416
- hour: '2-digit', minute: '2-digit',
417
- })}
418
- </p>
419
- </div>
420
-
421
- <DialogFooter>
422
- <DialogClose asChild>
423
- <Button variant="ghost">Close</Button>
424
- </DialogClose>
425
- <Button onClick={copyResetLink}>
426
- <Copy size={15} /> Copy Link
427
- </Button>
428
- </DialogFooter>
429
- </DialogContent>
430
- </Dialog>
431
  </div>
432
  </Layout>
433
  )
 
 
1
  import Layout from '../components/shared/Layout'
 
 
 
 
 
2
  import Search from 'lucide-react/dist/esm/icons/search'
 
 
3
  import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw'
4
+ import { Card } from '@/components/ui/card'
 
5
  import { Button } from '@/components/ui/button'
 
6
  import { Input } from '@/components/ui/input'
 
7
  import {
8
  Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
9
  } from '@/components/ui/table'
10
+ import useAdminUsers from '../hooks/useAdminUsers'
11
+ import UserRow from '../components/admin/UserRow'
12
+ import UserMobileCard from '../components/admin/UserMobileCard'
13
+ import UsersTableSkeleton from '../components/admin/UsersTableSkeleton'
14
+ import UsersPagination from '../components/admin/UsersPagination'
15
+ import ResetLinkDialog from '../components/admin/ResetLinkDialog'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
+ const TABLE_HEADERS = ['Name', 'Email', 'Role', 'Status', 'Joined', 'Actions']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
 
19
  export default function AdminUsers() {
20
+ const {
21
+ pageIndex,
22
+ users,
23
+ loading,
24
+ totalUsers,
25
+ pendingUsers,
26
+ searchInput,
27
+ setSearchInput,
28
+ updating,
29
+ resetLink,
30
+ setResetLink,
31
+ reload,
32
+ changeRole,
33
+ toggleStatus,
34
+ createResetLink,
35
+ copyResetLink,
36
+ goToPreviousPage,
37
+ goToNextPage,
38
+ showingFrom,
39
+ showingTo,
40
+ hasPagination,
41
+ hasMore,
42
+ } = useAdminUsers()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  return (
45
  <Layout>
46
  <div className="flex flex-col gap-6 animate-fade-in">
 
47
  <div className="flex items-center justify-between">
48
  <div>
49
  <h1 className="text-2xl font-bold text-slate-900 dark:text-white">Users</h1>
50
  <p className="text-slate-500 dark:text-slate-400 mt-1">{totalUsers} registered · {pendingUsers} pending approval</p>
51
  </div>
52
+ <Button variant="ghost" size="sm" onClick={reload}>
53
  <RefreshCw size={15} /> Refresh
54
  </Button>
55
  </div>
56
 
 
57
  <div className="relative">
58
  <Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 dark:text-slate-400 pointer-events-none" />
59
  <Input
 
68
  <UsersTableSkeleton />
69
  ) : (
70
  <>
 
71
  <Card className="overflow-hidden hidden md:block">
72
  <Table>
73
  <TableHeader>
 
96
  </TableBody>
97
  </Table>
98
 
99
+ <UsersPagination
100
+ pageIndex={pageIndex}
101
+ showingFrom={showingFrom}
102
+ showingTo={showingTo}
103
+ totalUsers={totalUsers}
104
+ hasPagination={hasPagination}
105
+ hasMore={hasMore}
106
+ onPrev={goToPreviousPage}
107
+ onNext={goToNextPage}
108
+ />
109
  </Card>
110
 
 
111
  <div className="flex flex-col gap-3 md:hidden">
112
  {users.map(u => (
113
  <UserMobileCard
 
122
  {users.length === 0 && (
123
  <p className="text-center py-12 text-muted-foreground">No users found</p>
124
  )}
125
+ <UsersPagination
126
+ mobile
127
+ pageIndex={pageIndex}
128
+ showingFrom={showingFrom}
129
+ showingTo={showingTo}
130
+ totalUsers={totalUsers}
131
+ hasPagination={hasPagination}
132
+ hasMore={hasMore}
133
+ onPrev={goToPreviousPage}
134
+ onNext={goToNextPage}
135
+ />
136
  </div>
137
  </>
138
  )}
139
 
140
+ <ResetLinkDialog
141
+ resetLink={resetLink}
142
+ onClose={() => setResetLink(null)}
143
+ onCopy={copyResetLink}
144
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  </div>
146
  </Layout>
147
  )