@xk2800/nextjs-template 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/README.md +339 -0
  2. package/components/auth/authCard.tsx +92 -0
  3. package/components/auth/authShell.tsx +46 -0
  4. package/components/auth/deviceCheck.tsx +52 -0
  5. package/components/auth/emailPasswordLogin.tsx +136 -0
  6. package/components/auth/emailPasswordSignup.tsx +178 -0
  7. package/components/auth/forgotPasswordForm.tsx +66 -0
  8. package/components/auth/loginErrorRecovery.tsx +117 -0
  9. package/components/auth/logoutButtons.tsx +26 -0
  10. package/components/auth/oneTap.tsx +50 -0
  11. package/components/auth/passkeyLogin.tsx +50 -0
  12. package/components/auth/resetPasswordForm.tsx +112 -0
  13. package/components/auth/socialLogin.tsx +51 -0
  14. package/components/auth/twoFactorVerifyForm.tsx +100 -0
  15. package/components/dashboard/accountDetailsCard.tsx +58 -0
  16. package/components/dashboard/activityLogsCard.tsx +141 -0
  17. package/components/dashboard/activityLogsCardSkeleton.tsx +20 -0
  18. package/components/dashboard/activityLogsSection.tsx +16 -0
  19. package/components/dashboard/admin/activityLogsSection.tsx +13 -0
  20. package/components/dashboard/admin/adminActivityLogsSkeleton.tsx +34 -0
  21. package/components/dashboard/admin/adminActivityLogsTable.tsx +319 -0
  22. package/components/dashboard/admin/adminStatsGrid.tsx +30 -0
  23. package/components/dashboard/admin/adminStatsGridSkeleton.tsx +16 -0
  24. package/components/dashboard/admin/adminUserActivityLogCard.tsx +53 -0
  25. package/components/dashboard/admin/adminUserActivityLogTable.tsx +69 -0
  26. package/components/dashboard/admin/adminUserLoginInfoCard.tsx +50 -0
  27. package/components/dashboard/admin/adminUserLoginInfoTable.tsx +63 -0
  28. package/components/dashboard/admin/adminUserSessionsCard.tsx +225 -0
  29. package/components/dashboard/admin/adminUserStatusCard.tsx +57 -0
  30. package/components/dashboard/admin/adminUsersSkeleton.tsx +30 -0
  31. package/components/dashboard/admin/adminUsersTable.tsx +596 -0
  32. package/components/dashboard/admin/deviceThrottleCard.tsx +59 -0
  33. package/components/dashboard/admin/deviceThrottleTable.tsx +93 -0
  34. package/components/dashboard/admin/impersonationLogCard.tsx +73 -0
  35. package/components/dashboard/admin/newDeviceCard.tsx +35 -0
  36. package/components/dashboard/admin/newDeviceTable.tsx +71 -0
  37. package/components/dashboard/admin/revokeAllSessionsButton.tsx +77 -0
  38. package/components/dashboard/admin/sharedDeviceCard.tsx +42 -0
  39. package/components/dashboard/admin/sharedDeviceTable.tsx +75 -0
  40. package/components/dashboard/admin/systemSettingsForm.tsx +200 -0
  41. package/components/dashboard/admin/systemSettingsSection.tsx +9 -0
  42. package/components/dashboard/admin/systemSettingsSkeleton.tsx +29 -0
  43. package/components/dashboard/admin/usersSection.tsx +13 -0
  44. package/components/dashboard/adminSection.tsx +31 -0
  45. package/components/dashboard/appSidebar.tsx +170 -0
  46. package/components/dashboard/dashboardHeader.tsx +85 -0
  47. package/components/dashboard/heartbeat.tsx +22 -0
  48. package/components/dashboard/impersonationBanner.tsx +50 -0
  49. package/components/dashboard/passkeysCard.tsx +187 -0
  50. package/components/dashboard/profileCard.tsx +42 -0
  51. package/components/dashboard/sectionErrorFallback.tsx +18 -0
  52. package/components/dashboard/sessionsCard.tsx +194 -0
  53. package/components/dashboard/sessionsCardSkeleton.tsx +20 -0
  54. package/components/dashboard/sessionsSection.tsx +22 -0
  55. package/components/dashboard/twoFactorCard.tsx +344 -0
  56. package/components/dashboard/verifyEmailButton.tsx +32 -0
  57. package/components/email/email-template-2.tsx +44 -0
  58. package/components/email/email-template-new-device.tsx +80 -0
  59. package/components/email/email-template-passkey-change.tsx +81 -0
  60. package/components/email/email-template-reset-password.tsx +56 -0
  61. package/components/email/email-template-verify-email.tsx +55 -0
  62. package/components/email/email-template-welcome.tsx +53 -0
  63. package/components/email/email-template.tsx +33 -0
  64. package/components/email/send-email-button.tsx +61 -0
  65. package/components/providers/theme-provider.tsx +9 -0
  66. package/components/theme/theme-toggle.tsx +53 -0
  67. package/components/ui/alert-dialog.tsx +141 -0
  68. package/components/ui/avatar.tsx +53 -0
  69. package/components/ui/badge.tsx +46 -0
  70. package/components/ui/button.tsx +64 -0
  71. package/components/ui/card.tsx +92 -0
  72. package/components/ui/checkbox.tsx +32 -0
  73. package/components/ui/data-table.tsx +302 -0
  74. package/components/ui/dialog.tsx +143 -0
  75. package/components/ui/dropdown-menu.tsx +203 -0
  76. package/components/ui/input.tsx +21 -0
  77. package/components/ui/label.tsx +26 -0
  78. package/components/ui/password-input.tsx +38 -0
  79. package/components/ui/separator.tsx +28 -0
  80. package/components/ui/sheet.tsx +143 -0
  81. package/components/ui/sidebar.tsx +726 -0
  82. package/components/ui/skeleton.tsx +13 -0
  83. package/components/ui/switch.tsx +35 -0
  84. package/components/ui/table.tsx +116 -0
  85. package/components/ui/textarea.tsx +18 -0
  86. package/components/ui/tooltip.tsx +57 -0
  87. package/dist/activity/logger.d.mts +18 -0
  88. package/dist/activity/logger.d.ts +18 -0
  89. package/dist/activity/logger.js +289 -0
  90. package/dist/activity/logger.js.map +1 -0
  91. package/dist/activity/logger.mjs +7 -0
  92. package/dist/activity/logger.mjs.map +1 -0
  93. package/dist/activity/queries.d.mts +98 -0
  94. package/dist/activity/queries.d.ts +98 -0
  95. package/dist/activity/queries.js +372 -0
  96. package/dist/activity/queries.js.map +1 -0
  97. package/dist/activity/queries.mjs +124 -0
  98. package/dist/activity/queries.mjs.map +1 -0
  99. package/dist/admin/queries.d.mts +68 -0
  100. package/dist/admin/queries.d.ts +68 -0
  101. package/dist/admin/queries.js +450 -0
  102. package/dist/admin/queries.js.map +1 -0
  103. package/dist/admin/queries.mjs +143 -0
  104. package/dist/admin/queries.mjs.map +1 -0
  105. package/dist/auth/helpers.d.mts +111 -0
  106. package/dist/auth/helpers.d.ts +111 -0
  107. package/dist/auth/helpers.js +1023 -0
  108. package/dist/auth/helpers.js.map +1 -0
  109. package/dist/auth/helpers.mjs +67 -0
  110. package/dist/auth/helpers.mjs.map +1 -0
  111. package/dist/auth/index.d.mts +6589 -0
  112. package/dist/auth/index.d.ts +6589 -0
  113. package/dist/auth/index.js +968 -0
  114. package/dist/auth/index.js.map +1 -0
  115. package/dist/auth/index.mjs +10 -0
  116. package/dist/auth/index.mjs.map +1 -0
  117. package/dist/auth-client/index.d.mts +4864 -0
  118. package/dist/auth-client/index.d.ts +4864 -0
  119. package/dist/auth-client/index.js +55 -0
  120. package/dist/auth-client/index.js.map +1 -0
  121. package/dist/auth-client/index.mjs +49 -0
  122. package/dist/auth-client/index.mjs.map +1 -0
  123. package/dist/chunk-2FW7C3KS.mjs +556 -0
  124. package/dist/chunk-2FW7C3KS.mjs.map +1 -0
  125. package/dist/chunk-ENGYO4K7.mjs +189 -0
  126. package/dist/chunk-ENGYO4K7.mjs.map +1 -0
  127. package/dist/chunk-GTPTVNY2.mjs +40 -0
  128. package/dist/chunk-GTPTVNY2.mjs.map +1 -0
  129. package/dist/chunk-HYKUB3A2.mjs +30 -0
  130. package/dist/chunk-HYKUB3A2.mjs.map +1 -0
  131. package/dist/chunk-JIYHBWAU.mjs +79 -0
  132. package/dist/chunk-JIYHBWAU.mjs.map +1 -0
  133. package/dist/chunk-K6OJCSJS.mjs +41 -0
  134. package/dist/chunk-K6OJCSJS.mjs.map +1 -0
  135. package/dist/chunk-N6ECRCHY.mjs +61 -0
  136. package/dist/chunk-N6ECRCHY.mjs.map +1 -0
  137. package/dist/chunk-YN4436PK.mjs +33 -0
  138. package/dist/chunk-YN4436PK.mjs.map +1 -0
  139. package/dist/config/env.d.mts +15 -0
  140. package/dist/config/env.d.ts +15 -0
  141. package/dist/config/env.js +42 -0
  142. package/dist/config/env.js.map +1 -0
  143. package/dist/config/env.mjs +4 -0
  144. package/dist/config/env.mjs.map +1 -0
  145. package/dist/db/index.d.mts +10 -0
  146. package/dist/db/index.d.ts +10 -0
  147. package/dist/db/index.js +252 -0
  148. package/dist/db/index.js.map +1 -0
  149. package/dist/db/index.mjs +6 -0
  150. package/dist/db/index.mjs.map +1 -0
  151. package/dist/db/schema.d.mts +2 -0
  152. package/dist/db/schema.d.ts +2 -0
  153. package/dist/db/schema.js +186 -0
  154. package/dist/db/schema.js.map +1 -0
  155. package/dist/db/schema.mjs +4 -0
  156. package/dist/db/schema.mjs.map +1 -0
  157. package/dist/schema-CgkDv4wJ.d.mts +1758 -0
  158. package/dist/schema-CgkDv4wJ.d.ts +1758 -0
  159. package/dist/sessions/queries.d.mts +34 -0
  160. package/dist/sessions/queries.d.ts +34 -0
  161. package/dist/sessions/queries.js +268 -0
  162. package/dist/sessions/queries.js.map +1 -0
  163. package/dist/sessions/queries.mjs +23 -0
  164. package/dist/sessions/queries.mjs.map +1 -0
  165. package/dist/settings/queries.d.mts +20 -0
  166. package/dist/settings/queries.d.ts +20 -0
  167. package/dist/settings/queries.js +344 -0
  168. package/dist/settings/queries.js.map +1 -0
  169. package/dist/settings/queries.mjs +7 -0
  170. package/dist/settings/queries.mjs.map +1 -0
  171. package/dist/types/auth/loginSchema.d.mts +8 -0
  172. package/dist/types/auth/loginSchema.d.ts +8 -0
  173. package/dist/types/auth/loginSchema.js +18 -0
  174. package/dist/types/auth/loginSchema.js.map +1 -0
  175. package/dist/types/auth/loginSchema.mjs +16 -0
  176. package/dist/types/auth/loginSchema.mjs.map +1 -0
  177. package/dist/types/auth/signupSchema.d.mts +9 -0
  178. package/dist/types/auth/signupSchema.d.ts +9 -0
  179. package/dist/types/auth/signupSchema.js +21 -0
  180. package/dist/types/auth/signupSchema.js.map +1 -0
  181. package/dist/types/auth/signupSchema.mjs +19 -0
  182. package/dist/types/auth/signupSchema.mjs.map +1 -0
  183. package/dist/users/queries.d.mts +71 -0
  184. package/dist/users/queries.d.ts +71 -0
  185. package/dist/users/queries.js +293 -0
  186. package/dist/users/queries.js.map +1 -0
  187. package/dist/users/queries.mjs +47 -0
  188. package/dist/users/queries.mjs.map +1 -0
  189. package/lib/auth-client.ts +58 -0
  190. package/lib/cookie-names.ts +13 -0
  191. package/lib/device-fingerprint.ts +21 -0
  192. package/lib/formatters.ts +53 -0
  193. package/lib/recalculate-columns.ts +75 -0
  194. package/lib/request-info.ts +78 -0
  195. package/lib/utils.ts +6 -0
  196. package/package.json +301 -0
  197. package/styles/theme.css +124 -0
  198. package/types/auth/loginSchema.ts +11 -0
  199. package/types/auth/signupSchema.ts +14 -0
