@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.
- package/README.md +339 -0
- package/components/auth/authCard.tsx +92 -0
- package/components/auth/authShell.tsx +46 -0
- package/components/auth/deviceCheck.tsx +52 -0
- package/components/auth/emailPasswordLogin.tsx +136 -0
- package/components/auth/emailPasswordSignup.tsx +178 -0
- package/components/auth/forgotPasswordForm.tsx +66 -0
- package/components/auth/loginErrorRecovery.tsx +117 -0
- package/components/auth/logoutButtons.tsx +26 -0
- package/components/auth/oneTap.tsx +50 -0
- package/components/auth/passkeyLogin.tsx +50 -0
- package/components/auth/resetPasswordForm.tsx +112 -0
- package/components/auth/socialLogin.tsx +51 -0
- package/components/auth/twoFactorVerifyForm.tsx +100 -0
- package/components/dashboard/accountDetailsCard.tsx +58 -0
- package/components/dashboard/activityLogsCard.tsx +141 -0
- package/components/dashboard/activityLogsCardSkeleton.tsx +20 -0
- package/components/dashboard/activityLogsSection.tsx +16 -0
- package/components/dashboard/admin/activityLogsSection.tsx +13 -0
- package/components/dashboard/admin/adminActivityLogsSkeleton.tsx +34 -0
- package/components/dashboard/admin/adminActivityLogsTable.tsx +319 -0
- package/components/dashboard/admin/adminStatsGrid.tsx +30 -0
- package/components/dashboard/admin/adminStatsGridSkeleton.tsx +16 -0
- package/components/dashboard/admin/adminUserActivityLogCard.tsx +53 -0
- package/components/dashboard/admin/adminUserActivityLogTable.tsx +69 -0
- package/components/dashboard/admin/adminUserLoginInfoCard.tsx +50 -0
- package/components/dashboard/admin/adminUserLoginInfoTable.tsx +63 -0
- package/components/dashboard/admin/adminUserSessionsCard.tsx +225 -0
- package/components/dashboard/admin/adminUserStatusCard.tsx +57 -0
- package/components/dashboard/admin/adminUsersSkeleton.tsx +30 -0
- package/components/dashboard/admin/adminUsersTable.tsx +596 -0
- package/components/dashboard/admin/deviceThrottleCard.tsx +59 -0
- package/components/dashboard/admin/deviceThrottleTable.tsx +93 -0
- package/components/dashboard/admin/impersonationLogCard.tsx +73 -0
- package/components/dashboard/admin/newDeviceCard.tsx +35 -0
- package/components/dashboard/admin/newDeviceTable.tsx +71 -0
- package/components/dashboard/admin/revokeAllSessionsButton.tsx +77 -0
- package/components/dashboard/admin/sharedDeviceCard.tsx +42 -0
- package/components/dashboard/admin/sharedDeviceTable.tsx +75 -0
- package/components/dashboard/admin/systemSettingsForm.tsx +200 -0
- package/components/dashboard/admin/systemSettingsSection.tsx +9 -0
- package/components/dashboard/admin/systemSettingsSkeleton.tsx +29 -0
- package/components/dashboard/admin/usersSection.tsx +13 -0
- package/components/dashboard/adminSection.tsx +31 -0
- package/components/dashboard/appSidebar.tsx +170 -0
- package/components/dashboard/dashboardHeader.tsx +85 -0
- package/components/dashboard/heartbeat.tsx +22 -0
- package/components/dashboard/impersonationBanner.tsx +50 -0
- package/components/dashboard/passkeysCard.tsx +187 -0
- package/components/dashboard/profileCard.tsx +42 -0
- package/components/dashboard/sectionErrorFallback.tsx +18 -0
- package/components/dashboard/sessionsCard.tsx +194 -0
- package/components/dashboard/sessionsCardSkeleton.tsx +20 -0
- package/components/dashboard/sessionsSection.tsx +22 -0
- package/components/dashboard/twoFactorCard.tsx +344 -0
- package/components/dashboard/verifyEmailButton.tsx +32 -0
- package/components/email/email-template-2.tsx +44 -0
- package/components/email/email-template-new-device.tsx +80 -0
- package/components/email/email-template-passkey-change.tsx +81 -0
- package/components/email/email-template-reset-password.tsx +56 -0
- package/components/email/email-template-verify-email.tsx +55 -0
- package/components/email/email-template-welcome.tsx +53 -0
- package/components/email/email-template.tsx +33 -0
- package/components/email/send-email-button.tsx +61 -0
- package/components/providers/theme-provider.tsx +9 -0
- package/components/theme/theme-toggle.tsx +53 -0
- package/components/ui/alert-dialog.tsx +141 -0
- package/components/ui/avatar.tsx +53 -0
- package/components/ui/badge.tsx +46 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/checkbox.tsx +32 -0
- package/components/ui/data-table.tsx +302 -0
- package/components/ui/dialog.tsx +143 -0
- package/components/ui/dropdown-menu.tsx +203 -0
- package/components/ui/input.tsx +21 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/password-input.tsx +38 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/sheet.tsx +143 -0
- package/components/ui/sidebar.tsx +726 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +57 -0
- package/dist/activity/logger.d.mts +18 -0
- package/dist/activity/logger.d.ts +18 -0
- package/dist/activity/logger.js +289 -0
- package/dist/activity/logger.js.map +1 -0
- package/dist/activity/logger.mjs +7 -0
- package/dist/activity/logger.mjs.map +1 -0
- package/dist/activity/queries.d.mts +98 -0
- package/dist/activity/queries.d.ts +98 -0
- package/dist/activity/queries.js +372 -0
- package/dist/activity/queries.js.map +1 -0
- package/dist/activity/queries.mjs +124 -0
- package/dist/activity/queries.mjs.map +1 -0
- package/dist/admin/queries.d.mts +68 -0
- package/dist/admin/queries.d.ts +68 -0
- package/dist/admin/queries.js +450 -0
- package/dist/admin/queries.js.map +1 -0
- package/dist/admin/queries.mjs +143 -0
- package/dist/admin/queries.mjs.map +1 -0
- package/dist/auth/helpers.d.mts +111 -0
- package/dist/auth/helpers.d.ts +111 -0
- package/dist/auth/helpers.js +1023 -0
- package/dist/auth/helpers.js.map +1 -0
- package/dist/auth/helpers.mjs +67 -0
- package/dist/auth/helpers.mjs.map +1 -0
- package/dist/auth/index.d.mts +6589 -0
- package/dist/auth/index.d.ts +6589 -0
- package/dist/auth/index.js +968 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/index.mjs +10 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth-client/index.d.mts +4864 -0
- package/dist/auth-client/index.d.ts +4864 -0
- package/dist/auth-client/index.js +55 -0
- package/dist/auth-client/index.js.map +1 -0
- package/dist/auth-client/index.mjs +49 -0
- package/dist/auth-client/index.mjs.map +1 -0
- package/dist/chunk-2FW7C3KS.mjs +556 -0
- package/dist/chunk-2FW7C3KS.mjs.map +1 -0
- package/dist/chunk-ENGYO4K7.mjs +189 -0
- package/dist/chunk-ENGYO4K7.mjs.map +1 -0
- package/dist/chunk-GTPTVNY2.mjs +40 -0
- package/dist/chunk-GTPTVNY2.mjs.map +1 -0
- package/dist/chunk-HYKUB3A2.mjs +30 -0
- package/dist/chunk-HYKUB3A2.mjs.map +1 -0
- package/dist/chunk-JIYHBWAU.mjs +79 -0
- package/dist/chunk-JIYHBWAU.mjs.map +1 -0
- package/dist/chunk-K6OJCSJS.mjs +41 -0
- package/dist/chunk-K6OJCSJS.mjs.map +1 -0
- package/dist/chunk-N6ECRCHY.mjs +61 -0
- package/dist/chunk-N6ECRCHY.mjs.map +1 -0
- package/dist/chunk-YN4436PK.mjs +33 -0
- package/dist/chunk-YN4436PK.mjs.map +1 -0
- package/dist/config/env.d.mts +15 -0
- package/dist/config/env.d.ts +15 -0
- package/dist/config/env.js +42 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/env.mjs +4 -0
- package/dist/config/env.mjs.map +1 -0
- package/dist/db/index.d.mts +10 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.js +252 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/index.mjs +6 -0
- package/dist/db/index.mjs.map +1 -0
- package/dist/db/schema.d.mts +2 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +186 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/schema.mjs +4 -0
- package/dist/db/schema.mjs.map +1 -0
- package/dist/schema-CgkDv4wJ.d.mts +1758 -0
- package/dist/schema-CgkDv4wJ.d.ts +1758 -0
- package/dist/sessions/queries.d.mts +34 -0
- package/dist/sessions/queries.d.ts +34 -0
- package/dist/sessions/queries.js +268 -0
- package/dist/sessions/queries.js.map +1 -0
- package/dist/sessions/queries.mjs +23 -0
- package/dist/sessions/queries.mjs.map +1 -0
- package/dist/settings/queries.d.mts +20 -0
- package/dist/settings/queries.d.ts +20 -0
- package/dist/settings/queries.js +344 -0
- package/dist/settings/queries.js.map +1 -0
- package/dist/settings/queries.mjs +7 -0
- package/dist/settings/queries.mjs.map +1 -0
- package/dist/types/auth/loginSchema.d.mts +8 -0
- package/dist/types/auth/loginSchema.d.ts +8 -0
- package/dist/types/auth/loginSchema.js +18 -0
- package/dist/types/auth/loginSchema.js.map +1 -0
- package/dist/types/auth/loginSchema.mjs +16 -0
- package/dist/types/auth/loginSchema.mjs.map +1 -0
- package/dist/types/auth/signupSchema.d.mts +9 -0
- package/dist/types/auth/signupSchema.d.ts +9 -0
- package/dist/types/auth/signupSchema.js +21 -0
- package/dist/types/auth/signupSchema.js.map +1 -0
- package/dist/types/auth/signupSchema.mjs +19 -0
- package/dist/types/auth/signupSchema.mjs.map +1 -0
- package/dist/users/queries.d.mts +71 -0
- package/dist/users/queries.d.ts +71 -0
- package/dist/users/queries.js +293 -0
- package/dist/users/queries.js.map +1 -0
- package/dist/users/queries.mjs +47 -0
- package/dist/users/queries.mjs.map +1 -0
- package/lib/auth-client.ts +58 -0
- package/lib/cookie-names.ts +13 -0
- package/lib/device-fingerprint.ts +21 -0
- package/lib/formatters.ts +53 -0
- package/lib/recalculate-columns.ts +75 -0
- package/lib/request-info.ts +78 -0
- package/lib/utils.ts +6 -0
- package/package.json +301 -0
- package/styles/theme.css +124 -0
- package/types/auth/loginSchema.ts +11 -0
- 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
|
+
}
|