@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,319 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } 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 { Label } from '../../ui/label'
|
|
8
|
+
import { Badge } from '../../ui/badge'
|
|
9
|
+
import { Skeleton } from '../../ui/skeleton'
|
|
10
|
+
import { DataTable, DataTablePagination, type Column } from '../../ui/data-table'
|
|
11
|
+
import { toast } from 'sonner'
|
|
12
|
+
import { formatDateTime } from '@xk2800/nextjs-template/lib/formatters'
|
|
13
|
+
import { Download } from 'lucide-react'
|
|
14
|
+
|
|
15
|
+
interface ActivityLog {
|
|
16
|
+
id: string
|
|
17
|
+
action: string
|
|
18
|
+
description: string
|
|
19
|
+
ipAddress: string | null
|
|
20
|
+
userAgent: string | null
|
|
21
|
+
metadata: string | null
|
|
22
|
+
createdAt: Date | string
|
|
23
|
+
userId: string
|
|
24
|
+
userName: string | null
|
|
25
|
+
userEmail: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PaginationData {
|
|
29
|
+
page: number
|
|
30
|
+
limit: number
|
|
31
|
+
total: number
|
|
32
|
+
pages: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const actionOptions = [
|
|
36
|
+
{ value: '', label: 'All actions' },
|
|
37
|
+
{ value: 'login', label: 'Login' },
|
|
38
|
+
{ value: 'logout', label: 'Logout' },
|
|
39
|
+
{ value: 'login_failed', label: 'Login failed' },
|
|
40
|
+
{ value: 'password_changed', label: 'Password changed' },
|
|
41
|
+
{ value: 'email_changed', label: 'Email changed' },
|
|
42
|
+
{ value: 'profile_updated', label: 'Profile updated' },
|
|
43
|
+
{ value: 'session_revoked', label: 'Session revoked' },
|
|
44
|
+
{ value: 'user_deleted', label: 'User deleted' },
|
|
45
|
+
{ value: 'user_banned', label: 'User banned' },
|
|
46
|
+
{ value: 'user_unbanned', label: 'User unbanned' },
|
|
47
|
+
{ value: 'role_changed', label: 'Role changed' },
|
|
48
|
+
{ value: 'impersonation_started', label: 'Impersonation started' },
|
|
49
|
+
{ value: 'impersonation_stopped', label: 'Impersonation stopped' },
|
|
50
|
+
{ value: 'settings_changed', label: 'Settings changed' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const actionBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
54
|
+
login: 'default',
|
|
55
|
+
logout: 'secondary',
|
|
56
|
+
login_failed: 'destructive',
|
|
57
|
+
password_changed: 'default',
|
|
58
|
+
email_changed: 'default',
|
|
59
|
+
profile_updated: 'secondary',
|
|
60
|
+
session_revoked: 'destructive',
|
|
61
|
+
user_deleted: 'destructive',
|
|
62
|
+
user_banned: 'destructive',
|
|
63
|
+
user_unbanned: 'default',
|
|
64
|
+
role_changed: 'default',
|
|
65
|
+
impersonation_started: 'destructive',
|
|
66
|
+
impersonation_stopped: 'secondary',
|
|
67
|
+
settings_changed: 'default',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface AdminActivityLogsTableProps {
|
|
71
|
+
initialLogs: ActivityLog[]
|
|
72
|
+
initialPagination: PaginationData
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const columns: Column<ActivityLog>[] = [
|
|
76
|
+
{
|
|
77
|
+
name: 'action',
|
|
78
|
+
title: 'Action',
|
|
79
|
+
sticky: true,
|
|
80
|
+
renderer: (log) => (
|
|
81
|
+
<Badge variant={actionBadgeVariant[log.action] || 'outline'}>
|
|
82
|
+
{log.action.replace(/_/g, ' ')}
|
|
83
|
+
</Badge>
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'user',
|
|
88
|
+
title: 'User',
|
|
89
|
+
renderer: (log) => (
|
|
90
|
+
<div className="text-sm">
|
|
91
|
+
<div className="font-medium">{log.userName || 'N/A'}</div>
|
|
92
|
+
<div className="text-xs text-gray-500">{log.userEmail}</div>
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'description',
|
|
98
|
+
title: 'Description',
|
|
99
|
+
renderer: (log) => (
|
|
100
|
+
<span className="text-sm max-w-xs truncate block">{log.description}</span>
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'ipAddress',
|
|
105
|
+
title: 'IP Address',
|
|
106
|
+
renderer: (log) => <span className="text-sm font-mono">{log.ipAddress || 'N/A'}</span>,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'createdAt',
|
|
110
|
+
title: 'Time',
|
|
111
|
+
renderer: (log) => (
|
|
112
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
113
|
+
{formatDateTime(log.createdAt)}
|
|
114
|
+
</span>
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
export default function AdminActivityLogsTable({ initialLogs, initialPagination }: AdminActivityLogsTableProps) {
|
|
120
|
+
const [logs, setLogs] = useState<ActivityLog[]>(initialLogs)
|
|
121
|
+
const [loading, setLoading] = useState(false)
|
|
122
|
+
const [search, setSearch] = useState('')
|
|
123
|
+
const [action, setAction] = useState('')
|
|
124
|
+
const [dateFrom, setDateFrom] = useState('')
|
|
125
|
+
const [dateTo, setDateTo] = useState('')
|
|
126
|
+
const [page, setPage] = useState(1)
|
|
127
|
+
const [pagination, setPagination] = useState<PaginationData | null>(initialPagination)
|
|
128
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
129
|
+
|
|
130
|
+
const requestId = useRef(0)
|
|
131
|
+
|
|
132
|
+
const fetchLogs = async (pageNum: number) => {
|
|
133
|
+
const currentRequest = ++requestId.current
|
|
134
|
+
setLoading(true)
|
|
135
|
+
try {
|
|
136
|
+
const params = new URLSearchParams({
|
|
137
|
+
page: pageNum.toString(),
|
|
138
|
+
limit: '20',
|
|
139
|
+
search,
|
|
140
|
+
action,
|
|
141
|
+
...(dateFrom && { dateFrom }),
|
|
142
|
+
...(dateTo && { dateTo }),
|
|
143
|
+
})
|
|
144
|
+
const response = await fetch(`/api/admin/activity-logs?${params}`)
|
|
145
|
+
if (!response.ok) throw new Error('Failed to fetch activity logs')
|
|
146
|
+
const data = await response.json()
|
|
147
|
+
if (currentRequest !== requestId.current) return
|
|
148
|
+
setLogs(data.logs)
|
|
149
|
+
setPagination(data.pagination)
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (currentRequest !== requestId.current) return
|
|
152
|
+
toast.error('Failed to load activity logs')
|
|
153
|
+
console.error(error)
|
|
154
|
+
} finally {
|
|
155
|
+
if (currentRequest === requestId.current) setLoading(false)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isFirstRender = useRef(true)
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (isFirstRender.current) {
|
|
163
|
+
isFirstRender.current = false
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
fetchLogs(1)
|
|
168
|
+
setPage(1)
|
|
169
|
+
}, 300)
|
|
170
|
+
return () => clearTimeout(timer)
|
|
171
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
172
|
+
}, [search, action, dateFrom, dateTo])
|
|
173
|
+
|
|
174
|
+
const resetFilters = () => {
|
|
175
|
+
setSearch('')
|
|
176
|
+
setAction('')
|
|
177
|
+
setDateFrom('')
|
|
178
|
+
setDateTo('')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const handleExport = async () => {
|
|
182
|
+
setIsExporting(true)
|
|
183
|
+
try {
|
|
184
|
+
const params = new URLSearchParams({
|
|
185
|
+
search,
|
|
186
|
+
action,
|
|
187
|
+
...(dateFrom && { dateFrom }),
|
|
188
|
+
...(dateTo && { dateTo }),
|
|
189
|
+
})
|
|
190
|
+
const response = await fetch(`/api/admin/activity-logs/export?${params}`)
|
|
191
|
+
if (!response.ok) throw new Error('Failed to export activity logs')
|
|
192
|
+
const blob = await response.blob()
|
|
193
|
+
const url = URL.createObjectURL(blob)
|
|
194
|
+
const link = document.createElement('a')
|
|
195
|
+
link.href = url
|
|
196
|
+
link.download = `activity-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
|
197
|
+
document.body.appendChild(link)
|
|
198
|
+
link.click()
|
|
199
|
+
link.remove()
|
|
200
|
+
URL.revokeObjectURL(url)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
toast.error('Failed to export activity logs')
|
|
203
|
+
console.error(error)
|
|
204
|
+
} finally {
|
|
205
|
+
setIsExporting(false)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<Card>
|
|
212
|
+
<CardHeader>
|
|
213
|
+
<CardTitle>Filters</CardTitle>
|
|
214
|
+
<CardDescription>Search and narrow down activity events</CardDescription>
|
|
215
|
+
</CardHeader>
|
|
216
|
+
<CardContent>
|
|
217
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 items-end">
|
|
218
|
+
<div className="space-y-2 lg:col-span-2">
|
|
219
|
+
<Label htmlFor="search">User</Label>
|
|
220
|
+
<Input
|
|
221
|
+
id="search"
|
|
222
|
+
placeholder="Search by name or email..."
|
|
223
|
+
value={search}
|
|
224
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="space-y-2">
|
|
229
|
+
<Label htmlFor="action">Action</Label>
|
|
230
|
+
<select
|
|
231
|
+
id="action"
|
|
232
|
+
value={action}
|
|
233
|
+
onChange={(e) => setAction(e.target.value)}
|
|
234
|
+
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:bg-input/30"
|
|
235
|
+
>
|
|
236
|
+
{actionOptions.map((opt) => (
|
|
237
|
+
<option key={opt.value} value={opt.value}>
|
|
238
|
+
{opt.label}
|
|
239
|
+
</option>
|
|
240
|
+
))}
|
|
241
|
+
</select>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div className="space-y-2">
|
|
245
|
+
<Label htmlFor="dateFrom">From</Label>
|
|
246
|
+
<Input
|
|
247
|
+
id="dateFrom"
|
|
248
|
+
type="date"
|
|
249
|
+
value={dateFrom}
|
|
250
|
+
onChange={(e) => setDateFrom(e.target.value)}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="space-y-2">
|
|
255
|
+
<Label htmlFor="dateTo">To</Label>
|
|
256
|
+
<Input
|
|
257
|
+
id="dateTo"
|
|
258
|
+
type="date"
|
|
259
|
+
value={dateTo}
|
|
260
|
+
onChange={(e) => setDateTo(e.target.value)}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{(search || action || dateFrom || dateTo) && (
|
|
266
|
+
<Button variant="ghost" size="sm" className="mt-4" onClick={resetFilters}>
|
|
267
|
+
Clear filters
|
|
268
|
+
</Button>
|
|
269
|
+
)}
|
|
270
|
+
</CardContent>
|
|
271
|
+
</Card>
|
|
272
|
+
|
|
273
|
+
<Card>
|
|
274
|
+
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
275
|
+
<div>
|
|
276
|
+
<CardTitle>Events</CardTitle>
|
|
277
|
+
<CardDescription>
|
|
278
|
+
{pagination ? `Showing ${logs.length} of ${pagination.total} events` : 'Loading...'}
|
|
279
|
+
</CardDescription>
|
|
280
|
+
</div>
|
|
281
|
+
<Button
|
|
282
|
+
variant="outline"
|
|
283
|
+
size="sm"
|
|
284
|
+
onClick={handleExport}
|
|
285
|
+
disabled={isExporting || logs.length === 0}
|
|
286
|
+
>
|
|
287
|
+
<Download />
|
|
288
|
+
{isExporting ? 'Exporting...' : 'Export CSV'}
|
|
289
|
+
</Button>
|
|
290
|
+
</CardHeader>
|
|
291
|
+
<CardContent>
|
|
292
|
+
{loading ? (
|
|
293
|
+
<div className="space-y-4">
|
|
294
|
+
{[...Array(5)].map((_, i) => (
|
|
295
|
+
<Skeleton key={i} className="h-16 w-full" />
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
) : logs.length === 0 ? (
|
|
299
|
+
<p className="text-center text-gray-500 py-8">No activity logs found</p>
|
|
300
|
+
) : (
|
|
301
|
+
<DataTable columns={columns} data={logs} getRowId={(log) => log.id} pageSize={false} />
|
|
302
|
+
)}
|
|
303
|
+
|
|
304
|
+
{pagination && (
|
|
305
|
+
<DataTablePagination
|
|
306
|
+
page={page}
|
|
307
|
+
pages={pagination.pages}
|
|
308
|
+
onPageChange={(p) => {
|
|
309
|
+
setPage(p)
|
|
310
|
+
fetchLogs(p)
|
|
311
|
+
}}
|
|
312
|
+
className="mt-6 pt-6"
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
</CardContent>
|
|
316
|
+
</Card>
|
|
317
|
+
</>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Users, UserPlus, Activity, Ban } from "lucide-react"
|
|
2
|
+
import { getAdminDashboardStats } from "@xk2800/nextjs-template/admin/queries"
|
|
3
|
+
import { Card, CardContent } from "../../ui/card"
|
|
4
|
+
|
|
5
|
+
const STAT_CONFIG = [
|
|
6
|
+
{ key: "totalUsers", label: "Total users", icon: Users },
|
|
7
|
+
{ key: "newSignupsThisWeek", label: "New signups this week", icon: UserPlus },
|
|
8
|
+
{ key: "activeSessions", label: "Active sessions", icon: Activity },
|
|
9
|
+
{ key: "bannedCount", label: "Banned users", icon: Ban },
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export default async function AdminStatsGrid() {
|
|
13
|
+
const stats = await getAdminDashboardStats()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
17
|
+
{STAT_CONFIG.map(({ key, label, icon: Icon }) => (
|
|
18
|
+
<Card key={key}>
|
|
19
|
+
<CardContent>
|
|
20
|
+
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
|
|
21
|
+
<Icon className="size-3.5" />
|
|
22
|
+
{label}
|
|
23
|
+
</div>
|
|
24
|
+
<p className="text-3xl font-bold mt-2 text-foreground">{stats[key]}</p>
|
|
25
|
+
</CardContent>
|
|
26
|
+
</Card>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Card, CardContent } from "../../ui/card"
|
|
2
|
+
import { Skeleton } from "../../ui/skeleton"
|
|
3
|
+
|
|
4
|
+
export default function AdminStatsGridSkeleton() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
7
|
+
{[...Array(4)].map((_, i) => (
|
|
8
|
+
<Card key={i}>
|
|
9
|
+
<CardContent>
|
|
10
|
+
<Skeleton className="h-20" />
|
|
11
|
+
</CardContent>
|
|
12
|
+
</Card>
|
|
13
|
+
))}
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
2
|
+
import { parseUserAgent, lookupGeoLocation, formatDeviceInfo, formatLocation } from "../../../lib/request-info"
|
|
3
|
+
import AdminUserActivityLogTable from "./adminUserActivityLogTable"
|
|
4
|
+
|
|
5
|
+
interface ActivityLog {
|
|
6
|
+
id: string
|
|
7
|
+
action: string
|
|
8
|
+
description: string
|
|
9
|
+
ipAddress: string | null
|
|
10
|
+
userAgent: string | null
|
|
11
|
+
metadata: string | null
|
|
12
|
+
createdAt: Date
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getReferrerUrl(metadata: string | null): string | null {
|
|
16
|
+
if (!metadata) return null
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(metadata)
|
|
20
|
+
return typeof parsed.referrerUrl === 'string' ? parsed.referrerUrl : null
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function AdminUserActivityLogCard({ logs }: { logs: ActivityLog[] }) {
|
|
27
|
+
const rows = logs.map((log) => ({
|
|
28
|
+
id: log.id,
|
|
29
|
+
action: log.action,
|
|
30
|
+
description: log.description,
|
|
31
|
+
referrerUrl: getReferrerUrl(log.metadata),
|
|
32
|
+
device: formatDeviceInfo(parseUserAgent(log.userAgent)),
|
|
33
|
+
location: formatLocation(lookupGeoLocation(log.ipAddress)),
|
|
34
|
+
ipAddress: log.ipAddress,
|
|
35
|
+
createdAt: log.createdAt,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Card>
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle>Activity Log</CardTitle>
|
|
42
|
+
<CardDescription>Recent activity for this user</CardDescription>
|
|
43
|
+
</CardHeader>
|
|
44
|
+
<CardContent>
|
|
45
|
+
{logs.length === 0 ? (
|
|
46
|
+
<p className="text-center text-gray-500 py-8">No activity logs found</p>
|
|
47
|
+
) : (
|
|
48
|
+
<AdminUserActivityLogTable rows={rows} />
|
|
49
|
+
)}
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../../ui/badge"
|
|
4
|
+
import { DataTable, type Column } from "../../ui/data-table"
|
|
5
|
+
import { formatDateTime, actionBadgeVariant } from "../../../lib/formatters"
|
|
6
|
+
|
|
7
|
+
export type ActivityLogRow = {
|
|
8
|
+
id: string
|
|
9
|
+
action: string
|
|
10
|
+
description: string
|
|
11
|
+
referrerUrl: string | null
|
|
12
|
+
device: string
|
|
13
|
+
location: string
|
|
14
|
+
ipAddress: string | null
|
|
15
|
+
createdAt: Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const columns: Column<ActivityLogRow>[] = [
|
|
19
|
+
{
|
|
20
|
+
name: 'action',
|
|
21
|
+
title: 'Action',
|
|
22
|
+
sticky: true,
|
|
23
|
+
renderer: (log) => (
|
|
24
|
+
<Badge variant={actionBadgeVariant[log.action] || 'outline'}>
|
|
25
|
+
{log.action.replace(/_/g, ' ')}
|
|
26
|
+
</Badge>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'description',
|
|
31
|
+
title: 'Description',
|
|
32
|
+
renderer: (log) => (
|
|
33
|
+
<div className="text-sm max-w-xs">
|
|
34
|
+
<div className="truncate">{log.description}</div>
|
|
35
|
+
{log.referrerUrl && (
|
|
36
|
+
<div className="text-xs text-gray-500 truncate">via {log.referrerUrl}</div>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'device',
|
|
43
|
+
title: 'Device',
|
|
44
|
+
renderer: (log) => <span className="text-sm">{log.device}</span>,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'location',
|
|
48
|
+
title: 'Location',
|
|
49
|
+
renderer: (log) => <span className="text-sm">{log.location}</span>,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'ipAddress',
|
|
53
|
+
title: 'IP Address',
|
|
54
|
+
renderer: (log) => <span className="text-sm font-mono">{log.ipAddress || 'N/A'}</span>,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'createdAt',
|
|
58
|
+
title: 'Time',
|
|
59
|
+
renderer: (log) => (
|
|
60
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
61
|
+
{formatDateTime(log.createdAt)}
|
|
62
|
+
</span>
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
export default function AdminUserActivityLogTable({ rows }: { rows: ActivityLogRow[] }) {
|
|
68
|
+
return <DataTable columns={columns} data={rows} getRowId={(log) => log.id} />
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
2
|
+
import { formatDeviceInfo, formatLocation, type DeviceType } from "../../../lib/request-info"
|
|
3
|
+
import AdminUserLoginInfoTable from "./adminUserLoginInfoTable"
|
|
4
|
+
|
|
5
|
+
interface LoginEvent {
|
|
6
|
+
id: string
|
|
7
|
+
description: string
|
|
8
|
+
referrerUrl: string | null
|
|
9
|
+
os: string | null
|
|
10
|
+
browser: string | null
|
|
11
|
+
deviceType: string | null
|
|
12
|
+
country: string | null
|
|
13
|
+
city: string | null
|
|
14
|
+
ipAddress: string | null
|
|
15
|
+
createdAt: Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function AdminUserLoginInfoCard({ logins }: { logins: LoginEvent[] }) {
|
|
19
|
+
const rows = logins.map((login) => ({
|
|
20
|
+
id: login.id,
|
|
21
|
+
isRegistration: login.description.toLowerCase().includes('registered'),
|
|
22
|
+
device: formatDeviceInfo({
|
|
23
|
+
os: login.os,
|
|
24
|
+
browser: login.browser,
|
|
25
|
+
deviceType: (login.deviceType as DeviceType) || 'desktop',
|
|
26
|
+
}),
|
|
27
|
+
location: formatLocation({ country: login.country, city: login.city }),
|
|
28
|
+
referrerUrl: login.referrerUrl,
|
|
29
|
+
ipAddress: login.ipAddress,
|
|
30
|
+
createdAt: login.createdAt,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Card>
|
|
35
|
+
<CardHeader>
|
|
36
|
+
<CardTitle>Registration & Login Info</CardTitle>
|
|
37
|
+
<CardDescription>
|
|
38
|
+
Referrer, device, and location captured for each sign-in
|
|
39
|
+
</CardDescription>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent>
|
|
42
|
+
{logins.length === 0 ? (
|
|
43
|
+
<p className="text-center text-gray-500 py-8">No login history found</p>
|
|
44
|
+
) : (
|
|
45
|
+
<AdminUserLoginInfoTable rows={rows} />
|
|
46
|
+
)}
|
|
47
|
+
</CardContent>
|
|
48
|
+
</Card>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../../ui/badge"
|
|
4
|
+
import { DataTable, type Column } from "../../ui/data-table"
|
|
5
|
+
import { formatDateTime } from "../../../lib/formatters"
|
|
6
|
+
|
|
7
|
+
export type LoginInfoRow = {
|
|
8
|
+
id: string
|
|
9
|
+
isRegistration: boolean
|
|
10
|
+
device: string
|
|
11
|
+
location: string
|
|
12
|
+
referrerUrl: string | null
|
|
13
|
+
ipAddress: string | null
|
|
14
|
+
createdAt: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const columns: Column<LoginInfoRow>[] = [
|
|
18
|
+
{
|
|
19
|
+
name: 'event',
|
|
20
|
+
title: 'Event',
|
|
21
|
+
sticky: true,
|
|
22
|
+
renderer: (login) => (
|
|
23
|
+
<Badge variant={login.isRegistration ? 'default' : 'outline'}>
|
|
24
|
+
{login.isRegistration ? 'Registered' : 'Login'}
|
|
25
|
+
</Badge>
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'device',
|
|
30
|
+
title: 'Device',
|
|
31
|
+
renderer: (login) => <span className="text-sm">{login.device}</span>,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'location',
|
|
35
|
+
title: 'Location',
|
|
36
|
+
renderer: (login) => <span className="text-sm">{login.location}</span>,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'referrerUrl',
|
|
40
|
+
title: 'Landing URL',
|
|
41
|
+
renderer: (login) => (
|
|
42
|
+
<span className="text-sm max-w-xs truncate block">{login.referrerUrl || 'N/A'}</span>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'ipAddress',
|
|
47
|
+
title: 'IP Address',
|
|
48
|
+
renderer: (login) => <span className="text-sm font-mono">{login.ipAddress || 'N/A'}</span>,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'createdAt',
|
|
52
|
+
title: 'Time',
|
|
53
|
+
renderer: (login) => (
|
|
54
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
55
|
+
{formatDateTime(login.createdAt)}
|
|
56
|
+
</span>
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
export default function AdminUserLoginInfoTable({ rows }: { rows: LoginInfoRow[] }) {
|
|
62
|
+
return <DataTable columns={columns} data={rows} getRowId={(login) => login.id} />
|
|
63
|
+
}
|