@opensaas/stack-auth 0.31.1 → 0.32.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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +42 -7
  4. package/README.md +64 -18
  5. package/dist/server/index.d.ts.map +1 -1
  6. package/dist/server/index.js +8 -2
  7. package/dist/server/index.js.map +1 -1
  8. package/dist/ui/components/ForgotPasswordForm.d.ts +10 -7
  9. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
  10. package/dist/ui/components/ForgotPasswordForm.js +11 -10
  11. package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
  12. package/dist/ui/components/ResetPasswordForm.d.ts +60 -0
  13. package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -0
  14. package/dist/ui/components/ResetPasswordForm.js +70 -0
  15. package/dist/ui/components/ResetPasswordForm.js.map +1 -0
  16. package/dist/ui/components/SignInForm.d.ts +22 -9
  17. package/dist/ui/components/SignInForm.d.ts.map +1 -1
  18. package/dist/ui/components/SignInForm.js +25 -19
  19. package/dist/ui/components/SignInForm.js.map +1 -1
  20. package/dist/ui/components/SignUpForm.d.ts +16 -8
  21. package/dist/ui/components/SignUpForm.d.ts.map +1 -1
  22. package/dist/ui/components/SignUpForm.js +19 -23
  23. package/dist/ui/components/SignUpForm.js.map +1 -1
  24. package/dist/ui/index.d.ts +3 -0
  25. package/dist/ui/index.d.ts.map +1 -1
  26. package/dist/ui/index.js +1 -0
  27. package/dist/ui/index.js.map +1 -1
  28. package/dist/ui/lib/clean-error-message.d.ts +12 -0
  29. package/dist/ui/lib/clean-error-message.d.ts.map +1 -0
  30. package/dist/ui/lib/clean-error-message.js +20 -0
  31. package/dist/ui/lib/clean-error-message.js.map +1 -0
  32. package/dist/ui/types.d.ts +55 -0
  33. package/dist/ui/types.d.ts.map +1 -0
  34. package/dist/ui/types.js +12 -0
  35. package/dist/ui/types.js.map +1 -0
  36. package/package.json +3 -3
  37. package/src/server/index.ts +8 -2
  38. package/src/ui/components/ForgotPasswordForm.tsx +18 -14
  39. package/src/ui/components/ResetPasswordForm.tsx +182 -0
  40. package/src/ui/components/SignInForm.tsx +43 -26
  41. package/src/ui/components/SignUpForm.tsx +37 -29
  42. package/src/ui/index.ts +17 -0
  43. package/src/ui/lib/clean-error-message.ts +21 -0
  44. package/src/ui/types.ts +49 -0
  45. package/tests/clean-error-message.test.ts +29 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -1,14 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useState } from 'react'
4
- import type { createAuthClient } from 'better-auth/react'
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
- * Better-auth client instance
9
- * Created with createAuthClient from better-auth/react
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
- authClient: ReturnType<typeof createAuthClient>
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 { authClient } from '@/lib/auth-client'
37
+ * import { requestPasswordResetAction } from '@/lib/actions/auth'
34
38
  *
35
39
  * export default function ForgotPasswordPage() {
36
- * return <ForgotPasswordForm authClient={authClient} />
40
+ * return <ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />
37
41
  * }
38
42
  * ```
39
43
  */
40
44
  export function ForgotPasswordForm({
41
- authClient,
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 authClient.requestPasswordReset({
59
- email,
60
- redirectTo: '/reset-password',
61
- })
62
+ const result = await requestPasswordResetAction({ email })
62
63
 
63
- if (result.error) {
64
- throw new Error(result.error.message)
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 = err instanceof Error ? err.message : 'Failed to send reset email'
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 type { createAuthClient } from 'better-auth/react'
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
- * Better-auth client instance
10
- * Created with createAuthClient from better-auth/react
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
- authClient: ReturnType<typeof createAuthClient>
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 { authClient } from '@/lib/auth-client'
58
+ * import { signInAction, signInSocialAction } from '@/lib/actions/auth'
51
59
  *
52
60
  * export default function SignInPage() {
53
- * return <SignInForm authClient={authClient} redirectTo="/admin" />
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
- authClient,
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 authClient.signIn.email({
79
- email,
80
- password,
81
- callbackURL: redirectTo,
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 = err instanceof Error ? err.message : 'Sign in failed'
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
- await authClient.signIn.social({
109
- provider,
110
- callbackURL: redirectTo,
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 = err instanceof Error ? err.message : 'Sign in failed'
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
- {showSocialProviders && socialProviders.length > 0 && (
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 type { createAuthClient } from 'better-auth/react'
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
- * Better-auth client instance
10
- * Created with createAuthClient from better-auth/react
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
- authClient: ReturnType<typeof createAuthClient>
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 { authClient } from '@/lib/auth-client'
63
+ * import { signUpAction, signInSocialAction } from '@/lib/actions/auth'
55
64
  *
56
65
  * export default function SignUpPage() {
57
- * return <SignUpForm authClient={authClient} redirectTo="/admin" />
66
+ * return <SignUpForm signUpAction={signUpAction} redirectTo="/admin" />
58
67
  * }
59
68
  * ```
60
69
  */
61
70
  export function SignUpForm({
62
- authClient,
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 authClient.signUp.email({
93
- email,
94
- password,
95
- name,
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 = err instanceof Error ? err.message : 'Sign up failed'
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
- await authClient.signIn.social({
127
- provider,
128
- callbackURL: redirectTo,
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 = err instanceof Error ? err.message : 'Sign up failed'
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
- {showSocialProviders && socialProviders.length > 0 && (
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
+ }
@@ -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
+ })