@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,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'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
|