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