@@ -0,0 +1,596 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef } from 'react'
4
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../ui/card'
5
+ import { Button } from '../../ui/button'
6
+ import { Input } from '../../ui/input'
7
+ import { Badge } from '../../ui/badge'
8
+ import { Skeleton } from '../../ui/skeleton'
9
+ import { Checkbox } from '../../ui/checkbox'
10
+ import { DataTable, DataTablePagination, type Column } from '../../ui/data-table'
11
+ import {
12
+ AlertDialog,
13
+ AlertDialogAction,
14
+ AlertDialogCancel,
15
+ AlertDialogContent,
16
+ AlertDialogDescription,
17
+ AlertDialogFooter,
18
+ AlertDialogHeader,
19
+ AlertDialogTitle,
20
+ } from '../../ui/alert-dialog'
21
+ import { toast } from 'sonner'
22
+ import { useRouter } from 'next/navigation'
23
+ import Link from 'next/link'
24
+ import { formatDate, formatDateTime } from '@xk2800/nextjs-template/lib/formatters'
25
+ import { Download } from 'lucide-react'
26
+ import { authClient, useSession } from '@xk2800/nextjs-template/auth-client'
27
+
28
+ interface User {
29
+ id: string
30
+ name: string | null
31
+ email: string
32
+ role: string
33
+ banned: boolean
34
+ bannedAt: Date | null
35
+ bannedReason: string | null
36
+ lastLoginAt: Date | null
37
+ lastActiveAt: Date | null
38
+ createdAt: Date
39
+ }
40
+
41
+ // A user counts as "online" if their heartbeat (sent every 30s while a
42
+ // dashboard tab is open, see components/dashboard/heartbeat.tsx) landed
43
+ // within this window.
44
+ const ONLINE_THRESHOLD_MS = 2 * 60 * 1000
45
+
46
+ function isOnline(lastActiveAt: Date | null): boolean {
47
+ if (!lastActiveAt) return false
48
+ return Date.now() - new Date(lastActiveAt).getTime() < ONLINE_THRESHOLD_MS
49
+ }
50
+
51
+ interface PaginationData {
52
+ page: number
53
+ limit: number
54
+ total: number
55
+ pages: number
56
+ }
57
+
58
+ interface AdminUsersTableProps {
59
+ initialUsers: User[]
60
+ initialPagination: PaginationData
61
+ }
62
+
63
+ export default function AdminUsersTable({ initialUsers, initialPagination }: AdminUsersTableProps) {
64
+ const router = useRouter()
65
+ const { data: currentSession } = useSession()
66
+ const [users, setUsers] = useState<User[]>(initialUsers)
67
+ const [loading, setLoading] = useState(false)
68
+ const [search, setSearch] = useState('')
69
+ const [page, setPage] = useState(1)
70
+ const [pagination, setPagination] = useState<PaginationData | null>(initialPagination)
71
+ const [actionUserId, setActionUserId] = useState<string | null>(null)
72
+ const [actionType, setActionType] = useState<'delete' | 'ban' | 'unban' | 'promote' | 'demote' | 'impersonate' | null>(null)
73
+ const [isProcessing, setIsProcessing] = useState(false)
74
+ const [isExporting, setIsExporting] = useState(false)
75
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
76
+ const [bulkActionType, setBulkActionType] = useState<'ban' | 'delete' | null>(null)
77
+ const [isBulkProcessing, setIsBulkProcessing] = useState(false)
78
+
79
+ const currentUserId = currentSession?.user?.id
80
+ const selectableIds = users.filter((u) => u.id !== currentUserId).map((u) => u.id)
81
+ const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id))
82
+ const someSelected = selectedIds.size > 0 && !allSelected
83
+
84
+ const toggleSelectAll = () => {
85
+ setSelectedIds(allSelected ? new Set() : new Set(selectableIds))
86
+ }
87
+
88
+ const toggleSelectOne = (id: string) => {
89
+ setSelectedIds((prev) => {
90
+ const next = new Set(prev)
91
+ if (next.has(id)) {
92
+ next.delete(id)
93
+ } else {
94
+ next.add(id)
95
+ }
96
+ return next
97
+ })
98
+ }
99
+
100
+ const fetchUsers = async (pageNum: number, searchQuery: string, silent = false) => {
101
+ if (!silent) setLoading(true)
102
+ try {
103
+ const params = new URLSearchParams({
104
+ page: pageNum.toString(),
105
+ limit: '10',
106
+ search: searchQuery,
107
+ })
108
+ const response = await fetch(`/api/admin/users?${params}`)
109
+ if (!response.ok) throw new Error('Failed to fetch users')
110
+ const data = await response.json()
111
+ setUsers(data.users)
112
+ setPagination(data.pagination)
113
+ if (!silent) setSelectedIds(new Set())
114
+ } catch (error) {
115
+ if (!silent) toast.error('Failed to load users')
116
+ console.error(error)
117
+ } finally {
118
+ if (!silent) setLoading(false)
119
+ }
120
+ }
121
+
122
+ const isFirstRender = useRef(true)
123
+
124
+ useEffect(() => {
125
+ if (isFirstRender.current) {
126
+ isFirstRender.current = false
127
+ return
128
+ }
129
+ const timer = setTimeout(() => {
130
+ fetchUsers(1, search)
131
+ setPage(1)
132
+ }, 300)
133
+ return () => clearTimeout(timer)
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [search])
136
+
137
+ // Keep online status fresh without a full-page reload.
138
+ useEffect(() => {
139
+ const interval = setInterval(() => {
140
+ fetchUsers(page, search, true)
141
+ }, 30_000)
142
+ return () => clearInterval(interval)
143
+ }, [page, search])
144
+
145
+ const handleExport = async () => {
146
+ setIsExporting(true)
147
+ try {
148
+ const params = new URLSearchParams({ search })
149
+ const response = await fetch(`/api/admin/users/export?${params}`)
150
+ if (!response.ok) throw new Error('Failed to export users')
151
+ const blob = await response.blob()
152
+ const url = URL.createObjectURL(blob)
153
+ const link = document.createElement('a')
154
+ link.href = url
155
+ link.download = `users-${new Date().toISOString().slice(0, 10)}.csv`
156
+ document.body.appendChild(link)
157
+ link.click()
158
+ link.remove()
159
+ URL.revokeObjectURL(url)
160
+ } catch (error) {
161
+ toast.error('Failed to export users')
162
+ console.error(error)
163
+ } finally {
164
+ setIsExporting(false)
165
+ }
166
+ }
167
+
168
+ const handleAction = async () => {
169
+ if (!actionUserId || !actionType) return
170
+
171
+ if (actionType === 'impersonate') {
172
+ setIsProcessing(true)
173
+ try {
174
+ const { error } = await authClient.admin.impersonateUser({ userId: actionUserId })
175
+ if (error) throw new Error(error.message)
176
+ setActionUserId(null)
177
+ setActionType(null)
178
+ router.push('/dashboard')
179
+ } catch (error) {
180
+ toast.error('Failed to impersonate user')
181
+ console.error(error)
182
+ } finally {
183
+ setIsProcessing(false)
184
+ }
185
+ return
186
+ }
187
+
188
+ setIsProcessing(true)
189
+ try {
190
+ let response
191
+ let successMessage = ''
192
+
193
+ switch (actionType) {
194
+ case 'delete':
195
+ response = await fetch(`/api/admin/users/${actionUserId}`, {
196
+ method: 'DELETE',
197
+ })
198
+ successMessage = 'User deleted successfully'
199
+ break
200
+ case 'ban':
201
+ response = await fetch(`/api/admin/users/${actionUserId}/ban`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({ ban: true, reason: 'Banned by admin' }),
205
+ })
206
+ successMessage = 'User banned successfully'
207
+ break
208
+ case 'unban':
209
+ response = await fetch(`/api/admin/users/${actionUserId}/ban`, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ ban: false }),
213
+ })
214
+ successMessage = 'User unbanned successfully'
215
+ break
216
+ case 'promote':
217
+ response = await fetch(`/api/admin/users/${actionUserId}/role`, {
218
+ method: 'PATCH',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ role: 'admin' }),
221
+ })
222
+ successMessage = 'User promoted to admin'
223
+ break
224
+ case 'demote':
225
+ response = await fetch(`/api/admin/users/${actionUserId}/role`, {
226
+ method: 'PATCH',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ role: 'user' }),
229
+ })
230
+ successMessage = 'Admin access removed'
231
+ break
232
+ }
233
+
234
+ if (!response?.ok) throw new Error('Action failed')
235
+ toast.success(successMessage)
236
+ setActionUserId(null)
237
+ setActionType(null)
238
+ await fetchUsers(page, search)
239
+ } catch (error) {
240
+ toast.error(`Failed to ${actionType} user`)
241
+ console.error(error)
242
+ } finally {
243
+ setIsProcessing(false)
244
+ }
245
+ }
246
+
247
+ const handleBulkAction = async () => {
248
+ if (!bulkActionType || selectedIds.size === 0) return
249
+
250
+ setIsBulkProcessing(true)
251
+ try {
252
+ const userIds = Array.from(selectedIds)
253
+ const response =
254
+ bulkActionType === 'delete'
255
+ ? await fetch('/api/admin/users/bulk-delete', {
256
+ method: 'DELETE',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ userIds }),
259
+ })
260
+ : await fetch('/api/admin/users/bulk-ban', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ userIds, ban: true, reason: 'Banned by admin (bulk action)' }),
264
+ })
265
+
266
+ if (!response.ok) {
267
+ const data = await response.json().catch(() => null)
268
+ throw new Error(data?.error || 'Bulk action failed')
269
+ }
270
+
271
+ toast.success(
272
+ bulkActionType === 'delete'
273
+ ? `${userIds.length} user(s) deleted`
274
+ : `${userIds.length} user(s) banned`
275
+ )
276
+ setBulkActionType(null)
277
+ setSelectedIds(new Set())
278
+ await fetchUsers(page, search)
279
+ } catch (error) {
280
+ toast.error(error instanceof Error ? error.message : `Failed to bulk ${bulkActionType} users`)
281
+ console.error(error)
282
+ } finally {
283
+ setIsBulkProcessing(false)
284
+ }
285
+ }
286
+
287
+ return (
288
+ <>
289
+ <Card>
290
+ <CardHeader>
291
+ <CardTitle>Search Users</CardTitle>
292
+ <CardDescription>Search by name or email</CardDescription>
293
+ </CardHeader>
294
+ <CardContent>
295
+ <Input
296
+ placeholder="Search users..."
297
+ value={search}
298
+ onChange={(e) => setSearch(e.target.value)}
299
+ className="max-w-sm"
300
+ />
301
+ </CardContent>
302
+ </Card>
303
+
304
+ <Card>
305
+ <CardHeader className="flex flex-row items-start justify-between gap-4">
306
+ <div>
307
+ <CardTitle>Users List</CardTitle>
308
+ <CardDescription>
309
+ {pagination ? `Showing ${users.length} of ${pagination.total} users` : 'Loading...'}
310
+ </CardDescription>
311
+ </div>
312
+ <Button
313
+ variant="outline"
314
+ size="sm"
315
+ onClick={handleExport}
316
+ disabled={isExporting || users.length === 0}
317
+ >
318
+ <Download />
319
+ {isExporting ? 'Exporting...' : 'Export CSV'}
320
+ </Button>
321
+ </CardHeader>
322
+ <CardContent>
323
+ {selectedIds.size > 0 && (
324
+ <div className="flex items-center justify-between gap-4 mb-4 rounded-md border bg-muted/50 px-4 py-2">
325
+ <p className="text-sm font-medium">{selectedIds.size} selected</p>
326
+ <div className="flex items-center gap-2">
327
+ <Button variant="outline" size="sm" onClick={() => setSelectedIds(new Set())}>
328
+ Clear
329
+ </Button>
330
+ <Button variant="outline" size="sm" onClick={() => setBulkActionType('ban')}>
331
+ Ban Selected
332
+ </Button>
333
+ <Button variant="destructive" size="sm" onClick={() => setBulkActionType('delete')}>
334
+ Delete Selected
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ )}
339
+ {loading ? (
340
+ <div className="space-y-4">
341
+ {[...Array(5)].map((_, i) => (
342
+ <Skeleton key={i} className="h-16 w-full" />
343
+ ))}
344
+ </div>
345
+ ) : users.length === 0 ? (
346
+ <p className="text-center text-gray-500 py-8">No users found</p>
347
+ ) : (
348
+ <DataTable
349
+ columns={[
350
+ {
351
+ name: 'select',
352
+ title: (
353
+ <Checkbox
354
+ checked={allSelected ? true : someSelected ? 'indeterminate' : false}
355
+ onCheckedChange={toggleSelectAll}
356
+ aria-label="Select all users on this page"
357
+ />
358
+ ),
359
+ minWidth: 40,
360
+ renderer: (user) => (
361
+ <Checkbox
362
+ checked={selectedIds.has(user.id)}
363
+ onCheckedChange={() => toggleSelectOne(user.id)}
364
+ disabled={user.id === currentUserId}
365
+ aria-label={`Select ${user.name || user.email}`}
366
+ />
367
+ ),
368
+ },
369
+ {
370
+ name: 'name',
371
+ title: 'Name',
372
+ sticky: true,
373
+ renderer: (user) => (
374
+ <Link
375
+ href={`/dashboard/admin/users/${user.id}`}
376
+ className="inline-flex items-center gap-2 font-medium hover:underline"
377
+ >
378
+ <span
379
+ className={`h-2 w-2 rounded-full ${isOnline(user.lastActiveAt) ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}
380
+ title={isOnline(user.lastActiveAt) ? 'Online' : 'Offline'}
381
+ />
382
+ {user.name || 'N/A'}
383
+ </Link>
384
+ ),
385
+ },
386
+ {
387
+ name: 'email',
388
+ title: 'Email',
389
+ renderer: (user) => <span className="text-sm">{user.email}</span>,
390
+ },
391
+ {
392
+ name: 'role',
393
+ title: 'Role',
394
+ renderer: (user) => (
395
+ <Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
396
+ {user.role}
397
+ </Badge>
398
+ ),
399
+ },
400
+ {
401
+ name: 'status',
402
+ title: 'Status',
403
+ renderer: (user) =>
404
+ user.banned ? (
405
+ <Badge variant="destructive">Banned</Badge>
406
+ ) : (
407
+ <Badge variant="outline">Active</Badge>
408
+ ),
409
+ },
410
+ {
411
+ name: 'lastLoginAt',
412
+ title: 'Last Login',
413
+ renderer: (user) => (
414
+ <span className="text-sm">
415
+ {user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'Never'}
416
+ </span>
417
+ ),
418
+ },
419
+ {
420
+ name: 'createdAt',
421
+ title: 'Joined',
422
+ renderer: (user) => <span className="text-sm">{formatDate(user.createdAt)}</span>,
423
+ },
424
+ {
425
+ name: 'actions',
426
+ title: 'Actions',
427
+ minWidth: 340,
428
+ renderer: (user) => (
429
+ <div className="space-x-2 text-right">
430
+ <Button
431
+ variant="outline"
432
+ size="sm"
433
+ disabled={user.id === currentUserId || user.banned}
434
+ title={user.banned ? 'Cannot impersonate a banned user' : undefined}
435
+ onClick={() => {
436
+ setActionUserId(user.id)
437
+ setActionType('impersonate')
438
+ }}
439
+ >
440
+ Impersonate
441
+ </Button>
442
+ {user.role === 'admin' ? (
443
+ <Button
444
+ variant="outline"
445
+ size="sm"
446
+ onClick={() => {
447
+ setActionUserId(user.id)
448
+ setActionType('demote')
449
+ }}
450
+ >
451
+ Remove Admin
452
+ </Button>
453
+ ) : (
454
+ <Button
455
+ variant="outline"
456
+ size="sm"
457
+ onClick={() => {
458
+ setActionUserId(user.id)
459
+ setActionType('promote')
460
+ }}
461
+ >
462
+ Make Admin
463
+ </Button>
464
+ )}
465
+ {user.banned ? (
466
+ <Button
467
+ variant="outline"
468
+ size="sm"
469
+ onClick={() => {
470
+ setActionUserId(user.id)
471
+ setActionType('unban')
472
+ }}
473
+ >
474
+ Unban
475
+ </Button>
476
+ ) : (
477
+ <Button
478
+ variant="outline"
479
+ size="sm"
480
+ onClick={() => {
481
+ setActionUserId(user.id)
482
+ setActionType('ban')
483
+ }}
484
+ >
485
+ Ban
486
+ </Button>
487
+ )}
488
+ <Button
489
+ variant="destructive"
490
+ size="sm"
491
+ onClick={() => {
492
+ setActionUserId(user.id)
493
+ setActionType('delete')
494
+ }}
495
+ >
496
+ Delete
497
+ </Button>
498
+ </div>
499
+ ),
500
+ },
501
+ ] satisfies Column<User>[]}
502
+ data={users}
503
+ getRowId={(user) => user.id}
504
+ pageSize={false}
505
+ />
506
+ )}
507
+
508
+ {/* Pagination */}
509
+ {pagination && (
510
+ <DataTablePagination
511
+ page={page}
512
+ pages={pagination.pages}
513
+ onPageChange={(p) => {
514
+ setPage(p)
515
+ fetchUsers(p, search)
516
+ }}
517
+ className="mt-6 pt-6"
518
+ />
519
+ )}
520
+ </CardContent>
521
+ </Card>
522
+
523
+ {/* Action Confirmation Dialog */}
524
+ <AlertDialog open={!!actionUserId} onOpenChange={() => setActionUserId(null)}>
525
+ <AlertDialogContent>
526
+ <AlertDialogHeader>
527
+ <AlertDialogTitle>
528
+ {actionType === 'delete' && 'Delete User?'}
529
+ {actionType === 'ban' && 'Ban User?'}
530
+ {actionType === 'unban' && 'Unban User?'}
531
+ {actionType === 'promote' && 'Promote to Admin?'}
532
+ {actionType === 'demote' && 'Remove Admin Access?'}
533
+ {actionType === 'impersonate' && 'Impersonate User?'}
534
+ </AlertDialogTitle>
535
+ <AlertDialogDescription>
536
+ {actionType === 'delete' &&
537
+ 'This action will permanently delete the user and all their data.'}
538
+ {actionType === 'ban' &&
539
+ 'This will revoke all sessions and prevent the user from logging in.'}
540
+ {actionType === 'unban' && 'This will restore the user access.'}
541
+ {actionType === 'promote' &&
542
+ 'This will grant the user full admin dashboard access.'}
543
+ {actionType === 'demote' &&
544
+ "This will revoke the user's admin dashboard access."}
545
+ {actionType === 'impersonate' &&
546
+ "You'll be signed in as this user until you stop impersonating. This is logged."}
547
+ </AlertDialogDescription>
548
+ </AlertDialogHeader>
549
+ <AlertDialogFooter>
550
+ <AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
551
+ <AlertDialogAction
552
+ onClick={handleAction}
553
+ disabled={isProcessing}
554
+ className={actionType === 'delete' ? 'bg-destructive hover:bg-destructive/90' : ''}
555
+ >
556
+ {isProcessing ? 'Processing...' : 'Confirm'}
557
+ </AlertDialogAction>
558
+ </AlertDialogFooter>
559
+ </AlertDialogContent>
560
+ </AlertDialog>
561
+
562
+ {/* Bulk Action Confirmation Dialog */}
563
+ <AlertDialog
564
+ open={!!bulkActionType}
565
+ onOpenChange={(open) => {
566
+ if (!open) setBulkActionType(null)
567
+ }}
568
+ >
569
+ <AlertDialogContent>
570
+ <AlertDialogHeader>
571
+ <AlertDialogTitle>
572
+ {bulkActionType === 'delete'
573
+ ? `Delete ${selectedIds.size} Users?`
574
+ : `Ban ${selectedIds.size} Users?`}
575
+ </AlertDialogTitle>
576
+ <AlertDialogDescription>
577
+ {bulkActionType === 'delete'
578
+ ? 'This will permanently delete the selected users and all their data. This action cannot be undone.'
579
+ : 'This will revoke all sessions for the selected users and prevent them from logging in.'}
580
+ </AlertDialogDescription>
581
+ </AlertDialogHeader>
582
+ <AlertDialogFooter>
583
+ <AlertDialogCancel disabled={isBulkProcessing}>Cancel</AlertDialogCancel>
584
+ <AlertDialogAction
585
+ onClick={handleBulkAction}
586
+ disabled={isBulkProcessing}
587
+ className={bulkActionType === 'delete' ? 'bg-destructive hover:bg-destructive/90' : ''}
588
+ >
589
+ {isBulkProcessing ? 'Processing...' : 'Confirm'}
590
+ </AlertDialogAction>
591
+ </AlertDialogFooter>
592
+ </AlertDialogContent>
593
+ </AlertDialog>
594
+ </>
595
+ )
596
+ }
@@ -0,0 +1,59 @@
1
+ import Link from "next/link"
2
+ import { ShieldAlert } from "lucide-react"
3
+ import { getDeviceThrottleActivity } from "@xk2800/nextjs-template/admin/queries"
4
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
5
+ import { Badge } from "../../ui/badge"
6
+ import DeviceThrottleTable from "./deviceThrottleTable"
7
+
8
+ // Live view of devices accumulating failed sign-ins / sign-up attempts, keyed
9
+ // by FingerprintJS visitorId (see lib/auth-throttle.ts). Every column an admin
10
+ // needs to block the source is here — fingerprint and IP are `select-all` so
11
+ // they can be dragged straight into a firewall / WAF / reverse-proxy rule.
12
+ // The historical per-account trail lives in the audit log (login_failed).
13
+ interface DeviceThrottleCardProps {
14
+ // Hidden on the Audit Logs page, where the full failed-login table is right below.
15
+ showHistoryLink?: boolean
16
+ }
17
+
18
+ export default async function DeviceThrottleCard({ showHistoryLink = true }: DeviceThrottleCardProps) {
19
+ const rows = await getDeviceThrottleActivity(50)
20
+ const blockedNow = rows.filter((r) => r.blocked && r.windowActive).length
21
+
22
+ return (
23
+ <Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
24
+ <CardHeader>
25
+ <div className="flex flex-wrap items-center gap-2">
26
+ <CardTitle>Device throttle</CardTitle>
27
+ <span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400">
28
+ <ShieldAlert className="size-3" />
29
+ Security
30
+ </span>
31
+ {blockedNow > 0 && (
32
+ <Badge variant="destructive">{blockedNow} blocked right now</Badge>
33
+ )}
34
+ </div>
35
+ <CardDescription>
36
+ Fingerprints hitting the sign-in / sign-up abuse limit. Fingerprint and IP
37
+ are click-to-select for a block rule.
38
+ </CardDescription>
39
+ </CardHeader>
40
+ <CardContent>
41
+ {rows.length === 0 ? (
42
+ <p className="text-sm text-gray-500 py-4">No abuse activity detected</p>
43
+ ) : (
44
+ <DeviceThrottleTable rows={rows} />
45
+ )}
46
+ {showHistoryLink && (
47
+ <div className="mt-4 pt-4 border-t border-amber-200 dark:border-amber-900/40">
48
+ <Link
49
+ href="/dashboard/admin/activity-logs"
50
+ className="text-sm font-medium text-amber-900 hover:underline dark:text-amber-400"
51
+ >
52
+ View failed sign-in history →
53
+ </Link>
54
+ </div>
55
+ )}
56
+ </CardContent>
57
+ </Card>
58
+ )
59
+ }