@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,29 @@
1
+ import { Card, CardContent, CardHeader } from "../../ui/card"
2
+ import { Skeleton } from "../../ui/skeleton"
3
+
4
+ export default function SystemSettingsSkeleton() {
5
+ return (
6
+ <div className="space-y-6">
7
+ <Card>
8
+ <CardHeader>
9
+ <Skeleton className="h-6 w-40" />
10
+ </CardHeader>
11
+ <CardContent className="space-y-4">
12
+ {[...Array(4)].map((_, i) => (
13
+ <Skeleton key={i} className="h-10 w-full" />
14
+ ))}
15
+ </CardContent>
16
+ </Card>
17
+
18
+ <Card>
19
+ <CardHeader>
20
+ <Skeleton className="h-6 w-40" />
21
+ </CardHeader>
22
+ <CardContent className="space-y-4">
23
+ <Skeleton className="h-10 w-full" />
24
+ <Skeleton className="h-20 w-full" />
25
+ </CardContent>
26
+ </Card>
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,13 @@
1
+ import { getUsersFiltered } from "@xk2800/nextjs-template/users/queries"
2
+ import AdminUsersTable from "./adminUsersTable"
3
+
4
+ export default async function UsersSection() {
5
+ const { users, total, page, limit, pages } = await getUsersFiltered({ page: 1, limit: 10 })
6
+
7
+ return (
8
+ <AdminUsersTable
9
+ initialUsers={users}
10
+ initialPagination={{ page, limit, total, pages }}
11
+ />
12
+ )
13
+ }
@@ -0,0 +1,31 @@
1
+ import Link from "next/link"
2
+ import { ShieldCheck, LayoutDashboard } from "lucide-react"
3
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"
4
+ import { Button } from "../ui/button"
5
+
6
+ export default function AdminSection() {
7
+ return (
8
+ <Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
9
+ <CardHeader>
10
+ <div className="flex items-center gap-2">
11
+ <CardTitle>Admin panel</CardTitle>
12
+ <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">
13
+ <ShieldCheck className="size-3" />
14
+ Admin only
15
+ </span>
16
+ </div>
17
+ <CardDescription>
18
+ System stats, user management, and audit logs
19
+ </CardDescription>
20
+ </CardHeader>
21
+ <CardContent>
22
+ <Link href="/dashboard/admin">
23
+ <Button className="w-full">
24
+ <LayoutDashboard />
25
+ Open admin dashboard
26
+ </Button>
27
+ </Link>
28
+ </CardContent>
29
+ </Card>
30
+ )
31
+ }
@@ -0,0 +1,170 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { usePathname } from "next/navigation"
5
+ import {
6
+ LayoutDashboard,
7
+ Settings,
8
+ ShieldCheck,
9
+ Users,
10
+ ScrollText,
11
+ SlidersHorizontal,
12
+ Table,
13
+ LogOut,
14
+ } from "lucide-react"
15
+
16
+ import { authClient } from "@/lib/auth-client"
17
+ import {
18
+ Sidebar,
19
+ SidebarContent,
20
+ SidebarFooter,
21
+ SidebarGroup,
22
+ SidebarGroupContent,
23
+ SidebarGroupLabel,
24
+ SidebarHeader,
25
+ SidebarMenu,
26
+ SidebarMenuButton,
27
+ SidebarMenuItem,
28
+ } from "@/components/ui/sidebar"
29
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
30
+ import { Button } from "@/components/ui/button"
31
+
32
+ export interface AppSidebarUser {
33
+ name: string
34
+ email: string
35
+ image?: string | null
36
+ }
37
+
38
+ export interface AppSidebarProps {
39
+ user: AppSidebarUser
40
+ isAdmin: boolean
41
+ brandName?: React.ReactNode
42
+ brandHref?: string
43
+ }
44
+
45
+ const workspaceLinks = [
46
+ { href: "/dashboard", label: "Overview", icon: LayoutDashboard },
47
+ { href: "/dashboard/data-table", label: "DataTable", icon: Table },
48
+ { href: "/dashboard/settings", label: "Settings", icon: Settings },
49
+ ]
50
+
51
+ const adminLinks = [
52
+ { href: "/dashboard/admin", label: "Admin home", icon: ShieldCheck },
53
+ { href: "/dashboard/admin/users", label: "Users", icon: Users },
54
+ { href: "/dashboard/admin/activity-logs", label: "Audit logs", icon: ScrollText },
55
+ { href: "/dashboard/admin/settings", label: "System settings", icon: SlidersHorizontal },
56
+ ]
57
+
58
+ function initials(name: string) {
59
+ return name
60
+ .split(" ")
61
+ .filter(Boolean)
62
+ .slice(0, 2)
63
+ .map((part) => part[0]?.toUpperCase())
64
+ .join("")
65
+ }
66
+
67
+ export default function AppSidebar({
68
+ user,
69
+ isAdmin,
70
+ brandName = "Template",
71
+ brandHref = "/",
72
+ }: AppSidebarProps) {
73
+ const pathname = usePathname()
74
+
75
+ const handleSignOut = async () => {
76
+ await authClient.signOut({
77
+ fetchOptions: {
78
+ onSuccess: () => {
79
+ window.location.href = "/"
80
+ },
81
+ },
82
+ })
83
+ }
84
+
85
+ return (
86
+ <Sidebar collapsible="icon">
87
+ <SidebarHeader>
88
+ <Link
89
+ href={brandHref}
90
+ className="flex items-center gap-2 px-2 py-1.5 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center"
91
+ >
92
+ <div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-sidebar-primary-foreground" />
93
+ <span className="text-base font-semibold group-data-[collapsible=icon]:hidden">
94
+ {brandName}
95
+ </span>
96
+ </Link>
97
+ </SidebarHeader>
98
+
99
+ <SidebarContent>
100
+ <SidebarGroup>
101
+ <SidebarGroupLabel>Workspace</SidebarGroupLabel>
102
+ <SidebarGroupContent>
103
+ <SidebarMenu>
104
+ {workspaceLinks.map((link) => (
105
+ <SidebarMenuItem key={link.href}>
106
+ <SidebarMenuButton
107
+ asChild
108
+ isActive={pathname === link.href}
109
+ tooltip={link.label}
110
+ >
111
+ <Link href={link.href}>
112
+ <link.icon />
113
+ <span>{link.label}</span>
114
+ </Link>
115
+ </SidebarMenuButton>
116
+ </SidebarMenuItem>
117
+ ))}
118
+ </SidebarMenu>
119
+ </SidebarGroupContent>
120
+ </SidebarGroup>
121
+
122
+ {isAdmin && (
123
+ <SidebarGroup>
124
+ <SidebarGroupLabel>Administration</SidebarGroupLabel>
125
+ <SidebarGroupContent>
126
+ <SidebarMenu>
127
+ {adminLinks.map((link) => (
128
+ <SidebarMenuItem key={link.href}>
129
+ <SidebarMenuButton
130
+ asChild
131
+ isActive={pathname === link.href}
132
+ tooltip={link.label}
133
+ >
134
+ <Link href={link.href}>
135
+ <link.icon />
136
+ <span>{link.label}</span>
137
+ </Link>
138
+ </SidebarMenuButton>
139
+ </SidebarMenuItem>
140
+ ))}
141
+ </SidebarMenu>
142
+ </SidebarGroupContent>
143
+ </SidebarGroup>
144
+ )}
145
+ </SidebarContent>
146
+
147
+ <SidebarFooter>
148
+ <div className="flex items-center gap-2 px-2 py-1.5 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
149
+ <Avatar className="size-8 shrink-0">
150
+ <AvatarImage src={user.image ?? undefined} alt={user.name} />
151
+ <AvatarFallback>{initials(user.name)}</AvatarFallback>
152
+ </Avatar>
153
+ <div className="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
154
+ <span className="truncate text-sm font-medium">{user.name}</span>
155
+ <span className="truncate text-xs text-muted-foreground">{user.email}</span>
156
+ </div>
157
+ <Button
158
+ variant="ghost"
159
+ size="icon-sm"
160
+ onClick={handleSignOut}
161
+ aria-label="Log out"
162
+ className="group-data-[collapsible=icon]:hidden"
163
+ >
164
+ <LogOut />
165
+ </Button>
166
+ </div>
167
+ </SidebarFooter>
168
+ </Sidebar>
169
+ )
170
+ }
@@ -0,0 +1,85 @@
1
+ import Link from "next/link"
2
+ import LogoutButtons from "../auth/logoutButtons"
3
+
4
+ export interface DashboardNavLink {
5
+ href: string
6
+ label: string
7
+ active?: boolean
8
+ }
9
+
10
+ export interface DashboardHeaderProps {
11
+ /**
12
+ * Omit on screens with no session (e.g. a public/marketing page) — the
13
+ * user name and LogoutButtons are only rendered when this is provided.
14
+ */
15
+ userName?: string
16
+ /**
17
+ * Plain text (default: "Template") or any node — e.g. an `<img>`/`<Image>`
18
+ * logo, an inline SVG, or a logo+text combo. Rendered inside a `<Link>`,
19
+ * so avoid nesting another interactive/anchor element inside it.
20
+ */
21
+ brandName?: React.ReactNode
22
+ brandHref?: string
23
+ navLinks?: DashboardNavLink[]
24
+ /**
25
+ * Rendered before the user name — e.g. pass `<ThemeToggle />` if your
26
+ * project uses next-themes. Omitted by default so DashboardHeader has no
27
+ * hard dependency on next-themes (it's an optional peer of the package).
28
+ */
29
+ actions?: React.ReactNode
30
+ }
31
+
32
+ const defaultNavLinks: DashboardNavLink[] = [
33
+ { href: "/dashboard", label: "Dashboard", active: true },
34
+ { href: "/dashboard/settings", label: "Settings" },
35
+ { href: "/", label: "Home" },
36
+ ]
37
+
38
+ export default function DashboardHeader({
39
+ userName,
40
+ brandName = "Template",
41
+ brandHref = "/",
42
+ navLinks = defaultNavLinks,
43
+ actions,
44
+ }: DashboardHeaderProps) {
45
+ return (
46
+ <header className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
47
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
48
+ <div className="flex items-center justify-between">
49
+ <div className="flex items-center gap-8">
50
+ <Link href={brandHref} className="text-xl font-bold">
51
+ {brandName}
52
+ </Link>
53
+ <nav className="hidden md:flex items-center gap-6">
54
+ {navLinks.map((link) => (
55
+ <Link
56
+ key={link.href}
57
+ href={link.href}
58
+ className={
59
+ link.active
60
+ ? "text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition"
61
+ : "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition"
62
+ }
63
+ >
64
+ {link.label}
65
+ </Link>
66
+ ))}
67
+ </nav>
68
+ </div>
69
+
70
+ <div className="flex items-center gap-4">
71
+ {actions}
72
+ {userName && (
73
+ <>
74
+ <span className="text-sm text-gray-600 dark:text-gray-400">
75
+ {userName}
76
+ </span>
77
+ <LogoutButtons />
78
+ </>
79
+ )}
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </header>
84
+ )
85
+ }
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const HEARTBEAT_INTERVAL_MS = 30_000
6
+
7
+ // Renders nothing — just keeps `users.lastActiveAt` fresh while an
8
+ // authenticated user has a dashboard tab open, so admins can see who's
9
+ // currently online (see ONLINE_THRESHOLD_MS in adminUsersPage).
10
+ export default function Heartbeat() {
11
+ useEffect(() => {
12
+ const ping = () => {
13
+ fetch('/api/heartbeat', { method: 'POST' }).catch(() => {})
14
+ }
15
+
16
+ ping()
17
+ const interval = setInterval(ping, HEARTBEAT_INTERVAL_MS)
18
+ return () => clearInterval(interval)
19
+ }, [])
20
+
21
+ return null
22
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { toast } from 'sonner'
6
+ import { authClient, useSession } from '../../lib/auth-client'
7
+ import { Button } from '../ui/button'
8
+
9
+ // Shown app-wide (see app/dashboard/layout.tsx) while the current session is
10
+ // an admin impersonating another user, so it's impossible to forget you're
11
+ // not looking at your own account.
12
+ export default function ImpersonationBanner() {
13
+ const { data: session } = useSession()
14
+ const router = useRouter()
15
+ const [isStopping, setIsStopping] = useState(false)
16
+
17
+ if (!session?.session.impersonatedBy) return null
18
+
19
+ const handleStop = async () => {
20
+ setIsStopping(true)
21
+ try {
22
+ const { error } = await authClient.admin.stopImpersonating()
23
+ if (error) throw new Error(error.message)
24
+ router.push('/dashboard/admin/users')
25
+ router.refresh()
26
+ } catch (error) {
27
+ toast.error('Failed to stop impersonating')
28
+ console.error(error)
29
+ } finally {
30
+ setIsStopping(false)
31
+ }
32
+ }
33
+
34
+ return (
35
+ <div className="sticky top-0 z-50 flex items-center justify-center gap-3 bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950">
36
+ <span>
37
+ You&apos;re viewing as <strong>{session.user.name || session.user.email}</strong>
38
+ </span>
39
+ <Button
40
+ variant="outline"
41
+ size="sm"
42
+ onClick={handleStop}
43
+ disabled={isStopping}
44
+ className="h-7 border-amber-950/30 bg-transparent text-amber-950 hover:bg-amber-950/10"
45
+ >
46
+ {isStopping ? 'Stopping...' : 'Stop impersonating'}
47
+ </Button>
48
+ </div>
49
+ )
50
+ }
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
6
+ import { Button } from '../ui/button'
7
+ import { Input } from '../ui/input'
8
+ import { Skeleton } from '../ui/skeleton'
9
+ import { DataTable, type Column } from '../ui/data-table'
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogAction,
13
+ AlertDialogCancel,
14
+ AlertDialogContent,
15
+ AlertDialogDescription,
16
+ AlertDialogFooter,
17
+ AlertDialogHeader,
18
+ AlertDialogTitle,
19
+ } from '../ui/alert-dialog'
20
+ import { formatDateTime } from '../../lib/formatters'
21
+ import { authClient } from '../../lib/auth-client'
22
+
23
+ type Passkey = {
24
+ id: string
25
+ name?: string | null
26
+ deviceType?: string | null
27
+ createdAt?: Date | string | null
28
+ }
29
+
30
+ const columns: Column<Passkey>[] = [
31
+ { name: 'name', title: 'Name', sticky: true, renderer: (pk) => pk.name || 'Passkey' },
32
+ {
33
+ name: 'deviceType',
34
+ title: 'Type',
35
+ renderer: (pk) => (
36
+ <span className="text-sm text-muted-foreground">{pk.deviceType || '—'}</span>
37
+ ),
38
+ },
39
+ {
40
+ name: 'createdAt',
41
+ title: 'Added',
42
+ renderer: (pk) => (
43
+ <span className="text-sm">{pk.createdAt ? formatDateTime(pk.createdAt) : '—'}</span>
44
+ ),
45
+ },
46
+ ]
47
+
48
+ export default function PasskeysCard() {
49
+ const [passkeys, setPasskeys] = useState<Passkey[] | null>(null)
50
+ const [name, setName] = useState('')
51
+ const [adding, setAdding] = useState(false)
52
+ const [toDelete, setToDelete] = useState<Passkey | null>(null)
53
+ const [deleting, setDeleting] = useState(false)
54
+
55
+ const load = useCallback(async () => {
56
+ const { data, error } = await authClient.passkey.listUserPasskeys()
57
+ if (error) {
58
+ toast.error(error.message || 'Could not load passkeys')
59
+ setPasskeys([])
60
+ return
61
+ }
62
+ setPasskeys((data ?? []) as Passkey[])
63
+ }, [])
64
+
65
+ useEffect(() => {
66
+ void load()
67
+ }, [load])
68
+
69
+ const addPasskey = async () => {
70
+ setAdding(true)
71
+ try {
72
+ const res = await authClient.passkey.addPasskey({ name: name.trim() || undefined })
73
+ if (res?.error) {
74
+ toast.error(res.error.message || 'Could not register passkey')
75
+ return
76
+ }
77
+ toast.success('Passkey registered')
78
+ setName('')
79
+ await load()
80
+ } catch (err) {
81
+ // User cancelled the browser prompt, or no authenticator available.
82
+ toast.error(err instanceof Error ? err.message : 'Passkey registration cancelled')
83
+ } finally {
84
+ setAdding(false)
85
+ }
86
+ }
87
+
88
+ const deletePasskey = async (id: string) => {
89
+ setDeleting(true)
90
+ try {
91
+ const { error } = await authClient.passkey.deletePasskey({ id })
92
+ if (error) {
93
+ toast.error(error.message || 'Could not remove passkey')
94
+ return
95
+ }
96
+ toast.success('Passkey removed')
97
+ setToDelete(null)
98
+ await load()
99
+ } finally {
100
+ setDeleting(false)
101
+ }
102
+ }
103
+
104
+ return (
105
+ <>
106
+ <Card>
107
+ <CardHeader>
108
+ <CardTitle>Passkeys</CardTitle>
109
+ <CardDescription>
110
+ Sign in without a password using Touch ID, Windows Hello, or a
111
+ hardware security key.
112
+ </CardDescription>
113
+ </CardHeader>
114
+ <CardContent className="space-y-4">
115
+ <div className="flex flex-wrap items-end gap-2">
116
+ <div className="flex flex-col gap-1">
117
+ <label htmlFor="passkey-name" className="text-sm font-medium">
118
+ Name (optional)
119
+ </label>
120
+ <Input
121
+ id="passkey-name"
122
+ placeholder="e.g. MacBook Touch ID"
123
+ value={name}
124
+ onChange={(e) => setName(e.target.value)}
125
+ className="w-64 max-w-full"
126
+ />
127
+ </div>
128
+ <Button onClick={addPasskey} disabled={adding}>
129
+ {adding ? 'Waiting for device...' : 'Add a passkey'}
130
+ </Button>
131
+ </div>
132
+
133
+ {passkeys === null ? (
134
+ <Skeleton className="h-20 w-full" />
135
+ ) : passkeys.length === 0 ? (
136
+ <p className="py-6 text-center text-sm text-muted-foreground">
137
+ No passkeys registered yet.
138
+ </p>
139
+ ) : (
140
+ <DataTable
141
+ columns={[
142
+ ...columns,
143
+ {
144
+ name: 'actions',
145
+ title: 'Actions',
146
+ renderer: (pk) => (
147
+ <Button
148
+ variant="destructive"
149
+ size="sm"
150
+ onClick={() => setToDelete(pk)}
151
+ >
152
+ Remove
153
+ </Button>
154
+ ),
155
+ },
156
+ ]}
157
+ data={passkeys}
158
+ getRowId={(pk) => pk.id}
159
+ />
160
+ )}
161
+ </CardContent>
162
+ </Card>
163
+
164
+ <AlertDialog open={!!toDelete} onOpenChange={() => setToDelete(null)}>
165
+ <AlertDialogContent>
166
+ <AlertDialogHeader>
167
+ <AlertDialogTitle>Remove this passkey?</AlertDialogTitle>
168
+ <AlertDialogDescription>
169
+ You won&apos;t be able to sign in with{' '}
170
+ {toDelete?.name ? `"${toDelete.name}"` : 'this passkey'} anymore.
171
+ </AlertDialogDescription>
172
+ </AlertDialogHeader>
173
+ <AlertDialogFooter>
174
+ <AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
175
+ <AlertDialogAction
176
+ onClick={() => toDelete && deletePasskey(toDelete.id)}
177
+ disabled={deleting}
178
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
179
+ >
180
+ {deleting ? 'Removing...' : 'Remove'}
181
+ </AlertDialogAction>
182
+ </AlertDialogFooter>
183
+ </AlertDialogContent>
184
+ </AlertDialog>
185
+ </>
186
+ )
187
+ }
@@ -0,0 +1,42 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
2
+ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
3
+ import { Badge } from "../ui/badge"
4
+ import { getUserInitials } from "../../lib/formatters"
5
+
6
+ type User = {
7
+ id: string
8
+ name: string
9
+ email: string
10
+ image?: string | null
11
+ role: string | null | undefined
12
+ }
13
+
14
+ export default function ProfileCard({ user, isAdmin }: { user: User; isAdmin: boolean }) {
15
+ return (
16
+ <Card>
17
+ <CardHeader>
18
+ <CardTitle>Profile</CardTitle>
19
+ </CardHeader>
20
+ <CardContent>
21
+ <div className="flex items-center gap-4">
22
+ <Avatar className="h-20 w-20">
23
+ <AvatarImage src={user.image || undefined} alt={user.name} />
24
+ <AvatarFallback className="text-2xl">
25
+ {getUserInitials(user.name)}
26
+ </AvatarFallback>
27
+ </Avatar>
28
+
29
+ <div className="flex-1">
30
+ <div className="flex items-center gap-2 mb-1">
31
+ <h3 className="text-xl font-semibold">{user.name}</h3>
32
+ <Badge variant={isAdmin ? 'default' : 'secondary'}>
33
+ {user.role}
34
+ </Badge>
35
+ </div>
36
+ <p className="text-sm text-gray-600">{user.email}</p>
37
+ </div>
38
+ </div>
39
+ </CardContent>
40
+ </Card>
41
+ )
42
+ }
@@ -0,0 +1,18 @@
1
+ import { AlertTriangle } from "lucide-react"
2
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
3
+
4
+ export default function SectionErrorFallback({ title }: { title: string }) {
5
+ return (
6
+ <Card>
7
+ <CardHeader>
8
+ <CardTitle>{title}</CardTitle>
9
+ </CardHeader>
10
+ <CardContent>
11
+ <p className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 py-4">
12
+ <AlertTriangle className="size-4 text-destructive shrink-0" />
13
+ Something went wrong loading this section. Try refreshing the page.
14
+ </p>
15
+ </CardContent>
16
+ </Card>
17
+ )
18
+ }