@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,194 @@
1
+ 'use client'
2
+
3
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"
4
+ import { Badge } from "../ui/badge"
5
+ import { Button } from "../ui/button"
6
+ import { DataTable, type Column } from "../ui/data-table"
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogAction,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ } from "../ui/alert-dialog"
17
+ import { formatDateTime } from "../../lib/formatters"
18
+ import { useState } from "react"
19
+ import { useRouter } from "next/navigation"
20
+ import { toast } from "sonner"
21
+
22
+ interface Session {
23
+ id: string
24
+ createdAt: Date
25
+ expiresAt: Date
26
+ ipAddress: string | null
27
+ userAgent: string | null
28
+ token: string
29
+ }
30
+
31
+ interface SessionsCardProps {
32
+ userId: string
33
+ initialSessions: Session[]
34
+ currentSessionToken: string
35
+ enableSessionRevocation: boolean
36
+ }
37
+
38
+ const baseColumns: Column<Session>[] = [
39
+ {
40
+ name: 'createdAt',
41
+ title: 'Created',
42
+ renderer: (session) => <span className="text-sm">{formatDateTime(session.createdAt)}</span>,
43
+ },
44
+ {
45
+ name: 'expiresAt',
46
+ title: 'Expires',
47
+ renderer: (session) => <span className="text-sm">{formatDateTime(session.expiresAt)}</span>,
48
+ },
49
+ {
50
+ name: 'ipAddress',
51
+ title: 'IP Address',
52
+ renderer: (session) => <span className="text-sm font-mono">{session.ipAddress || 'N/A'}</span>,
53
+ },
54
+ {
55
+ name: 'userAgent',
56
+ title: 'User Agent',
57
+ renderer: (session) => (
58
+ <span className="text-sm max-w-xs truncate block">{session.userAgent || 'N/A'}</span>
59
+ ),
60
+ },
61
+ ]
62
+
63
+ export default function SessionsCard({
64
+ userId,
65
+ initialSessions,
66
+ currentSessionToken,
67
+ enableSessionRevocation
68
+ }: SessionsCardProps) {
69
+ const router = useRouter()
70
+ const [sessions, setSessions] = useState(initialSessions)
71
+ const [sessionToRevoke, setSessionToRevoke] = useState<string | null>(null)
72
+ const [isRevoking, setIsRevoking] = useState(false)
73
+
74
+ const now = new Date()
75
+ const activeSessions = sessions.filter(s => new Date(s.expiresAt) > now)
76
+ const expiredSessions = sessions.filter(s => new Date(s.expiresAt) <= now)
77
+
78
+ const handleRevokeSession = async (sessionId: string) => {
79
+ setIsRevoking(true)
80
+ try {
81
+ const response = await fetch('/api/sessions/revoke', {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ sessionId }),
85
+ })
86
+
87
+ if (!response.ok) {
88
+ throw new Error('Failed to revoke session')
89
+ }
90
+
91
+ toast.success('Session revoked successfully')
92
+ setSessions(sessions.filter(s => s.id !== sessionId))
93
+ setSessionToRevoke(null)
94
+ router.refresh()
95
+ } catch (error) {
96
+ toast.error('Failed to revoke session')
97
+ console.error(error)
98
+ } finally {
99
+ setIsRevoking(false)
100
+ }
101
+ }
102
+
103
+ const isCurrentSession = (token: string) => token === currentSessionToken
104
+
105
+ return (
106
+ <>
107
+ <Card>
108
+ <CardHeader>
109
+ <CardTitle>Active Sessions</CardTitle>
110
+ <CardDescription>
111
+ You have {activeSessions.length} active session(s)
112
+ </CardDescription>
113
+ </CardHeader>
114
+ <CardContent>
115
+ {activeSessions.length === 0 ? (
116
+ <p className="text-sm text-gray-600 dark:text-gray-400 text-center py-8">
117
+ No active sessions found
118
+ </p>
119
+ ) : (
120
+ <DataTable
121
+ columns={[
122
+ {
123
+ name: 'status',
124
+ title: 'Status',
125
+ renderer: (session) => {
126
+ const isCurrent = isCurrentSession(session.token)
127
+ return (
128
+ <Badge variant={isCurrent ? "default" : "secondary"}>
129
+ {isCurrent ? "Current" : "Active"}
130
+ </Badge>
131
+ )
132
+ },
133
+ },
134
+ ...baseColumns,
135
+ ...(enableSessionRevocation
136
+ ? [
137
+ {
138
+ name: 'actions',
139
+ title: 'Actions',
140
+ renderer: (session: Session) =>
141
+ !isCurrentSession(session.token) && (
142
+ <Button
143
+ variant="destructive"
144
+ size="sm"
145
+ onClick={() => setSessionToRevoke(session.id)}
146
+ >
147
+ Revoke
148
+ </Button>
149
+ ),
150
+ },
151
+ ]
152
+ : []),
153
+ ]}
154
+ data={activeSessions}
155
+ getRowId={(session) => session.id}
156
+ />
157
+ )}
158
+
159
+ {/* Show expired sessions count if any */}
160
+ {expiredSessions.length > 0 && (
161
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
162
+ {expiredSessions.length} expired session(s) not shown
163
+ </p>
164
+ )}
165
+ </CardContent>
166
+ </Card>
167
+
168
+ <AlertDialog
169
+ open={!!sessionToRevoke}
170
+ onOpenChange={() => setSessionToRevoke(null)}
171
+ >
172
+ <AlertDialogContent>
173
+ <AlertDialogHeader>
174
+ <AlertDialogTitle>Revoke Session?</AlertDialogTitle>
175
+ <AlertDialogDescription>
176
+ This will log out the device associated with this session.
177
+ This action cannot be undone.
178
+ </AlertDialogDescription>
179
+ </AlertDialogHeader>
180
+ <AlertDialogFooter>
181
+ <AlertDialogCancel disabled={isRevoking}>Cancel</AlertDialogCancel>
182
+ <AlertDialogAction
183
+ onClick={() => sessionToRevoke && handleRevokeSession(sessionToRevoke)}
184
+ disabled={isRevoking}
185
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
186
+ >
187
+ {isRevoking ? "Revoking..." : "Revoke Session"}
188
+ </AlertDialogAction>
189
+ </AlertDialogFooter>
190
+ </AlertDialogContent>
191
+ </AlertDialog>
192
+ </>
193
+ )
194
+ }
@@ -0,0 +1,20 @@
1
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"
2
+ import { Skeleton } from "../ui/skeleton"
3
+
4
+ export default function SessionsCardSkeleton() {
5
+ return (
6
+ <Card>
7
+ <CardHeader>
8
+ <CardTitle>Active Sessions</CardTitle>
9
+ <CardDescription>Loading...</CardDescription>
10
+ </CardHeader>
11
+ <CardContent>
12
+ <div className="space-y-4">
13
+ {[...Array(5)].map((_, i) => (
14
+ <Skeleton key={i} className="h-12 w-full" />
15
+ ))}
16
+ </div>
17
+ </CardContent>
18
+ </Card>
19
+ )
20
+ }
@@ -0,0 +1,22 @@
1
+ import { cookies } from "next/headers"
2
+ import { getUserSessions } from "@xk2800/nextjs-template/sessions/queries"
3
+ import { getSystemSettings } from "@xk2800/nextjs-template/settings/queries"
4
+ import SessionsCard from "./sessionsCard"
5
+
6
+ export default async function SessionsSection({ userId }: { userId: string }) {
7
+ const [allSessions, settings] = await Promise.all([
8
+ getUserSessions(userId),
9
+ getSystemSettings(),
10
+ ])
11
+ const cookieStore = await cookies()
12
+ const currentSessionToken = cookieStore.get('better-auth.session_token')?.value || ''
13
+
14
+ return (
15
+ <SessionsCard
16
+ userId={userId}
17
+ initialSessions={allSessions}
18
+ currentSessionToken={currentSessionToken}
19
+ enableSessionRevocation={settings.enableSessionRevocation}
20
+ />
21
+ )
22
+ }
@@ -0,0 +1,344 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import QRCode from 'react-qr-code'
6
+ import { toast } from 'sonner'
7
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
8
+ import { Button } from '../ui/button'
9
+ import { Input } from '../ui/input'
10
+ import { Label } from '../ui/label'
11
+ import { PasswordInput } from '../ui/password-input'
12
+ import { Badge } from '../ui/badge'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '../ui/dialog'
21
+ import { useSession, authClient } from '../../lib/auth-client'
22
+
23
+ type SetupStep = 'password' | 'verify' | 'backup'
24
+
25
+ function BackupCodeList({ codes }: { codes: string[] }) {
26
+ return (
27
+ <div className="space-y-3">
28
+ <p className="text-sm text-muted-foreground">
29
+ Save these backup codes somewhere safe. Each one works once if you lose
30
+ access to your authenticator. They won&apos;t be shown again.
31
+ </p>
32
+ <div className="grid grid-cols-2 gap-2 rounded-md border p-3 font-mono text-sm">
33
+ {codes.map((code) => (
34
+ <span key={code}>{code}</span>
35
+ ))}
36
+ </div>
37
+ <Button
38
+ type="button"
39
+ variant="outline"
40
+ size="sm"
41
+ onClick={() => {
42
+ navigator.clipboard.writeText(codes.join('\n'))
43
+ toast.success('Backup codes copied')
44
+ }}
45
+ >
46
+ Copy codes
47
+ </Button>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export default function TwoFactorCard() {
53
+ const router = useRouter()
54
+ const { data: session } = useSession()
55
+ const enabled = !!session?.user?.twoFactorEnabled
56
+
57
+ const [setupOpen, setSetupOpen] = useState(false)
58
+ const [step, setStep] = useState<SetupStep>('password')
59
+ const [password, setPassword] = useState('')
60
+ const [code, setCode] = useState('')
61
+ const [totpUri, setTotpUri] = useState('')
62
+ const [backupCodes, setBackupCodes] = useState<string[]>([])
63
+ const [busy, setBusy] = useState(false)
64
+
65
+ const [disableOpen, setDisableOpen] = useState(false)
66
+ const [regenOpen, setRegenOpen] = useState(false)
67
+
68
+ const resetSetup = () => {
69
+ setStep('password')
70
+ setPassword('')
71
+ setCode('')
72
+ setTotpUri('')
73
+ setBackupCodes([])
74
+ }
75
+
76
+ const startSetup = async () => {
77
+ setBusy(true)
78
+ try {
79
+ const { data, error } = await authClient.twoFactor.enable({ password })
80
+ if (error || !data) {
81
+ toast.error(error?.message || 'Could not start 2FA setup')
82
+ return
83
+ }
84
+ setTotpUri(data.totpURI)
85
+ setBackupCodes(data.backupCodes)
86
+ setStep('verify')
87
+ } finally {
88
+ setBusy(false)
89
+ }
90
+ }
91
+
92
+ const verifySetup = async () => {
93
+ setBusy(true)
94
+ try {
95
+ const { error } = await authClient.twoFactor.verifyTotp({ code: code.trim() })
96
+ if (error) {
97
+ toast.error(error.message || 'That code did not match')
98
+ return
99
+ }
100
+ setStep('backup')
101
+ router.refresh()
102
+ } finally {
103
+ setBusy(false)
104
+ }
105
+ }
106
+
107
+ const disable2fa = async () => {
108
+ setBusy(true)
109
+ try {
110
+ const { error } = await authClient.twoFactor.disable({ password })
111
+ if (error) {
112
+ toast.error(error.message || 'Could not disable 2FA')
113
+ return
114
+ }
115
+ toast.success('Two-factor authentication disabled')
116
+ setDisableOpen(false)
117
+ setPassword('')
118
+ router.refresh()
119
+ } finally {
120
+ setBusy(false)
121
+ }
122
+ }
123
+
124
+ const regenerate = async () => {
125
+ setBusy(true)
126
+ try {
127
+ const { data, error } = await authClient.twoFactor.generateBackupCodes({ password })
128
+ if (error || !data) {
129
+ toast.error(error?.message || 'Could not regenerate backup codes')
130
+ return
131
+ }
132
+ setBackupCodes(data.backupCodes)
133
+ setPassword('')
134
+ } finally {
135
+ setBusy(false)
136
+ }
137
+ }
138
+
139
+ const secret = (() => {
140
+ try {
141
+ return new URL(totpUri).searchParams.get('secret') ?? ''
142
+ } catch {
143
+ return ''
144
+ }
145
+ })()
146
+
147
+ return (
148
+ <Card>
149
+ <CardHeader>
150
+ <div className="flex items-center gap-2">
151
+ <CardTitle>Two-factor authentication</CardTitle>
152
+ <Badge variant={enabled ? 'default' : 'secondary'}>
153
+ {enabled ? 'Enabled' : 'Disabled'}
154
+ </Badge>
155
+ </div>
156
+ <CardDescription>
157
+ Require a time-based code from an authenticator app when signing in
158
+ with your password.
159
+ </CardDescription>
160
+ </CardHeader>
161
+ <CardContent className="flex flex-wrap gap-3">
162
+ {enabled ? (
163
+ <>
164
+ <Button variant="outline" onClick={() => { setBackupCodes([]); setPassword(''); setRegenOpen(true) }}>
165
+ Regenerate backup codes
166
+ </Button>
167
+ <Button variant="destructive" onClick={() => { setPassword(''); setDisableOpen(true) }}>
168
+ Disable 2FA
169
+ </Button>
170
+ </>
171
+ ) : (
172
+ <Button
173
+ onClick={() => {
174
+ resetSetup()
175
+ setSetupOpen(true)
176
+ }}
177
+ >
178
+ Enable 2FA
179
+ </Button>
180
+ )}
181
+ </CardContent>
182
+
183
+ {/* Setup flow */}
184
+ <Dialog
185
+ open={setupOpen}
186
+ onOpenChange={(o) => {
187
+ setSetupOpen(o)
188
+ if (!o) resetSetup()
189
+ }}
190
+ >
191
+ <DialogContent>
192
+ {step === 'password' && (
193
+ <>
194
+ <DialogHeader>
195
+ <DialogTitle>Confirm your password</DialogTitle>
196
+ <DialogDescription>
197
+ Enter your current password to begin setting up two-factor
198
+ authentication.
199
+ </DialogDescription>
200
+ </DialogHeader>
201
+ <div className="flex flex-col gap-2">
202
+ <Label htmlFor="tfa-password">Password</Label>
203
+ <PasswordInput
204
+ id="tfa-password"
205
+ autoComplete="current-password"
206
+ value={password}
207
+ onChange={(e) => setPassword(e.target.value)}
208
+ />
209
+ </div>
210
+ <DialogFooter>
211
+ <Button onClick={startSetup} disabled={busy || !password}>
212
+ {busy ? 'Checking...' : 'Continue'}
213
+ </Button>
214
+ </DialogFooter>
215
+ </>
216
+ )}
217
+
218
+ {step === 'verify' && (
219
+ <>
220
+ <DialogHeader>
221
+ <DialogTitle>Scan the QR code</DialogTitle>
222
+ <DialogDescription>
223
+ Scan this with your authenticator app, then enter the 6-digit
224
+ code it shows to confirm.
225
+ </DialogDescription>
226
+ </DialogHeader>
227
+ <div className="flex flex-col items-center gap-4">
228
+ <div className="rounded-md bg-white p-3">
229
+ <QRCode value={totpUri} size={160} />
230
+ </div>
231
+ {secret && (
232
+ <p className="break-all text-center text-xs text-muted-foreground">
233
+ Or enter this key manually: <span className="font-mono">{secret}</span>
234
+ </p>
235
+ )}
236
+ <div className="flex w-full flex-col gap-2">
237
+ <Label htmlFor="tfa-code">Authenticator code</Label>
238
+ <Input
239
+ id="tfa-code"
240
+ inputMode="numeric"
241
+ autoComplete="one-time-code"
242
+ value={code}
243
+ onChange={(e) => setCode(e.target.value)}
244
+ />
245
+ </div>
246
+ </div>
247
+ <DialogFooter>
248
+ <Button onClick={verifySetup} disabled={busy || !code.trim()}>
249
+ {busy ? 'Verifying...' : 'Verify & enable'}
250
+ </Button>
251
+ </DialogFooter>
252
+ </>
253
+ )}
254
+
255
+ {step === 'backup' && (
256
+ <>
257
+ <DialogHeader>
258
+ <DialogTitle>Two-factor authentication is on</DialogTitle>
259
+ </DialogHeader>
260
+ <BackupCodeList codes={backupCodes} />
261
+ <DialogFooter>
262
+ <Button
263
+ onClick={() => {
264
+ setSetupOpen(false)
265
+ resetSetup()
266
+ }}
267
+ >
268
+ Done
269
+ </Button>
270
+ </DialogFooter>
271
+ </>
272
+ )}
273
+ </DialogContent>
274
+ </Dialog>
275
+
276
+ {/* Disable */}
277
+ <Dialog open={disableOpen} onOpenChange={setDisableOpen}>
278
+ <DialogContent>
279
+ <DialogHeader>
280
+ <DialogTitle>Disable two-factor authentication</DialogTitle>
281
+ <DialogDescription>
282
+ Enter your password to turn off 2FA. Your account will be less
283
+ protected.
284
+ </DialogDescription>
285
+ </DialogHeader>
286
+ <div className="flex flex-col gap-2">
287
+ <Label htmlFor="tfa-disable-password">Password</Label>
288
+ <PasswordInput
289
+ id="tfa-disable-password"
290
+ autoComplete="current-password"
291
+ value={password}
292
+ onChange={(e) => setPassword(e.target.value)}
293
+ />
294
+ </div>
295
+ <DialogFooter>
296
+ <Button variant="destructive" onClick={disable2fa} disabled={busy || !password}>
297
+ {busy ? 'Disabling...' : 'Disable 2FA'}
298
+ </Button>
299
+ </DialogFooter>
300
+ </DialogContent>
301
+ </Dialog>
302
+
303
+ {/* Regenerate backup codes */}
304
+ <Dialog
305
+ open={regenOpen}
306
+ onOpenChange={(o) => {
307
+ setRegenOpen(o)
308
+ if (!o) { setBackupCodes([]); setPassword('') }
309
+ }}
310
+ >
311
+ <DialogContent>
312
+ <DialogHeader>
313
+ <DialogTitle>Regenerate backup codes</DialogTitle>
314
+ <DialogDescription>
315
+ This invalidates your existing backup codes.
316
+ </DialogDescription>
317
+ </DialogHeader>
318
+ {backupCodes.length > 0 ? (
319
+ <BackupCodeList codes={backupCodes} />
320
+ ) : (
321
+ <div className="flex flex-col gap-2">
322
+ <Label htmlFor="tfa-regen-password">Password</Label>
323
+ <PasswordInput
324
+ id="tfa-regen-password"
325
+ autoComplete="current-password"
326
+ value={password}
327
+ onChange={(e) => setPassword(e.target.value)}
328
+ />
329
+ </div>
330
+ )}
331
+ <DialogFooter>
332
+ {backupCodes.length > 0 ? (
333
+ <Button onClick={() => setRegenOpen(false)}>Done</Button>
334
+ ) : (
335
+ <Button onClick={regenerate} disabled={busy || !password}>
336
+ {busy ? 'Generating...' : 'Generate new codes'}
337
+ </Button>
338
+ )}
339
+ </DialogFooter>
340
+ </DialogContent>
341
+ </Dialog>
342
+ </Card>
343
+ )
344
+ }
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { Button } from '../ui/button'
6
+ import { authClient } from '../../lib/auth-client'
7
+
8
+ export default function VerifyEmailButton({ email }: { email: string }) {
9
+ const [sending, setSending] = useState(false)
10
+ const [sent, setSent] = useState(false)
11
+
12
+ async function send() {
13
+ setSending(true)
14
+ const { error } = await authClient.sendVerificationEmail({
15
+ email,
16
+ callbackURL: '/dashboard',
17
+ })
18
+ setSending(false)
19
+ if (error) {
20
+ toast.error(error.message || 'Could not send verification email')
21
+ return
22
+ }
23
+ setSent(true)
24
+ toast.success('Verification email sent — check your inbox')
25
+ }
26
+
27
+ return (
28
+ <Button size="sm" variant="outline" onClick={send} disabled={sending || sent}>
29
+ {sent ? 'Email sent' : sending ? 'Sending…' : 'Verify email'}
30
+ </Button>
31
+ )
32
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ Html,
3
+ Head,
4
+ Body,
5
+ Container,
6
+ Section,
7
+ Text,
8
+ Heading,
9
+ Hr,
10
+ } from '@react-email/components';
11
+
12
+ interface EmailTemplate2Props {
13
+ firstName: string;
14
+ }
15
+
16
+ export function EmailTemplate2({ firstName }: EmailTemplate2Props) {
17
+ return (
18
+ <Html>
19
+ <Head />
20
+ <Body style={{ backgroundColor: '#f3f4f6', fontFamily: 'Arial, sans-serif' }}>
21
+ <Container style={{ margin: '0 auto', padding: '20px 0 48px', maxWidth: '560px' }}>
22
+ <Section style={{ backgroundColor: '#ffffff', borderRadius: '8px', padding: '32px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
23
+ <Heading style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '8px', color: '#1f2937' }}>
24
+ Hello {firstName},
25
+ </Heading>
26
+ <Text style={{ fontSize: '14px', color: '#6b7280', marginBottom: '24px' }}>
27
+ This is your second template
28
+ </Text>
29
+ <Hr style={{ borderColor: '#e5e7eb', margin: '24px 0' }} />
30
+ <Text style={{ fontSize: '16px', lineHeight: '24px', color: '#374151', marginBottom: '16px' }}>
31
+ This is an alternate email template with a different design and layout. You can customize this template with different content, styling, and structure to suit your needs.
32
+ </Text>
33
+ <Text style={{ fontSize: '16px', lineHeight: '24px', color: '#374151' }}>
34
+ Feel free to modify this template to match your brand and message.
35
+ </Text>
36
+ </Section>
37
+ <Text style={{ fontSize: '12px', color: '#9ca3af', marginTop: '32px', textAlign: 'center' }}>
38
+ © 2024 Your Company. All rights reserved.
39
+ </Text>
40
+ </Container>
41
+ </Body>
42
+ </Html>
43
+ );
44
+ }