@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,178 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useMemo, useState } from 'react'
4
+ import { z } from 'zod'
5
+ import { Button } from '../ui/button'
6
+ import { Input } from '../ui/input'
7
+ import { PasswordInput } from '../ui/password-input'
8
+ import { Label } from '../ui/label'
9
+ import { authClient } from '../../lib/auth-client'
10
+ import { getVisitorId } from '../../lib/device-fingerprint'
11
+ import { LOGIN_REFERRER_COOKIE, DEVICE_FINGERPRINT_HEADER } from '../../lib/cookie-names'
12
+ import { SignupSchema } from '../../types/auth/signupSchema'
13
+ import { toast } from 'sonner'
14
+
15
+ type Props = {
16
+ callbackUrl?: string
17
+ }
18
+
19
+ // Matches better-auth's own default minPasswordLength (server/auth.ts
20
+ // doesn't override it) — the meter below is UX guidance, not the source of
21
+ // truth for what the server will actually accept.
22
+ const MIN_PASSWORD_LENGTH = 8
23
+
24
+ function getPasswordStrength(password: string): number {
25
+ if (password.length === 0) return 0
26
+ let score = 0
27
+ if (password.length >= MIN_PASSWORD_LENGTH) score++
28
+ if (password.length >= 12) score++
29
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
30
+ if (/[0-9]/.test(password) || /[^A-Za-z0-9]/.test(password)) score++
31
+ return score
32
+ }
33
+
34
+ const STRENGTH_LABEL = ['', 'Weak', 'Weak', 'Good', 'Strong']
35
+ const STRENGTH_COLOR = ['bg-muted', 'bg-red-500', 'bg-red-500', 'bg-amber-500', 'bg-green-500']
36
+
37
+ // Derived from the schema (not hand-typed) so a field rename/add/remove
38
+ // there can't silently drift out of sync with the error state's shape here.
39
+ type FieldErrors = Partial<Record<keyof z.infer<typeof SignupSchema>, string>>
40
+
41
+ const EmailPasswordSignup = ({ callbackUrl }: Props) => {
42
+ const [name, setName] = useState('')
43
+ const [email, setEmail] = useState('')
44
+ const [password, setPassword] = useState('')
45
+ const [isSubmitting, setIsSubmitting] = useState(false)
46
+ const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
47
+ const strength = useMemo(() => getPasswordStrength(password), [password])
48
+
49
+ // Warm the fingerprint cache so the first submit doesn't wait on it.
50
+ useEffect(() => {
51
+ void getVisitorId()
52
+ }, [])
53
+
54
+ const handleSubmit = async (e: React.FormEvent) => {
55
+ e.preventDefault()
56
+
57
+ const result = SignupSchema.safeParse({ name, email, password })
58
+ if (!result.success) {
59
+ const formatted = z.flattenError(result.error).fieldErrors
60
+ setFieldErrors({
61
+ name: formatted.name?.[0],
62
+ email: formatted.email?.[0],
63
+ password: formatted.password?.[0],
64
+ })
65
+ return
66
+ }
67
+ setFieldErrors({})
68
+
69
+ setIsSubmitting(true)
70
+
71
+ // See emailPasswordLogin.tsx — keeps this cookie fresh for this flow so
72
+ // the login-activity log doesn't pick up a stale value from an
73
+ // abandoned social sign-in attempt.
74
+ document.cookie = `${LOGIN_REFERRER_COOKIE}=${encodeURIComponent(window.location.href)}; path=/; max-age=300; samesite=lax`
75
+
76
+ try {
77
+ const visitorId = await getVisitorId()
78
+ const { error } = await authClient.signUp.email({
79
+ name,
80
+ email,
81
+ password,
82
+ callbackURL: callbackUrl || '/dashboard',
83
+ fetchOptions: visitorId
84
+ ? { headers: { [DEVICE_FINGERPRINT_HEADER]: visitorId } }
85
+ : undefined,
86
+ })
87
+
88
+ if (error) {
89
+ toast.error(error.message || 'Failed to sign up')
90
+ }
91
+ } catch (error) {
92
+ toast.error(error instanceof Error ? error.message : 'Failed to sign up')
93
+ } finally {
94
+ setIsSubmitting(false)
95
+ }
96
+ }
97
+
98
+ return (
99
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
100
+ <div className="flex flex-col gap-2">
101
+ <Label htmlFor="name" className={fieldErrors.name ? 'text-destructive' : undefined}>
102
+ Name
103
+ </Label>
104
+ <Input
105
+ id="name"
106
+ type="text"
107
+ autoComplete="name"
108
+ placeholder="Ada Lovelace"
109
+ aria-invalid={!!fieldErrors.name}
110
+ aria-describedby={fieldErrors.name ? 'name-error' : undefined}
111
+ value={name}
112
+ onChange={(e) => {
113
+ setName(e.target.value)
114
+ if (fieldErrors.name) setFieldErrors((prev) => ({ ...prev, name: undefined }))
115
+ }}
116
+ />
117
+ {fieldErrors.name && <p id="name-error" className="text-sm text-destructive">{fieldErrors.name}</p>}
118
+ </div>
119
+ <div className="flex flex-col gap-2">
120
+ <Label htmlFor="email" className={fieldErrors.email ? 'text-destructive' : undefined}>
121
+ Email
122
+ </Label>
123
+ <Input
124
+ id="email"
125
+ type="email"
126
+ autoComplete="email"
127
+ placeholder="you@company.com"
128
+ aria-invalid={!!fieldErrors.email}
129
+ aria-describedby={fieldErrors.email ? 'email-error' : undefined}
130
+ value={email}
131
+ onChange={(e) => {
132
+ setEmail(e.target.value)
133
+ if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
134
+ }}
135
+ />
136
+ {fieldErrors.email && <p id="email-error" className="text-sm text-destructive">{fieldErrors.email}</p>}
137
+ </div>
138
+ <div className="flex flex-col gap-2">
139
+ <Label htmlFor="password" className={fieldErrors.password ? 'text-destructive' : undefined}>
140
+ Password
141
+ </Label>
142
+ <PasswordInput
143
+ id="password"
144
+ autoComplete="new-password"
145
+ placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
146
+ aria-invalid={!!fieldErrors.password}
147
+ aria-describedby={[fieldErrors.password && 'password-error', 'password-hint'].filter(Boolean).join(' ')}
148
+ value={password}
149
+ onChange={(e) => {
150
+ setPassword(e.target.value)
151
+ if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
152
+ }}
153
+ />
154
+ {fieldErrors.password && <p id="password-error" className="text-sm text-destructive">{fieldErrors.password}</p>}
155
+ <div className="flex items-center gap-2">
156
+ <div className="flex flex-1 gap-1">
157
+ {[1, 2, 3, 4].map((segment) => (
158
+ <span
159
+ key={segment}
160
+ className={`h-1 flex-1 rounded-full transition-colors ${
161
+ segment <= strength ? STRENGTH_COLOR[strength] : 'bg-muted'
162
+ }`}
163
+ />
164
+ ))}
165
+ </div>
166
+ <span id="password-hint" className="text-xs text-muted-foreground">
167
+ {password.length < MIN_PASSWORD_LENGTH ? `Use ${MIN_PASSWORD_LENGTH}+ characters` : STRENGTH_LABEL[strength]}
168
+ </span>
169
+ </div>
170
+ </div>
171
+ <Button type="submit" className="w-full" disabled={isSubmitting}>
172
+ {isSubmitting ? 'Creating account...' : 'Create account'}
173
+ </Button>
174
+ </form>
175
+ )
176
+ }
177
+
178
+ export default EmailPasswordSignup
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { Button } from '../ui/button'
5
+ import { Input } from '../ui/input'
6
+ import { Label } from '../ui/label'
7
+ import { authClient } from '../../lib/auth-client'
8
+ import { toast } from 'sonner'
9
+
10
+ const ForgotPasswordForm = () => {
11
+ const [email, setEmail] = useState('')
12
+ const [isSubmitting, setIsSubmitting] = useState(false)
13
+ const [sent, setSent] = useState(false)
14
+
15
+ const handleSubmit = async (e: React.FormEvent) => {
16
+ e.preventDefault()
17
+ setIsSubmitting(true)
18
+
19
+ try {
20
+ const { error } = await authClient.forgetPassword({
21
+ email,
22
+ redirectTo: '/reset-password',
23
+ })
24
+
25
+ if (error) {
26
+ toast.error(error.message || 'Failed to send reset email')
27
+ } else {
28
+ setSent(true)
29
+ }
30
+ } catch (error) {
31
+ toast.error(error instanceof Error ? error.message : 'Failed to send reset email')
32
+ } finally {
33
+ setIsSubmitting(false)
34
+ }
35
+ }
36
+
37
+ if (sent) {
38
+ return (
39
+ <p className="text-sm text-muted-foreground">
40
+ If an account exists for <span className="font-medium text-foreground">{email}</span>, we&apos;ve
41
+ sent a link to reset your password. It expires in an hour.
42
+ </p>
43
+ )
44
+ }
45
+
46
+ return (
47
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
48
+ <div className="flex flex-col gap-2">
49
+ <Label htmlFor="email">Email</Label>
50
+ <Input
51
+ id="email"
52
+ type="email"
53
+ autoComplete="email"
54
+ value={email}
55
+ onChange={(e) => setEmail(e.target.value)}
56
+ // required
57
+ />
58
+ </div>
59
+ <Button type="submit" className="w-full" disabled={isSubmitting}>
60
+ {isSubmitting ? 'Sending...' : 'Send reset link'}
61
+ </Button>
62
+ </form>
63
+ )
64
+ }
65
+
66
+ export default ForgotPasswordForm
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { authClient } from '../../lib/auth-client'
6
+
7
+ type Props = {
8
+ /** The `?error=` code better-auth redirected to /login with. */
9
+ error: string
10
+ /** Already normalized server-side (lib/auth-helpers is server-only). */
11
+ callbackUrl: string
12
+ }
13
+
14
+ // better-auth throws these from parseState when the one-time OAuth `state` row
15
+ // is missing at callback time — almost always because the callback URL got hit
16
+ // twice (browser Back after Google's no-consent redirect, bfcache replay,
17
+ // prefetch, double-nav — better-auth#5658 / #6544). The first hit logs the user
18
+ // in; the second lands here. Recoverable: wait for the winning request's
19
+ // session cookie to land, then continue — or restart the flow once.
20
+ const TRANSIENT = new Set([
21
+ 'please_restart_the_process',
22
+ 'state_mismatch',
23
+ 'state_not_found',
24
+ ])
25
+
26
+ // Only one auto-restart per tab session, so a genuinely broken OAuth config
27
+ // can't bounce the user login → Google → login forever.
28
+ const RETRY_GUARD_KEY = 'login-oauth-retried'
29
+
30
+ const FRIENDLY: Record<string, string> = {
31
+ account_banned: 'This account has been suspended.',
32
+ account_not_linked:
33
+ 'An account with this email already exists. Sign in with your original method, then link Google from settings.',
34
+ }
35
+
36
+ export default function LoginErrorRecovery({ error, callbackUrl }: Props) {
37
+ const router = useRouter()
38
+ const [message, setMessage] = useState<string | null>(null)
39
+ const [recovering, setRecovering] = useState(TRANSIENT.has(error))
40
+
41
+ useEffect(() => {
42
+ if (!TRANSIENT.has(error)) {
43
+ setMessage(FRIENDLY[error] ?? 'Sign-in was interrupted. Please try again.')
44
+ return
45
+ }
46
+
47
+ let cancelled = false
48
+
49
+ const hasSession = async () => {
50
+ const { data } = await authClient.getSession()
51
+ return Boolean(data?.user)
52
+ }
53
+
54
+ const run = async () => {
55
+ // The losing request usually beats the winning request's Set-Cookie to
56
+ // the browser — give it a moment, then re-check.
57
+ let authed = await hasSession()
58
+ if (!authed) {
59
+ await new Promise((r) => setTimeout(r, 1200))
60
+ authed = await hasSession()
61
+ }
62
+ if (cancelled) return
63
+
64
+ if (authed) {
65
+ try {
66
+ sessionStorage.removeItem(RETRY_GUARD_KEY)
67
+ } catch {
68
+ // no sessionStorage (private mode) — nothing to clear
69
+ }
70
+ router.replace(callbackUrl)
71
+ return
72
+ }
73
+
74
+ let alreadyRetried = false
75
+ try {
76
+ alreadyRetried = sessionStorage.getItem(RETRY_GUARD_KEY) === '1'
77
+ sessionStorage.setItem(RETRY_GUARD_KEY, '1')
78
+ } catch {
79
+ // no sessionStorage — fall through and allow the single retry
80
+ }
81
+ if (cancelled) return
82
+
83
+ if (alreadyRetried) {
84
+ setRecovering(false)
85
+ setMessage("Sign-in didn't complete. Please try again.")
86
+ return
87
+ }
88
+
89
+ // Fresh flow, fresh `state` row.
90
+ await authClient.signIn.social({ provider: 'google', callbackURL: callbackUrl })
91
+ }
92
+
93
+ run().catch(() => {
94
+ if (cancelled) return
95
+ setRecovering(false)
96
+ setMessage("Sign-in didn't complete. Please try again.")
97
+ })
98
+
99
+ return () => {
100
+ cancelled = true
101
+ }
102
+ }, [error, callbackUrl, router])
103
+
104
+ if (recovering) {
105
+ return (
106
+ <p className="mb-4 text-sm text-muted-foreground" role="status">
107
+ Reconnecting your session…
108
+ </p>
109
+ )
110
+ }
111
+
112
+ if (message) {
113
+ return <p className="mb-4 text-sm text-destructive">{message}</p>
114
+ }
115
+
116
+ return null
117
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { authClient } from "../../lib/auth-client";
5
+ import { Button } from '../ui/button';
6
+
7
+
8
+ const LogoutButtons = () => {
9
+ const handleSignOut = async () => {
10
+ await authClient.signOut({
11
+ fetchOptions: {
12
+ onSuccess: () => {
13
+ window.location.href = "/"
14
+ }
15
+ }
16
+ })
17
+ }
18
+
19
+ return (
20
+ <div>
21
+ <Button onClick={handleSignOut}>Logout</Button>
22
+ </div>
23
+ )
24
+ }
25
+
26
+ export default LogoutButtons
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import { authClient, useSession } from '../../lib/auth-client'
4
+ import { useRouter } from 'next/navigation'
5
+ import React, { useEffect, useState } from 'react'
6
+
7
+ type OneTapProps = {
8
+ /**
9
+ * Where Better-Auth redirects after a successful One Tap sign-in.
10
+ * @default "/dashboard"
11
+ */
12
+ callbackURL?: string
13
+ }
14
+
15
+ const OneTap = ({ callbackURL = '/dashboard' }: OneTapProps) => {
16
+ const { data: session, isPending } = useSession()
17
+ const router = useRouter()
18
+ // NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP (build-time) decides whether the plugin
19
+ // exists in the bundle at all — unchanged. This is the separate, live
20
+ // on/off switch from the System Settings page: null while unknown, so we
21
+ // never fire the prompt before we've actually heard back.
22
+ const [oneTapOffered, setOneTapOffered] = useState<boolean | null>(null)
23
+
24
+ useEffect(() => {
25
+ if (process.env.NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP !== 'true') return
26
+
27
+ fetch('/api/settings/public')
28
+ .then((res) => res.json())
29
+ .then((data) => setOneTapOffered(Boolean(data.oneTapEnabled)))
30
+ // Fail closed — never nag a user with a prompt an admin just disabled.
31
+ .catch(() => setOneTapOffered(false))
32
+ }, [])
33
+
34
+ useEffect(() => {
35
+ if (process.env.NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP !== 'true') return
36
+ if (isPending || session) return
37
+ if (oneTapOffered !== true) return
38
+
39
+ authClient.oneTap({
40
+ callbackURL,
41
+ onPromptNotification: (notification) => {
42
+ console.log('One Tap prompt notification:', notification)
43
+ }
44
+ })
45
+ }, [session, isPending, callbackURL, oneTapOffered])
46
+
47
+ return null
48
+ }
49
+
50
+ export default OneTap
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { KeyRound } from 'lucide-react'
6
+ import { Button } from '../ui/button'
7
+ import { authClient } from '../../lib/auth-client'
8
+ import { toast } from 'sonner'
9
+
10
+ type Props = {
11
+ callbackUrl?: string
12
+ }
13
+
14
+ // Passwordless sign-in with a passkey registered from the Settings page.
15
+ const PasskeyLogin = ({ callbackUrl }: Props) => {
16
+ const router = useRouter()
17
+ const [isSubmitting, setIsSubmitting] = useState(false)
18
+
19
+ const handleClick = async () => {
20
+ setIsSubmitting(true)
21
+ try {
22
+ const { error } = await authClient.signIn.passkey()
23
+ if (error) {
24
+ toast.error(error.message || 'Passkey sign-in failed')
25
+ return
26
+ }
27
+ router.push(callbackUrl || '/dashboard')
28
+ router.refresh()
29
+ } catch (err) {
30
+ toast.error(err instanceof Error ? err.message : 'Passkey sign-in failed')
31
+ } finally {
32
+ setIsSubmitting(false)
33
+ }
34
+ }
35
+
36
+ return (
37
+ <Button
38
+ type="button"
39
+ variant="outline"
40
+ className="w-full"
41
+ onClick={handleClick}
42
+ disabled={isSubmitting}
43
+ >
44
+ <KeyRound className="size-4" />
45
+ {isSubmitting ? 'Waiting for passkey...' : 'Sign in with a passkey'}
46
+ </Button>
47
+ )
48
+ }
49
+
50
+ export default PasskeyLogin
@@ -0,0 +1,112 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Button } from '../ui/button'
6
+ import { PasswordInput } from '../ui/password-input'
7
+ import { Label } from '../ui/label'
8
+ import { authClient } from '../../lib/auth-client'
9
+ import { toast } from 'sonner'
10
+
11
+ type Props = {
12
+ token: string
13
+ }
14
+
15
+ type FieldErrors = {
16
+ password?: string
17
+ confirmPassword?: string
18
+ }
19
+
20
+ const ResetPasswordForm = ({ token }: Props) => {
21
+ const router = useRouter()
22
+ const [password, setPassword] = useState('')
23
+ const [confirmPassword, setConfirmPassword] = useState('')
24
+ const [isSubmitting, setIsSubmitting] = useState(false)
25
+ const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
26
+
27
+ const handleSubmit = async (e: React.FormEvent) => {
28
+ e.preventDefault()
29
+
30
+ // Matches better-auth's own default minPasswordLength (emailAndPassword
31
+ // config doesn't override it in server/auth.ts) — kept in sync here so
32
+ // the client doesn't accept something the server then rejects.
33
+ const errors: FieldErrors = {}
34
+ if (password.length < 8) {
35
+ errors.password = 'Password must be at least 8 characters'
36
+ }
37
+ if (password !== confirmPassword) {
38
+ errors.confirmPassword = 'Passwords do not match'
39
+ }
40
+ if (errors.password || errors.confirmPassword) {
41
+ setFieldErrors(errors)
42
+ return
43
+ }
44
+ setFieldErrors({})
45
+
46
+ setIsSubmitting(true)
47
+
48
+ try {
49
+ const { error } = await authClient.resetPassword({
50
+ newPassword: password,
51
+ token,
52
+ })
53
+
54
+ if (error) {
55
+ toast.error(error.message || 'Failed to reset password')
56
+ } else {
57
+ toast.success('Password reset — log in with your new password')
58
+ router.push('/login')
59
+ }
60
+ } catch (error) {
61
+ toast.error(error instanceof Error ? error.message : 'Failed to reset password')
62
+ } finally {
63
+ setIsSubmitting(false)
64
+ }
65
+ }
66
+
67
+ return (
68
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
69
+ <div className="flex flex-col gap-2">
70
+ <Label htmlFor="password" className={fieldErrors.password ? 'text-destructive' : undefined}>
71
+ New password
72
+ </Label>
73
+ <PasswordInput
74
+ id="password"
75
+ autoComplete="new-password"
76
+ aria-invalid={!!fieldErrors.password}
77
+ aria-describedby={fieldErrors.password ? 'password-error' : undefined}
78
+ value={password}
79
+ onChange={(e) => {
80
+ setPassword(e.target.value)
81
+ if (fieldErrors.password || fieldErrors.confirmPassword) {
82
+ setFieldErrors((prev) => ({ ...prev, password: undefined, confirmPassword: undefined }))
83
+ }
84
+ }}
85
+ />
86
+ {fieldErrors.password && <p id="password-error" className="text-sm text-destructive">{fieldErrors.password}</p>}
87
+ </div>
88
+ <div className="flex flex-col gap-2">
89
+ <Label htmlFor="confirmPassword" className={fieldErrors.confirmPassword ? 'text-destructive' : undefined}>
90
+ Confirm password
91
+ </Label>
92
+ <PasswordInput
93
+ id="confirmPassword"
94
+ autoComplete="new-password"
95
+ aria-invalid={!!fieldErrors.confirmPassword}
96
+ aria-describedby={fieldErrors.confirmPassword ? 'confirm-password-error' : undefined}
97
+ value={confirmPassword}
98
+ onChange={(e) => {
99
+ setConfirmPassword(e.target.value)
100
+ if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: undefined }))
101
+ }}
102
+ />
103
+ {fieldErrors.confirmPassword && <p id="confirm-password-error" className="text-sm text-destructive">{fieldErrors.confirmPassword}</p>}
104
+ </div>
105
+ <Button type="submit" className="w-full" disabled={isSubmitting}>
106
+ {isSubmitting ? 'Resetting...' : 'Reset password'}
107
+ </Button>
108
+ </form>
109
+ )
110
+ }
111
+
112
+ export default ResetPasswordForm
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+
3
+ import { Button } from '../ui/button'
4
+ import { FcGoogle } from "react-icons/fc";
5
+ import React from 'react'
6
+ import { authClient } from "../../lib/auth-client";
7
+ import { LOGIN_REFERRER_COOKIE } from "../../lib/cookie-names";
8
+ import { toast } from 'sonner'
9
+
10
+ type Props = {
11
+ callbackUrl?: string
12
+ label?: string
13
+ }
14
+
15
+ const SocialLogin = ({ callbackUrl, label = 'Sign in with Google' }: Props) => {
16
+ const handleGoogleSignIn = async () => {
17
+ // The OAuth round trip through Google means the server only sees
18
+ // accounts.google.com as the referer on the callback request — stash
19
+ // the actual page here, before we redirect away, so the login-activity
20
+ // log can show where the user really came from (read back via
21
+ // getCookie in server/auth.ts's session.create hook).
22
+ document.cookie = `${LOGIN_REFERRER_COOKIE}=${encodeURIComponent(window.location.href)}; path=/; max-age=300; samesite=lax`
23
+
24
+ try {
25
+ const { error } = await authClient.signIn.social({
26
+ provider: "google",
27
+ callbackURL: callbackUrl || "/dashboard"
28
+ })
29
+
30
+ if (error) {
31
+ toast.error(error.message || 'Failed to sign in with Google')
32
+ }
33
+ } catch (error) {
34
+ toast.error(error instanceof Error ? error.message : 'Failed to sign in with Google')
35
+ }
36
+ }
37
+
38
+ return (
39
+ <div>
40
+ <Button
41
+ variant="outline"
42
+ className="w-full"
43
+ onClick={handleGoogleSignIn}
44
+ >
45
+ <div className="flex items-center gap-1"><FcGoogle /> {label}</div>
46
+ </Button>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ export default SocialLogin