@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,93 @@
1
+ 'use client'
2
+
3
+ import { type DeviceThrottleRow } from "@xk2800/nextjs-template/admin/queries"
4
+ import { Badge } from "../../ui/badge"
5
+ import { DataTable, type Column } from "../../ui/data-table"
6
+ import { formatDateTime } from "../../../lib/formatters"
7
+
8
+ function statusBadge(row: DeviceThrottleRow) {
9
+ if (row.blocked && row.windowActive) return <Badge variant="destructive">Blocked</Badge>
10
+ if (row.windowActive) return <Badge variant="secondary">Watching</Badge>
11
+ return <Badge variant="outline">Cooled off</Badge>
12
+ }
13
+
14
+ const columns: Column<DeviceThrottleRow>[] = [
15
+ { name: 'status', title: 'Status', sticky: true, renderer: (row) => statusBadge(row) },
16
+ {
17
+ name: 'fingerprint',
18
+ title: 'Fingerprint',
19
+ minWidth: 180,
20
+ renderer: (row) => (
21
+ <span className="font-mono text-xs select-all whitespace-nowrap">{row.fingerprint}</span>
22
+ ),
23
+ },
24
+ {
25
+ name: 'ipAddress',
26
+ title: 'IP address',
27
+ renderer: (row) => (
28
+ <span className="font-mono text-xs select-all">{row.ipAddress || "Unknown"}</span>
29
+ ),
30
+ },
31
+ {
32
+ name: 'device',
33
+ title: 'Device',
34
+ renderer: (row) => (
35
+ <span className="text-sm">
36
+ {[row.browser, row.os].filter(Boolean).join(" · ") || "Unknown"}
37
+ <span className="block text-xs text-muted-foreground">{row.deviceType}</span>
38
+ </span>
39
+ ),
40
+ },
41
+ {
42
+ name: 'location',
43
+ title: 'Location',
44
+ renderer: (row) => (
45
+ <span className="text-sm">
46
+ {[row.city, row.country].filter(Boolean).join(", ") || "Unknown"}
47
+ </span>
48
+ ),
49
+ },
50
+ {
51
+ name: 'count',
52
+ title: 'Attempts',
53
+ renderer: (row) => (
54
+ <span className="block text-right font-medium tabular-nums">{row.count}</span>
55
+ ),
56
+ },
57
+ {
58
+ name: 'lastKind',
59
+ title: 'Kind',
60
+ renderer: (row) => (
61
+ <span className="text-xs text-muted-foreground">
62
+ {row.lastKind === "signup" ? "sign-up" : "sign-in"}
63
+ </span>
64
+ ),
65
+ },
66
+ {
67
+ name: 'lastEmail',
68
+ title: 'Target email',
69
+ renderer: (row) => <span className="text-sm select-all">{row.lastEmail || "—"}</span>,
70
+ },
71
+ {
72
+ name: 'windowStart',
73
+ title: 'Window started',
74
+ renderer: (row) => (
75
+ <span className="text-xs whitespace-nowrap text-muted-foreground">
76
+ {formatDateTime(row.windowStart)}
77
+ </span>
78
+ ),
79
+ },
80
+ {
81
+ name: 'lastAttemptAt',
82
+ title: 'Last attempt',
83
+ renderer: (row) => (
84
+ <span className="text-xs whitespace-nowrap text-muted-foreground">
85
+ {formatDateTime(row.lastAttemptAt)}
86
+ </span>
87
+ ),
88
+ },
89
+ ]
90
+
91
+ export default function DeviceThrottleTable({ rows }: { rows: DeviceThrottleRow[] }) {
92
+ return <DataTable columns={columns} data={rows} getRowId={(row) => row.fingerprint} />
93
+ }
@@ -0,0 +1,73 @@
1
+ import Link from "next/link"
2
+ import { ShieldAlert } from "lucide-react"
3
+ import { getRecentImpersonationEvents } from "@xk2800/nextjs-template/activity/queries"
4
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
5
+ import { Badge } from "../../ui/badge"
6
+ import { formatDateTime } from "../../../lib/formatters"
7
+
8
+ // Dedicated, always-visible feed of who's been logging in as whom — so an
9
+ // admin can see this at a glance instead of having to remember to filter the
10
+ // generic activity log for it. Rendered on both the Users page (where the
11
+ // impersonated accounts live) and the Audit Logs page. Styled like
12
+ // adminSection.tsx's amber callout to read as a distinct, security-sensitive
13
+ // section rather than just another card in the list.
14
+ interface ImpersonationLogCardProps {
15
+ // Skip the "View full log" link on the Audit Logs page itself, since the
16
+ // full filterable table is already right below this card there.
17
+ showViewAllLink?: boolean
18
+ }
19
+
20
+ export default async function ImpersonationLogCard({ showViewAllLink = true }: ImpersonationLogCardProps) {
21
+ const events = await getRecentImpersonationEvents(10)
22
+
23
+ return (
24
+ <Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
25
+ <CardHeader>
26
+ <div className="flex items-center gap-2">
27
+ <CardTitle>Impersonation log</CardTitle>
28
+ <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">
29
+ <ShieldAlert className="size-3" />
30
+ Security
31
+ </span>
32
+ </div>
33
+ <CardDescription>Most recent admin impersonation activity</CardDescription>
34
+ </CardHeader>
35
+ <CardContent>
36
+ {events.length === 0 ? (
37
+ <p className="text-sm text-gray-500 py-4">No impersonation activity recorded</p>
38
+ ) : (
39
+ <ul className="divide-y divide-border">
40
+ {events.map((event) => (
41
+ <li key={event.id} className="flex items-center justify-between gap-4 py-3">
42
+ <div className="flex items-center gap-3">
43
+ <Badge variant={event.action === 'impersonation_started' ? 'destructive' : 'secondary'}>
44
+ {event.action === 'impersonation_started' ? 'Started' : 'Stopped'}
45
+ </Badge>
46
+ <div>
47
+ <p className="text-sm">{event.description}</p>
48
+ <p className="text-xs text-gray-500 dark:text-gray-400">
49
+ by {event.adminName || event.adminEmail || event.adminId}
50
+ </p>
51
+ </div>
52
+ </div>
53
+ <span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
54
+ {formatDateTime(event.createdAt)}
55
+ </span>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ )}
60
+ {showViewAllLink && (
61
+ <div className="mt-4 pt-4 border-t border-amber-200 dark:border-amber-900/40">
62
+ <Link
63
+ href="/dashboard/admin/activity-logs"
64
+ className="text-sm font-medium text-amber-900 hover:underline dark:text-amber-400"
65
+ >
66
+ View full audit log →
67
+ </Link>
68
+ </div>
69
+ )}
70
+ </CardContent>
71
+ </Card>
72
+ )
73
+ }
@@ -0,0 +1,35 @@
1
+ import { MonitorSmartphone } from "lucide-react"
2
+ import { getRecentNewDevices } from "@xk2800/nextjs-template/admin/queries"
3
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
4
+ import NewDeviceTable from "./newDeviceTable"
5
+
6
+ // First sign-in from a device for each account (written by
7
+ // app/api/device-check/route.ts). The user and every admin also get an email;
8
+ // this is the durable, at-a-glance surface. If a row looks like a hijack,
9
+ // "Revoke sessions" logs that account out everywhere in one click, or open the
10
+ // account for the full session list / activity trail.
11
+ export default async function NewDeviceCard() {
12
+ const rows = await getRecentNewDevices(20)
13
+
14
+ return (
15
+ <Card>
16
+ <CardHeader>
17
+ <div className="flex flex-wrap items-center gap-2">
18
+ <MonitorSmartphone className="size-4" />
19
+ <CardTitle>New devices</CardTitle>
20
+ </div>
21
+ <CardDescription>
22
+ First sign-in from a device for each account, newest first. Revoke the
23
+ account&apos;s sessions if the sign-in wasn&apos;t them.
24
+ </CardDescription>
25
+ </CardHeader>
26
+ <CardContent>
27
+ {rows.length === 0 ? (
28
+ <p className="text-sm text-gray-500 py-4">No new-device sign-ins recorded yet</p>
29
+ ) : (
30
+ <NewDeviceTable rows={rows} />
31
+ )}
32
+ </CardContent>
33
+ </Card>
34
+ )
35
+ }
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import Link from "next/link"
4
+ import { type RecentNewDeviceRow } from "@xk2800/nextjs-template/admin/queries"
5
+ import { DataTable, type Column } from "../../ui/data-table"
6
+ import { formatDateTime } from "../../../lib/formatters"
7
+ import RevokeAllSessionsButton from "./revokeAllSessionsButton"
8
+
9
+ const columns: Column<RecentNewDeviceRow>[] = [
10
+ {
11
+ name: 'account',
12
+ title: 'Account',
13
+ sticky: true,
14
+ renderer: (row) => (
15
+ <span className="text-sm">
16
+ <Link href={`/dashboard/admin/users/${row.userId}`} className="font-medium hover:underline">
17
+ {row.userName}
18
+ </Link>
19
+ <span className="block text-xs text-muted-foreground">{row.userEmail}</span>
20
+ </span>
21
+ ),
22
+ },
23
+ {
24
+ name: 'device',
25
+ title: 'Device',
26
+ renderer: (row) => (
27
+ <span className="text-sm">
28
+ {[row.browser, row.os].filter(Boolean).join(" · ") || "Unknown"}
29
+ <span className="block text-xs text-muted-foreground">{row.deviceType}</span>
30
+ </span>
31
+ ),
32
+ },
33
+ {
34
+ name: 'location',
35
+ title: 'Location',
36
+ renderer: (row) => (
37
+ <span className="text-sm">
38
+ {[row.city, row.country].filter(Boolean).join(", ") || "Unknown"}
39
+ </span>
40
+ ),
41
+ },
42
+ {
43
+ name: 'ipAddress',
44
+ title: 'IP address',
45
+ renderer: (row) => (
46
+ <span className="font-mono text-xs select-all">{row.ipAddress || "Unknown"}</span>
47
+ ),
48
+ },
49
+ {
50
+ name: 'createdAt',
51
+ title: 'When',
52
+ renderer: (row) => (
53
+ <span className="text-xs whitespace-nowrap text-muted-foreground">
54
+ {formatDateTime(row.createdAt)}
55
+ </span>
56
+ ),
57
+ },
58
+ {
59
+ name: 'actions',
60
+ title: 'Action',
61
+ renderer: (row) => (
62
+ <span className="block text-right">
63
+ <RevokeAllSessionsButton userId={row.userId} label="Revoke sessions" />
64
+ </span>
65
+ ),
66
+ },
67
+ ]
68
+
69
+ export default function NewDeviceTable({ rows }: { rows: RecentNewDeviceRow[] }) {
70
+ return <DataTable columns={columns} data={rows} getRowId={(row) => row.id} />
71
+ }
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { toast } from "sonner"
6
+ import { Button } from "../../ui/button"
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogAction,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ } from "../../ui/alert-dialog"
17
+
18
+ // Kills every session for one account — the fast path when a new-device alert
19
+ // turns out to be a hijack. Hits the existing admin route with no sessionId,
20
+ // which deletes them all and writes a session_revoked audit entry.
21
+ export default function RevokeAllSessionsButton({
22
+ userId,
23
+ label = "Revoke all sessions",
24
+ size = "sm",
25
+ }: {
26
+ userId: string
27
+ label?: string
28
+ size?: "sm" | "default"
29
+ }) {
30
+ const router = useRouter()
31
+ const [open, setOpen] = useState(false)
32
+ const [busy, setBusy] = useState(false)
33
+
34
+ const revoke = async () => {
35
+ setBusy(true)
36
+ try {
37
+ const res = await fetch(`/api/admin/users/${userId}/sessions`, { method: "DELETE" })
38
+ if (!res.ok) throw new Error()
39
+ toast.success("All sessions revoked")
40
+ setOpen(false)
41
+ router.refresh()
42
+ } catch {
43
+ toast.error("Failed to revoke sessions")
44
+ } finally {
45
+ setBusy(false)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <>
51
+ <Button variant="destructive" size={size} onClick={() => setOpen(true)}>
52
+ {label}
53
+ </Button>
54
+ <AlertDialog open={open} onOpenChange={setOpen}>
55
+ <AlertDialogContent>
56
+ <AlertDialogHeader>
57
+ <AlertDialogTitle>Revoke all sessions?</AlertDialogTitle>
58
+ <AlertDialogDescription>
59
+ Logs this account out of every device. Cached sessions may stay valid for
60
+ up to 5 minutes. The user can sign back in normally afterwards.
61
+ </AlertDialogDescription>
62
+ </AlertDialogHeader>
63
+ <AlertDialogFooter>
64
+ <AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
65
+ <AlertDialogAction
66
+ onClick={revoke}
67
+ disabled={busy}
68
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
69
+ >
70
+ {busy ? "Revoking..." : "Revoke all"}
71
+ </AlertDialogAction>
72
+ </AlertDialogFooter>
73
+ </AlertDialogContent>
74
+ </AlertDialog>
75
+ </>
76
+ )
77
+ }
@@ -0,0 +1,42 @@
1
+ import { Fingerprint, ShieldAlert } from "lucide-react"
2
+ import { getSharedDeviceAccounts } from "@xk2800/nextjs-template/admin/queries"
3
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
4
+ import { Badge } from "../../ui/badge"
5
+ import SharedDeviceTable from "./sharedDeviceTable"
6
+
7
+ // One physical device (FingerprintJS visitorId) that 3+ accounts have signed in
8
+ // from — the multi-account / trial-abuse signal. Each account links to its admin
9
+ // detail page, where it can be banned. Sits next to the Device throttle and New
10
+ // devices cards; a shared device that's also failing logins shows on all three.
11
+ export default async function SharedDeviceCard() {
12
+ const devices = await getSharedDeviceAccounts(20)
13
+
14
+ return (
15
+ <Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
16
+ <CardHeader>
17
+ <div className="flex flex-wrap items-center gap-2">
18
+ <Fingerprint className="size-4" />
19
+ <CardTitle>Shared devices</CardTitle>
20
+ <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">
21
+ <ShieldAlert className="size-3" />
22
+ Security
23
+ </span>
24
+ {devices.length > 0 && (
25
+ <Badge variant="destructive">{devices.length} flagged</Badge>
26
+ )}
27
+ </div>
28
+ <CardDescription>
29
+ One device signed into by three or more accounts — a multi-account /
30
+ trial-abuse signal. Open an account to ban it.
31
+ </CardDescription>
32
+ </CardHeader>
33
+ <CardContent>
34
+ {devices.length === 0 ? (
35
+ <p className="text-sm text-gray-500 py-4">No devices shared across 3+ accounts</p>
36
+ ) : (
37
+ <SharedDeviceTable devices={devices} />
38
+ )}
39
+ </CardContent>
40
+ </Card>
41
+ )
42
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import Link from "next/link"
4
+ import { type SharedDeviceRow } from "@xk2800/nextjs-template/admin/queries"
5
+ import { Badge } from "../../ui/badge"
6
+ import { DataTable, type Column } from "../../ui/data-table"
7
+ import { formatDateTime } from "../../../lib/formatters"
8
+
9
+ const columns: Column<SharedDeviceRow>[] = [
10
+ {
11
+ name: 'visitorId',
12
+ title: 'Device fingerprint',
13
+ sticky: true,
14
+ renderer: (d) => (
15
+ <span className="font-mono text-xs select-all break-all">{d.visitorId}</span>
16
+ ),
17
+ },
18
+ {
19
+ name: 'device',
20
+ title: 'Device',
21
+ renderer: (d) => (
22
+ <span className="text-sm">
23
+ {[d.browser, d.os].filter(Boolean).join(" · ") || "Unknown"}
24
+ <span className="block text-xs text-muted-foreground">{d.deviceType}</span>
25
+ </span>
26
+ ),
27
+ },
28
+ {
29
+ name: 'location',
30
+ title: 'Location',
31
+ renderer: (d) => (
32
+ <span className="text-sm">{[d.city, d.country].filter(Boolean).join(", ") || "Unknown"}</span>
33
+ ),
34
+ },
35
+ {
36
+ name: 'accountCount',
37
+ title: 'Accounts',
38
+ renderer: (d) => (
39
+ <span className="block text-right font-medium tabular-nums">{d.accountCount}</span>
40
+ ),
41
+ },
42
+ {
43
+ name: 'accounts',
44
+ title: 'Shared by',
45
+ minWidth: 220,
46
+ renderer: (d) => (
47
+ <ul className="space-y-1 text-sm">
48
+ {d.accounts.map((a) => (
49
+ <li key={a.userId}>
50
+ <Link href={`/dashboard/admin/users/${a.userId}`} className="font-medium hover:underline">
51
+ {a.userName}
52
+ </Link>
53
+ {a.banned && <Badge variant="outline" className="ml-2">Banned</Badge>}
54
+ <span className="block text-xs text-muted-foreground">
55
+ {a.userEmail} · first seen {formatDateTime(a.firstSeenAt)}
56
+ </span>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ ),
61
+ },
62
+ {
63
+ name: 'lastSeenAt',
64
+ title: 'Last seen',
65
+ renderer: (d) => (
66
+ <span className="text-xs whitespace-nowrap text-muted-foreground">
67
+ {formatDateTime(d.lastSeenAt)}
68
+ </span>
69
+ ),
70
+ },
71
+ ]
72
+
73
+ export default function SharedDeviceTable({ devices }: { devices: SharedDeviceRow[] }) {
74
+ return <DataTable columns={columns} data={devices} getRowId={(d) => d.visitorId} />
75
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ import { useState } from "react"
4
+ import { useRouter } from "next/navigation"
5
+ import { toast } from "sonner"
6
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
7
+ import { Switch } from "../../ui/switch"
8
+ import { Label } from "../../ui/label"
9
+ import { Textarea } from "../../ui/textarea"
10
+ import { Button } from "../../ui/button"
11
+ import type { SystemSettings } from "@xk2800/nextjs-template/settings/queries"
12
+
13
+ interface ConfiguredAuthFlags {
14
+ google: boolean
15
+ emailPassword: boolean
16
+ oneTap: boolean
17
+ }
18
+
19
+ interface SystemSettingsFormProps {
20
+ initialSettings: SystemSettings
21
+ configuredFlags: ConfiguredAuthFlags
22
+ }
23
+
24
+ export default function SystemSettingsForm({ initialSettings, configuredFlags }: SystemSettingsFormProps) {
25
+ const router = useRouter()
26
+ const [settings, setSettings] = useState(initialSettings)
27
+ const [isSaving, setIsSaving] = useState(false)
28
+
29
+ // "Left enabled" means actually usable — configured (env) AND toggled on
30
+ // (DB) — not just the DB toggle, which can look "on" while being fully
31
+ // inert if the env var for that method is off.
32
+ const anySignInMethodLeft =
33
+ (configuredFlags.google && settings.authEnableGoogle) ||
34
+ (configuredFlags.emailPassword && settings.authEnableEmailPassword)
35
+
36
+ const handleSave = async () => {
37
+ setIsSaving(true)
38
+ try {
39
+ const response = await fetch('/api/admin/settings', {
40
+ method: 'PATCH',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({
43
+ maintenanceMode: settings.maintenanceMode,
44
+ maintenanceMessage: settings.maintenanceMessage,
45
+ authEnableGoogle: settings.authEnableGoogle,
46
+ authEnableEmailPassword: settings.authEnableEmailPassword,
47
+ authEnableOneTap: settings.authEnableOneTap,
48
+ enableSessionRevocation: settings.enableSessionRevocation,
49
+ }),
50
+ })
51
+
52
+ const data = await response.json()
53
+ if (!response.ok) throw new Error(data.error || 'Failed to save settings')
54
+
55
+ toast.success('Settings saved')
56
+ router.refresh()
57
+ } catch (error) {
58
+ toast.error(error instanceof Error ? error.message : 'Failed to save settings')
59
+ } finally {
60
+ setIsSaving(false)
61
+ }
62
+ }
63
+
64
+ return (
65
+ <div className="space-y-6">
66
+ <Card>
67
+ <CardHeader>
68
+ <CardTitle>Authentication & Sessions</CardTitle>
69
+ <CardDescription>
70
+ Live toggles on top of what&apos;s configured via environment variables —
71
+ a method disabled here stops working immediately, without a redeploy
72
+ </CardDescription>
73
+ </CardHeader>
74
+ <CardContent className="space-y-4">
75
+ <div className="flex items-center justify-between">
76
+ <div>
77
+ <Label htmlFor="google-toggle">Google sign-in</Label>
78
+ <p className="text-sm text-gray-500">Show the Google button and accept sign-ins</p>
79
+ {!configuredFlags.google && (
80
+ <p className="text-sm text-amber-600 dark:text-amber-500">
81
+ Not configured for this deployment — set AUTH_ENABLE_GOOGLE=true (and Google credentials)
82
+ to unlock this toggle
83
+ </p>
84
+ )}
85
+ </div>
86
+ <Switch
87
+ id="google-toggle"
88
+ checked={settings.authEnableGoogle}
89
+ disabled={!configuredFlags.google}
90
+ onCheckedChange={(checked) => setSettings({ ...settings, authEnableGoogle: checked })}
91
+ />
92
+ </div>
93
+
94
+ <div className="flex items-center justify-between">
95
+ <div>
96
+ <Label htmlFor="email-toggle">Email &amp; password sign-in</Label>
97
+ <p className="text-sm text-gray-500">Show the email/password form and signup page</p>
98
+ {!configuredFlags.emailPassword && (
99
+ <p className="text-sm text-amber-600 dark:text-amber-500">
100
+ Not configured for this deployment — set AUTH_ENABLE_EMAIL_PASSWORD=true to unlock this toggle
101
+ </p>
102
+ )}
103
+ </div>
104
+ <Switch
105
+ id="email-toggle"
106
+ checked={settings.authEnableEmailPassword}
107
+ disabled={!configuredFlags.emailPassword}
108
+ onCheckedChange={(checked) => setSettings({ ...settings, authEnableEmailPassword: checked })}
109
+ />
110
+ </div>
111
+
112
+ <div className="flex items-center justify-between">
113
+ <div>
114
+ <Label htmlFor="onetap-toggle">Google One Tap</Label>
115
+ <p className="text-sm text-gray-500">
116
+ Show the One Tap prompt on load — a shortcut into Google sign-in, not a
117
+ separate method; it stops working if Google sign-in above is off
118
+ </p>
119
+ {!configuredFlags.oneTap ? (
120
+ <p className="text-sm text-amber-600 dark:text-amber-500">
121
+ Not configured for this deployment — set AUTH_ENABLE_ONE_TAP=true
122
+ (and NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP=true) to unlock this toggle
123
+ </p>
124
+ ) : !settings.authEnableGoogle && (
125
+ <p className="text-sm text-amber-600 dark:text-amber-500">
126
+ Requires Google sign-in to be enabled above
127
+ </p>
128
+ )}
129
+ </div>
130
+ <Switch
131
+ id="onetap-toggle"
132
+ checked={settings.authEnableOneTap}
133
+ disabled={!configuredFlags.oneTap}
134
+ onCheckedChange={(checked) => setSettings({ ...settings, authEnableOneTap: checked })}
135
+ />
136
+ </div>
137
+
138
+ <div className="flex items-center justify-between">
139
+ <div>
140
+ <Label htmlFor="revoke-toggle">Session revocation</Label>
141
+ <p className="text-sm text-gray-500">Let users revoke their own sessions from Settings</p>
142
+ </div>
143
+ <Switch
144
+ id="revoke-toggle"
145
+ checked={settings.enableSessionRevocation}
146
+ onCheckedChange={(checked) => setSettings({ ...settings, enableSessionRevocation: checked })}
147
+ />
148
+ </div>
149
+
150
+ {!anySignInMethodLeft && (
151
+ <p className="text-sm text-destructive">
152
+ At least one sign-in method must stay enabled, or nobody will be able to sign in.
153
+ </p>
154
+ )}
155
+ </CardContent>
156
+ </Card>
157
+
158
+ <Card>
159
+ <CardHeader>
160
+ <CardTitle>Maintenance Mode</CardTitle>
161
+ <CardDescription>
162
+ Blocks the dashboard for non-admins and shows a maintenance page instead — admins keep full access
163
+ </CardDescription>
164
+ </CardHeader>
165
+ <CardContent className="space-y-4">
166
+ <div className="flex items-center justify-between">
167
+ <div>
168
+ <Label htmlFor="maintenance-toggle">Maintenance mode</Label>
169
+ <p className="text-sm text-gray-500">Non-admins are redirected to /maintenance</p>
170
+ </div>
171
+ <Switch
172
+ id="maintenance-toggle"
173
+ checked={settings.maintenanceMode}
174
+ onCheckedChange={(checked) => setSettings({ ...settings, maintenanceMode: checked })}
175
+ />
176
+ </div>
177
+
178
+ {settings.maintenanceMode && (
179
+ <div className="space-y-2">
180
+ <Label htmlFor="maintenance-message">Message shown to visitors (optional)</Label>
181
+ <Textarea
182
+ id="maintenance-message"
183
+ value={settings.maintenanceMessage ?? ''}
184
+ onChange={(e) => setSettings({ ...settings, maintenanceMessage: e.target.value || null })}
185
+ placeholder="We'll be back shortly."
186
+ maxLength={2000}
187
+ />
188
+ </div>
189
+ )}
190
+ </CardContent>
191
+ </Card>
192
+
193
+ <div className="flex justify-end">
194
+ <Button onClick={handleSave} disabled={isSaving || !anySignInMethodLeft}>
195
+ {isSaving ? 'Saving...' : 'Save changes'}
196
+ </Button>
197
+ </div>
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,9 @@
1
+ import { getSystemSettings, getConfiguredAuthFlags } from "@xk2800/nextjs-template/settings/queries"
2
+ import SystemSettingsForm from "./systemSettingsForm"
3
+
4
+ export default async function SystemSettingsSection() {
5
+ const settings = await getSystemSettings()
6
+ const configuredFlags = getConfiguredAuthFlags()
7
+
8
+ return <SystemSettingsForm initialSettings={settings} configuredFlags={configuredFlags} />
9
+ }