@opensaas/stack-auth 0.31.1 → 0.33.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +54 -0
- package/CLAUDE.md +42 -7
- package/README.md +64 -18
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +8 -2
- package/dist/server/index.js.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.d.ts +10 -7
- package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.js +11 -10
- package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
- package/dist/ui/components/ResetPasswordForm.d.ts +60 -0
- package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -0
- package/dist/ui/components/ResetPasswordForm.js +70 -0
- package/dist/ui/components/ResetPasswordForm.js.map +1 -0
- package/dist/ui/components/SignInForm.d.ts +22 -9
- package/dist/ui/components/SignInForm.d.ts.map +1 -1
- package/dist/ui/components/SignInForm.js +25 -19
- package/dist/ui/components/SignInForm.js.map +1 -1
- package/dist/ui/components/SignUpForm.d.ts +16 -8
- package/dist/ui/components/SignUpForm.d.ts.map +1 -1
- package/dist/ui/components/SignUpForm.js +19 -23
- package/dist/ui/components/SignUpForm.js.map +1 -1
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/lib/clean-error-message.d.ts +12 -0
- package/dist/ui/lib/clean-error-message.d.ts.map +1 -0
- package/dist/ui/lib/clean-error-message.js +20 -0
- package/dist/ui/lib/clean-error-message.js.map +1 -0
- package/dist/ui/types.d.ts +55 -0
- package/dist/ui/types.d.ts.map +1 -0
- package/dist/ui/types.js +12 -0
- package/dist/ui/types.js.map +1 -0
- package/package.json +3 -3
- package/src/server/index.ts +8 -2
- package/src/ui/components/ForgotPasswordForm.tsx +18 -14
- package/src/ui/components/ResetPasswordForm.tsx +182 -0
- package/src/ui/components/SignInForm.tsx +43 -26
- package/src/ui/components/SignUpForm.tsx +37 -29
- package/src/ui/index.ts +17 -0
- package/src/ui/lib/clean-error-message.ts +21 -0
- package/src/ui/types.ts +49 -0
- package/tests/clean-error-message.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useState } from 'react'
|
|
4
|
-
import
|
|
4
|
+
import { cleanAuthErrorMessage } from '../lib/clean-error-message.js'
|
|
5
|
+
import type { RequestPasswordResetAction } from '../types.js'
|
|
5
6
|
|
|
6
7
|
export type ForgotPasswordFormProps = {
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Server action that requests a password-reset email.
|
|
10
|
+
* Define it in your app (`'use server'`) against your own auth instance.
|
|
10
11
|
*/
|
|
11
|
-
|
|
12
|
+
requestPasswordResetAction: RequestPasswordResetAction
|
|
12
13
|
/**
|
|
13
14
|
* Custom CSS class for the form container
|
|
14
15
|
*/
|
|
@@ -27,18 +28,21 @@ export type ForgotPasswordFormProps = {
|
|
|
27
28
|
* Forgot password form component
|
|
28
29
|
* Allows users to request a password reset email
|
|
29
30
|
*
|
|
31
|
+
* Submits through an app-owned server action rather than calling the auth API
|
|
32
|
+
* from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`.
|
|
33
|
+
*
|
|
30
34
|
* @example
|
|
31
35
|
* ```typescript
|
|
32
36
|
* import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
|
|
33
|
-
* import {
|
|
37
|
+
* import { requestPasswordResetAction } from '@/lib/actions/auth'
|
|
34
38
|
*
|
|
35
39
|
* export default function ForgotPasswordPage() {
|
|
36
|
-
* return <ForgotPasswordForm
|
|
40
|
+
* return <ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />
|
|
37
41
|
* }
|
|
38
42
|
* ```
|
|
39
43
|
*/
|
|
40
44
|
export function ForgotPasswordForm({
|
|
41
|
-
|
|
45
|
+
requestPasswordResetAction,
|
|
42
46
|
className = '',
|
|
43
47
|
onSuccess,
|
|
44
48
|
onError,
|
|
@@ -55,19 +59,19 @@ export function ForgotPasswordForm({
|
|
|
55
59
|
setLoading(true)
|
|
56
60
|
|
|
57
61
|
try {
|
|
58
|
-
const result = await
|
|
59
|
-
email,
|
|
60
|
-
redirectTo: '/reset-password',
|
|
61
|
-
})
|
|
62
|
+
const result = await requestPasswordResetAction({ email })
|
|
62
63
|
|
|
63
|
-
if (result.
|
|
64
|
-
throw new Error(result.error
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
throw new Error(cleanAuthErrorMessage(result.error, 'Failed to send reset email'))
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
setSuccess(true)
|
|
68
69
|
onSuccess?.()
|
|
69
70
|
} catch (err) {
|
|
70
|
-
const message =
|
|
71
|
+
const message = cleanAuthErrorMessage(
|
|
72
|
+
err instanceof Error ? err.message : undefined,
|
|
73
|
+
'Failed to send reset email',
|
|
74
|
+
)
|
|
71
75
|
setError(message)
|
|
72
76
|
onError?.(err instanceof Error ? err : new Error(message))
|
|
73
77
|
} finally {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation.js'
|
|
5
|
+
import { cleanAuthErrorMessage } from '../lib/clean-error-message.js'
|
|
6
|
+
import type { ResetPasswordAction } from '../types.js'
|
|
7
|
+
|
|
8
|
+
export type ResetPasswordFormProps = {
|
|
9
|
+
/**
|
|
10
|
+
* Server action that completes the password reset.
|
|
11
|
+
* Define it in your app (`'use server'`) against your own auth instance.
|
|
12
|
+
*/
|
|
13
|
+
resetPasswordAction: ResetPasswordAction
|
|
14
|
+
/**
|
|
15
|
+
* The reset token from the email link. The page reads it from
|
|
16
|
+
* `searchParams.token` and passes it in. When empty, the form renders an
|
|
17
|
+
* "invalid or expired link" state instead of a password form.
|
|
18
|
+
*/
|
|
19
|
+
token: string
|
|
20
|
+
/**
|
|
21
|
+
* URL to redirect to after a successful reset
|
|
22
|
+
* @default '/sign-in'
|
|
23
|
+
*/
|
|
24
|
+
redirectTo?: string
|
|
25
|
+
/**
|
|
26
|
+
* Require password confirmation
|
|
27
|
+
* @default true
|
|
28
|
+
*/
|
|
29
|
+
requirePasswordConfirmation?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Custom CSS class for the form container
|
|
32
|
+
*/
|
|
33
|
+
className?: string
|
|
34
|
+
/**
|
|
35
|
+
* Callback when the reset succeeds
|
|
36
|
+
*/
|
|
37
|
+
onSuccess?: () => void
|
|
38
|
+
/**
|
|
39
|
+
* Callback when the reset fails
|
|
40
|
+
*/
|
|
41
|
+
onError?: (error: Error) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reset password form component
|
|
46
|
+
* Completes a password reset using the token from the reset email.
|
|
47
|
+
*
|
|
48
|
+
* Submits through an app-owned server action rather than calling the auth API
|
|
49
|
+
* from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import { ResetPasswordForm } from '@opensaas/stack-auth/ui'
|
|
54
|
+
* import { resetPasswordAction } from '@/lib/actions/auth'
|
|
55
|
+
*
|
|
56
|
+
* export default async function ResetPasswordPage({
|
|
57
|
+
* searchParams,
|
|
58
|
+
* }: {
|
|
59
|
+
* searchParams: Promise<{ token?: string }>
|
|
60
|
+
* }) {
|
|
61
|
+
* const { token } = await searchParams
|
|
62
|
+
* return <ResetPasswordForm resetPasswordAction={resetPasswordAction} token={token ?? ''} />
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function ResetPasswordForm({
|
|
67
|
+
resetPasswordAction,
|
|
68
|
+
token,
|
|
69
|
+
redirectTo = '/sign-in',
|
|
70
|
+
requirePasswordConfirmation = true,
|
|
71
|
+
className = '',
|
|
72
|
+
onSuccess,
|
|
73
|
+
onError,
|
|
74
|
+
}: ResetPasswordFormProps) {
|
|
75
|
+
const router = useRouter()
|
|
76
|
+
const [password, setPassword] = useState('')
|
|
77
|
+
const [confirmPassword, setConfirmPassword] = useState('')
|
|
78
|
+
const [error, setError] = useState('')
|
|
79
|
+
const [loading, setLoading] = useState(false)
|
|
80
|
+
|
|
81
|
+
// Guard: no token means the user hit this page directly or followed a
|
|
82
|
+
// malformed/expired link. Don't render a form that can't succeed.
|
|
83
|
+
if (!token) {
|
|
84
|
+
return (
|
|
85
|
+
<div className={`w-full max-w-md mx-auto p-6 ${className}`}>
|
|
86
|
+
<h2 className="text-2xl font-bold mb-6">Reset Password</h2>
|
|
87
|
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
88
|
+
This password reset link is invalid or has expired. Please request a new one.
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
setError('')
|
|
97
|
+
|
|
98
|
+
if (requirePasswordConfirmation && password !== confirmPassword) {
|
|
99
|
+
setError('Passwords do not match')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setLoading(true)
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await resetPasswordAction({ token, password })
|
|
107
|
+
|
|
108
|
+
if (!result.success) {
|
|
109
|
+
throw new Error(cleanAuthErrorMessage(result.error, 'Failed to reset password'))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (onSuccess) {
|
|
113
|
+
onSuccess()
|
|
114
|
+
} else {
|
|
115
|
+
router.push(redirectTo)
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const message = cleanAuthErrorMessage(
|
|
119
|
+
err instanceof Error ? err.message : undefined,
|
|
120
|
+
'Failed to reset password',
|
|
121
|
+
)
|
|
122
|
+
setError(message)
|
|
123
|
+
onError?.(err instanceof Error ? err : new Error(message))
|
|
124
|
+
} finally {
|
|
125
|
+
setLoading(false)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className={`w-full max-w-md mx-auto p-6 ${className}`}>
|
|
131
|
+
<h2 className="text-2xl font-bold mb-6">Reset Password</h2>
|
|
132
|
+
|
|
133
|
+
{error && (
|
|
134
|
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
|
|
135
|
+
{error}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
140
|
+
<div>
|
|
141
|
+
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
|
142
|
+
New Password
|
|
143
|
+
</label>
|
|
144
|
+
<input
|
|
145
|
+
id="password"
|
|
146
|
+
type="password"
|
|
147
|
+
value={password}
|
|
148
|
+
onChange={(e) => setPassword((e.target as HTMLInputElement).value)}
|
|
149
|
+
required
|
|
150
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
151
|
+
disabled={loading}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{requirePasswordConfirmation && (
|
|
156
|
+
<div>
|
|
157
|
+
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2">
|
|
158
|
+
Confirm New Password
|
|
159
|
+
</label>
|
|
160
|
+
<input
|
|
161
|
+
id="confirmPassword"
|
|
162
|
+
type="password"
|
|
163
|
+
value={confirmPassword}
|
|
164
|
+
onChange={(e) => setConfirmPassword((e.target as HTMLInputElement).value)}
|
|
165
|
+
required
|
|
166
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
167
|
+
disabled={loading}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<button
|
|
173
|
+
type="submit"
|
|
174
|
+
disabled={loading}
|
|
175
|
+
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
176
|
+
>
|
|
177
|
+
{loading ? 'Resetting...' : 'Reset Password'}
|
|
178
|
+
</button>
|
|
179
|
+
</form>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState } from 'react'
|
|
4
4
|
import { useRouter } from 'next/navigation.js'
|
|
5
|
-
import
|
|
5
|
+
import { cleanAuthErrorMessage } from '../lib/clean-error-message.js'
|
|
6
|
+
import type { SignInAction, SignInSocialAction } from '../types.js'
|
|
6
7
|
|
|
7
8
|
export type SignInFormProps = {
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Pass your client from lib/auth-client.ts
|
|
10
|
+
* Server action that signs a user in with email + password.
|
|
11
|
+
* Define it in your app (`'use server'`) against your own auth instance.
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
signInAction: SignInAction
|
|
14
|
+
/**
|
|
15
|
+
* Server action that starts an OAuth sign-in and redirects to the provider.
|
|
16
|
+
* Required to render the social provider buttons; omit to hide them.
|
|
17
|
+
*/
|
|
18
|
+
signInSocialAction?: SignInSocialAction
|
|
14
19
|
/**
|
|
15
20
|
* URL to redirect to after successful sign in
|
|
16
21
|
* @default '/'
|
|
17
22
|
*/
|
|
18
23
|
redirectTo?: string
|
|
19
24
|
/**
|
|
20
|
-
* Show OAuth provider buttons
|
|
25
|
+
* Show OAuth provider buttons (requires `signInSocialAction`)
|
|
21
26
|
* @default true
|
|
22
27
|
*/
|
|
23
28
|
showSocialProviders?: boolean
|
|
@@ -44,18 +49,28 @@ export type SignInFormProps = {
|
|
|
44
49
|
* Sign in form component
|
|
45
50
|
* Provides email/password sign in and OAuth provider buttons
|
|
46
51
|
*
|
|
52
|
+
* Submits through app-owned server actions rather than calling the auth API
|
|
53
|
+
* from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`.
|
|
54
|
+
*
|
|
47
55
|
* @example
|
|
48
56
|
* ```typescript
|
|
49
57
|
* import { SignInForm } from '@opensaas/stack-auth/ui'
|
|
50
|
-
* import {
|
|
58
|
+
* import { signInAction, signInSocialAction } from '@/lib/actions/auth'
|
|
51
59
|
*
|
|
52
60
|
* export default function SignInPage() {
|
|
53
|
-
* return
|
|
61
|
+
* return (
|
|
62
|
+
* <SignInForm
|
|
63
|
+
* signInAction={signInAction}
|
|
64
|
+
* signInSocialAction={signInSocialAction}
|
|
65
|
+
* redirectTo="/admin"
|
|
66
|
+
* />
|
|
67
|
+
* )
|
|
54
68
|
* }
|
|
55
69
|
* ```
|
|
56
70
|
*/
|
|
57
71
|
export function SignInForm({
|
|
58
|
-
|
|
72
|
+
signInAction,
|
|
73
|
+
signInSocialAction,
|
|
59
74
|
redirectTo = '/',
|
|
60
75
|
showSocialProviders = true,
|
|
61
76
|
socialProviders = ['github', 'google'],
|
|
@@ -75,14 +90,10 @@ export function SignInForm({
|
|
|
75
90
|
setLoading(true)
|
|
76
91
|
|
|
77
92
|
try {
|
|
78
|
-
const result = await
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
if (result.error) {
|
|
85
|
-
throw new Error(result.error.message)
|
|
93
|
+
const result = await signInAction({ email, password })
|
|
94
|
+
|
|
95
|
+
if (!result.success) {
|
|
96
|
+
throw new Error(cleanAuthErrorMessage(result.error, 'Sign in failed'))
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
// If onSuccess is provided, call it. Otherwise, automatically redirect
|
|
@@ -92,7 +103,10 @@ export function SignInForm({
|
|
|
92
103
|
router.push(redirectTo)
|
|
93
104
|
}
|
|
94
105
|
} catch (err) {
|
|
95
|
-
const message =
|
|
106
|
+
const message = cleanAuthErrorMessage(
|
|
107
|
+
err instanceof Error ? err.message : undefined,
|
|
108
|
+
'Sign in failed',
|
|
109
|
+
)
|
|
96
110
|
setError(message)
|
|
97
111
|
onError?.(err instanceof Error ? err : new Error(message))
|
|
98
112
|
} finally {
|
|
@@ -101,25 +115,28 @@ export function SignInForm({
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
const handleSocialSignIn = async (provider: string) => {
|
|
118
|
+
if (!signInSocialAction) return
|
|
104
119
|
setError('')
|
|
105
120
|
setLoading(true)
|
|
106
121
|
|
|
107
122
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
})
|
|
112
|
-
// Social sign-in handles its own redirect via OAuth flow
|
|
113
|
-
// Only call onSuccess if provided
|
|
123
|
+
// The action performs a server-side redirect to the provider, so on
|
|
124
|
+
// success control does not return here.
|
|
125
|
+
await signInSocialAction(provider)
|
|
114
126
|
onSuccess?.()
|
|
115
127
|
} catch (err) {
|
|
116
|
-
const message =
|
|
128
|
+
const message = cleanAuthErrorMessage(
|
|
129
|
+
err instanceof Error ? err.message : undefined,
|
|
130
|
+
'Sign in failed',
|
|
131
|
+
)
|
|
117
132
|
setError(message)
|
|
118
133
|
onError?.(err instanceof Error ? err : new Error(message))
|
|
119
134
|
setLoading(false)
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
const canShowSocial = showSocialProviders && socialProviders.length > 0 && !!signInSocialAction
|
|
139
|
+
|
|
123
140
|
return (
|
|
124
141
|
<div className={`w-full max-w-md mx-auto p-6 ${className}`}>
|
|
125
142
|
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
|
|
@@ -170,7 +187,7 @@ export function SignInForm({
|
|
|
170
187
|
</button>
|
|
171
188
|
</form>
|
|
172
189
|
|
|
173
|
-
{
|
|
190
|
+
{canShowSocial && (
|
|
174
191
|
<>
|
|
175
192
|
<div className="relative my-6">
|
|
176
193
|
<div className="absolute inset-0 flex items-center">
|
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState } from 'react'
|
|
4
4
|
import { useRouter } from 'next/navigation.js'
|
|
5
|
-
import
|
|
5
|
+
import { cleanAuthErrorMessage } from '../lib/clean-error-message.js'
|
|
6
|
+
import type { SignUpAction, SignInSocialAction } from '../types.js'
|
|
6
7
|
|
|
7
8
|
export type SignUpFormProps = {
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Server action that creates an account with email + password.
|
|
11
|
+
* Define it in your app (`'use server'`) against your own auth instance.
|
|
11
12
|
*/
|
|
12
|
-
|
|
13
|
+
signUpAction: SignUpAction
|
|
14
|
+
/**
|
|
15
|
+
* Server action that starts an OAuth sign-in and redirects to the provider.
|
|
16
|
+
* Required to render the social provider buttons; omit to hide them.
|
|
17
|
+
*/
|
|
18
|
+
signInSocialAction?: SignInSocialAction
|
|
13
19
|
/**
|
|
14
20
|
* URL to redirect to after successful sign up
|
|
15
21
|
* @default '/'
|
|
16
22
|
*/
|
|
17
23
|
redirectTo?: string
|
|
18
24
|
/**
|
|
19
|
-
* Show OAuth provider buttons
|
|
25
|
+
* Show OAuth provider buttons (requires `signInSocialAction`)
|
|
20
26
|
* @default true
|
|
21
27
|
*/
|
|
22
28
|
showSocialProviders?: boolean
|
|
@@ -48,18 +54,22 @@ export type SignUpFormProps = {
|
|
|
48
54
|
* Sign up form component
|
|
49
55
|
* Provides email/password registration and OAuth provider buttons
|
|
50
56
|
*
|
|
57
|
+
* Submits through app-owned server actions rather than calling the auth API
|
|
58
|
+
* from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`.
|
|
59
|
+
*
|
|
51
60
|
* @example
|
|
52
61
|
* ```typescript
|
|
53
62
|
* import { SignUpForm } from '@opensaas/stack-auth/ui'
|
|
54
|
-
* import {
|
|
63
|
+
* import { signUpAction, signInSocialAction } from '@/lib/actions/auth'
|
|
55
64
|
*
|
|
56
65
|
* export default function SignUpPage() {
|
|
57
|
-
* return <SignUpForm
|
|
66
|
+
* return <SignUpForm signUpAction={signUpAction} redirectTo="/admin" />
|
|
58
67
|
* }
|
|
59
68
|
* ```
|
|
60
69
|
*/
|
|
61
70
|
export function SignUpForm({
|
|
62
|
-
|
|
71
|
+
signUpAction,
|
|
72
|
+
signInSocialAction,
|
|
63
73
|
redirectTo = '/',
|
|
64
74
|
showSocialProviders = true,
|
|
65
75
|
socialProviders = ['github', 'google'],
|
|
@@ -89,18 +99,10 @@ export function SignUpForm({
|
|
|
89
99
|
setLoading(true)
|
|
90
100
|
|
|
91
101
|
try {
|
|
92
|
-
const result = await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
callbackURL: redirectTo,
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
if (result.error) {
|
|
100
|
-
// Strip [body.field] prefixes from better-call validation errors for user-friendly display
|
|
101
|
-
const rawMessage = result.error.message ?? 'Sign up failed'
|
|
102
|
-
const cleanMessage = rawMessage.replace(/\[body\.\w+\]\s*/g, '').trim()
|
|
103
|
-
throw new Error(cleanMessage)
|
|
102
|
+
const result = await signUpAction({ name, email, password })
|
|
103
|
+
|
|
104
|
+
if (!result.success) {
|
|
105
|
+
throw new Error(cleanAuthErrorMessage(result.error, 'Sign up failed'))
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
// If onSuccess is provided, call it. Otherwise, automatically redirect
|
|
@@ -110,7 +112,10 @@ export function SignUpForm({
|
|
|
110
112
|
router.push(redirectTo)
|
|
111
113
|
}
|
|
112
114
|
} catch (err) {
|
|
113
|
-
const message =
|
|
115
|
+
const message = cleanAuthErrorMessage(
|
|
116
|
+
err instanceof Error ? err.message : undefined,
|
|
117
|
+
'Sign up failed',
|
|
118
|
+
)
|
|
114
119
|
setError(message)
|
|
115
120
|
onError?.(err instanceof Error ? err : new Error(message))
|
|
116
121
|
} finally {
|
|
@@ -119,25 +124,28 @@ export function SignUpForm({
|
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
const handleSocialSignUp = async (provider: string) => {
|
|
127
|
+
if (!signInSocialAction) return
|
|
122
128
|
setError('')
|
|
123
129
|
setLoading(true)
|
|
124
130
|
|
|
125
131
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
130
|
-
// Social sign-in handles its own redirect via OAuth flow
|
|
131
|
-
// Only call onSuccess if provided
|
|
132
|
+
// The action performs a server-side redirect to the provider, so on
|
|
133
|
+
// success control does not return here.
|
|
134
|
+
await signInSocialAction(provider)
|
|
132
135
|
onSuccess?.()
|
|
133
136
|
} catch (err) {
|
|
134
|
-
const message =
|
|
137
|
+
const message = cleanAuthErrorMessage(
|
|
138
|
+
err instanceof Error ? err.message : undefined,
|
|
139
|
+
'Sign up failed',
|
|
140
|
+
)
|
|
135
141
|
setError(message)
|
|
136
142
|
onError?.(err instanceof Error ? err : new Error(message))
|
|
137
143
|
setLoading(false)
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
const canShowSocial = showSocialProviders && socialProviders.length > 0 && !!signInSocialAction
|
|
148
|
+
|
|
141
149
|
return (
|
|
142
150
|
<div className={`w-full max-w-md mx-auto p-6 ${className}`}>
|
|
143
151
|
<h2 className="text-2xl font-bold mb-6">Sign Up</h2>
|
|
@@ -220,7 +228,7 @@ export function SignUpForm({
|
|
|
220
228
|
</button>
|
|
221
229
|
</form>
|
|
222
230
|
|
|
223
|
-
{
|
|
231
|
+
{canShowSocial && (
|
|
224
232
|
<>
|
|
225
233
|
<div className="relative my-6">
|
|
226
234
|
<div className="absolute inset-0 flex items-center">
|
package/src/ui/index.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
export { SignInForm } from './components/SignInForm.js'
|
|
2
2
|
export { SignUpForm } from './components/SignUpForm.js'
|
|
3
3
|
export { ForgotPasswordForm } from './components/ForgotPasswordForm.js'
|
|
4
|
+
export { ResetPasswordForm } from './components/ResetPasswordForm.js'
|
|
4
5
|
|
|
5
6
|
export type { SignInFormProps } from './components/SignInForm.js'
|
|
6
7
|
export type { SignUpFormProps } from './components/SignUpForm.js'
|
|
7
8
|
export type { ForgotPasswordFormProps } from './components/ForgotPasswordForm.js'
|
|
9
|
+
export type { ResetPasswordFormProps } from './components/ResetPasswordForm.js'
|
|
10
|
+
|
|
11
|
+
// Auth action contract types — the agreement between the forms and the
|
|
12
|
+
// app-owned server actions they invoke.
|
|
13
|
+
export type {
|
|
14
|
+
AuthActionResult,
|
|
15
|
+
SignInInput,
|
|
16
|
+
SignUpInput,
|
|
17
|
+
RequestPasswordResetInput,
|
|
18
|
+
ResetPasswordInput,
|
|
19
|
+
SignInAction,
|
|
20
|
+
SignUpAction,
|
|
21
|
+
RequestPasswordResetAction,
|
|
22
|
+
ResetPasswordAction,
|
|
23
|
+
SignInSocialAction,
|
|
24
|
+
} from './types.js'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip better-call's `[body.field]` validation prefixes from an auth error
|
|
3
|
+
* message so forms can display something user-friendly.
|
|
4
|
+
*
|
|
5
|
+
* Better-auth surfaces validation errors like `[body.password] Password is too
|
|
6
|
+
* short`. This removes those bracketed prefixes and normalises whitespace,
|
|
7
|
+
* falling back to a default when the message is empty or missing.
|
|
8
|
+
*
|
|
9
|
+
* Internal to the auth forms — not part of the package's public contract.
|
|
10
|
+
*/
|
|
11
|
+
export function cleanAuthErrorMessage(
|
|
12
|
+
message: string | null | undefined,
|
|
13
|
+
fallback = 'Something went wrong',
|
|
14
|
+
): string {
|
|
15
|
+
if (!message) return fallback
|
|
16
|
+
const cleaned = message
|
|
17
|
+
.replace(/\[body\.\w+\]\s*/g, '')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
return cleaned || fallback
|
|
21
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract types shared between the pre-built auth forms and the app-owned
|
|
3
|
+
* server actions they invoke.
|
|
4
|
+
*
|
|
5
|
+
* The forms live in this package but never own an `auth` instance. Instead each
|
|
6
|
+
* form receives **Auth actions** as props — `'use server'` functions the app
|
|
7
|
+
* defines against its own better-auth instance. These types are the agreement
|
|
8
|
+
* between the two: the app implements actions matching them, the forms call
|
|
9
|
+
* actions matching them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The result an email/password (non-redirecting) auth action returns.
|
|
14
|
+
* Success carries no payload; failure carries a display-ready message.
|
|
15
|
+
*/
|
|
16
|
+
export type AuthActionResult = { success: true } | { success: false; error: string }
|
|
17
|
+
|
|
18
|
+
/** Input to the sign-in action. */
|
|
19
|
+
export type SignInInput = { email: string; password: string }
|
|
20
|
+
|
|
21
|
+
/** Input to the sign-up action. */
|
|
22
|
+
export type SignUpInput = { name: string; email: string; password: string }
|
|
23
|
+
|
|
24
|
+
/** Input to the request-password-reset action. */
|
|
25
|
+
export type RequestPasswordResetInput = { email: string }
|
|
26
|
+
|
|
27
|
+
/** Input to the reset-password action. */
|
|
28
|
+
export type ResetPasswordInput = { token: string; password: string }
|
|
29
|
+
|
|
30
|
+
/** Signs a user in with email + password. */
|
|
31
|
+
export type SignInAction = (input: SignInInput) => Promise<AuthActionResult>
|
|
32
|
+
|
|
33
|
+
/** Creates an account with email + password. */
|
|
34
|
+
export type SignUpAction = (input: SignUpInput) => Promise<AuthActionResult>
|
|
35
|
+
|
|
36
|
+
/** Requests a password-reset email. */
|
|
37
|
+
export type RequestPasswordResetAction = (
|
|
38
|
+
input: RequestPasswordResetInput,
|
|
39
|
+
) => Promise<AuthActionResult>
|
|
40
|
+
|
|
41
|
+
/** Completes a password reset using a token from the reset email. */
|
|
42
|
+
export type ResetPasswordAction = (input: ResetPasswordInput) => Promise<AuthActionResult>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Starts an OAuth sign-in for the given provider. Unlike the email actions this
|
|
46
|
+
* one navigates away (it performs a server-side redirect to the provider), so
|
|
47
|
+
* it resolves to `void` and never returns a result to the form.
|
|
48
|
+
*/
|
|
49
|
+
export type SignInSocialAction = (provider: string) => Promise<void>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { cleanAuthErrorMessage } from '../src/ui/lib/clean-error-message.js'
|
|
3
|
+
|
|
4
|
+
describe('cleanAuthErrorMessage', () => {
|
|
5
|
+
it('strips a single [body.field] prefix', () => {
|
|
6
|
+
expect(cleanAuthErrorMessage('[body.password] Password too short')).toBe('Password too short')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('strips multiple [body.field] prefixes', () => {
|
|
10
|
+
expect(cleanAuthErrorMessage('[body.email] invalid [body.password] weak')).toBe('invalid weak')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('leaves a clean message untouched', () => {
|
|
14
|
+
expect(cleanAuthErrorMessage('Invalid email or password')).toBe('Invalid email or password')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to a default when the message is empty or nullish', () => {
|
|
18
|
+
expect(cleanAuthErrorMessage('', 'Sign in failed')).toBe('Sign in failed')
|
|
19
|
+
expect(cleanAuthErrorMessage(undefined, 'Sign in failed')).toBe('Sign in failed')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('uses a generic default when none is provided', () => {
|
|
23
|
+
expect(cleanAuthErrorMessage(undefined)).toBe('Something went wrong')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('trims surrounding whitespace left after stripping', () => {
|
|
27
|
+
expect(cleanAuthErrorMessage(' [body.name] Name is required ')).toBe('Name is required')
|
|
28
|
+
})
|
|
29
|
+
})
|