@opensaas/stack-auth 0.1.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 (56) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/INTEGRATION_SUMMARY.md +425 -0
  3. package/README.md +445 -0
  4. package/dist/client/index.d.ts +38 -0
  5. package/dist/client/index.d.ts.map +1 -0
  6. package/dist/client/index.js +23 -0
  7. package/dist/client/index.js.map +1 -0
  8. package/dist/config/index.d.ts +50 -0
  9. package/dist/config/index.d.ts.map +1 -0
  10. package/dist/config/index.js +115 -0
  11. package/dist/config/index.js.map +1 -0
  12. package/dist/config/types.d.ts +160 -0
  13. package/dist/config/types.d.ts.map +1 -0
  14. package/dist/config/types.js +2 -0
  15. package/dist/config/types.js.map +1 -0
  16. package/dist/index.d.ts +35 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +34 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/lists/index.d.ts +46 -0
  21. package/dist/lists/index.d.ts.map +1 -0
  22. package/dist/lists/index.js +227 -0
  23. package/dist/lists/index.js.map +1 -0
  24. package/dist/server/index.d.ts +27 -0
  25. package/dist/server/index.d.ts.map +1 -0
  26. package/dist/server/index.js +90 -0
  27. package/dist/server/index.js.map +1 -0
  28. package/dist/ui/components/ForgotPasswordForm.d.ts +36 -0
  29. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -0
  30. package/dist/ui/components/ForgotPasswordForm.js +50 -0
  31. package/dist/ui/components/ForgotPasswordForm.js.map +1 -0
  32. package/dist/ui/components/SignInForm.d.ts +52 -0
  33. package/dist/ui/components/SignInForm.d.ts.map +1 -0
  34. package/dist/ui/components/SignInForm.js +66 -0
  35. package/dist/ui/components/SignInForm.js.map +1 -0
  36. package/dist/ui/components/SignUpForm.d.ts +56 -0
  37. package/dist/ui/components/SignUpForm.d.ts.map +1 -0
  38. package/dist/ui/components/SignUpForm.js +74 -0
  39. package/dist/ui/components/SignUpForm.js.map +1 -0
  40. package/dist/ui/index.d.ts +7 -0
  41. package/dist/ui/index.d.ts.map +1 -0
  42. package/dist/ui/index.js +4 -0
  43. package/dist/ui/index.js.map +1 -0
  44. package/package.json +55 -0
  45. package/src/client/index.ts +44 -0
  46. package/src/config/index.ts +140 -0
  47. package/src/config/types.ts +166 -0
  48. package/src/index.ts +44 -0
  49. package/src/lists/index.ts +245 -0
  50. package/src/server/index.ts +120 -0
  51. package/src/ui/components/ForgotPasswordForm.tsx +120 -0
  52. package/src/ui/components/SignInForm.tsx +191 -0
  53. package/src/ui/components/SignUpForm.tsx +238 -0
  54. package/src/ui/index.ts +7 -0
  55. package/tsconfig.json +14 -0
  56. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,166 @@
1
+ import type { ExtendUserListConfig } from '../lists/index.js'
2
+
3
+ /**
4
+ * OAuth provider configuration
5
+ */
6
+ export type OAuthProvider = {
7
+ clientId: string
8
+ clientSecret: string
9
+ enabled?: boolean
10
+ }
11
+
12
+ /**
13
+ * Social provider configurations
14
+ */
15
+ export type SocialProvidersConfig = {
16
+ github?: OAuthProvider
17
+ google?: OAuthProvider
18
+ discord?: OAuthProvider
19
+ twitter?: OAuthProvider
20
+ [key: string]: OAuthProvider | undefined
21
+ }
22
+
23
+ /**
24
+ * Email and password configuration
25
+ */
26
+ export type EmailPasswordConfig = {
27
+ enabled: boolean
28
+ /**
29
+ * Minimum password length
30
+ * @default 8
31
+ */
32
+ minPasswordLength?: number
33
+ /**
34
+ * Require password confirmation
35
+ * @default true
36
+ */
37
+ requireConfirmation?: boolean
38
+ }
39
+
40
+ /**
41
+ * Email verification configuration
42
+ */
43
+ export type EmailVerificationConfig = {
44
+ enabled: boolean
45
+ /**
46
+ * Send verification email on sign up
47
+ * @default true
48
+ */
49
+ sendOnSignUp?: boolean
50
+ /**
51
+ * Token expiration in seconds
52
+ * @default 86400 (24 hours)
53
+ */
54
+ tokenExpiration?: number
55
+ }
56
+
57
+ /**
58
+ * Password reset configuration
59
+ */
60
+ export type PasswordResetConfig = {
61
+ enabled: boolean
62
+ /**
63
+ * Token expiration in seconds
64
+ * @default 3600 (1 hour)
65
+ */
66
+ tokenExpiration?: number
67
+ }
68
+
69
+ /**
70
+ * Session configuration
71
+ */
72
+ export type SessionConfig = {
73
+ /**
74
+ * Session expiration in seconds
75
+ * @default 604800 (7 days)
76
+ */
77
+ expiresIn?: number
78
+ /**
79
+ * Update session expiration on each request
80
+ * @default true
81
+ */
82
+ updateAge?: boolean
83
+ }
84
+
85
+ /**
86
+ * Auth configuration options
87
+ */
88
+ export type AuthConfig = {
89
+ /**
90
+ * Email and password authentication
91
+ */
92
+ emailAndPassword?: EmailPasswordConfig | { enabled: true }
93
+
94
+ /**
95
+ * Email verification
96
+ */
97
+ emailVerification?: EmailVerificationConfig | { enabled: true }
98
+
99
+ /**
100
+ * Password reset
101
+ */
102
+ passwordReset?: PasswordResetConfig | { enabled: true }
103
+
104
+ /**
105
+ * OAuth/social providers
106
+ */
107
+ socialProviders?: SocialProvidersConfig
108
+
109
+ /**
110
+ * Session configuration
111
+ */
112
+ session?: SessionConfig
113
+
114
+ /**
115
+ * Which fields to include in the session object
116
+ * This determines what data is available in access control functions
117
+ * @default ['userId', 'email', 'name']
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * sessionFields: ['userId', 'email', 'name', 'role']
122
+ * // session will be: { userId: string, email: string, name: string, role: string }
123
+ * ```
124
+ */
125
+ sessionFields?: string[]
126
+
127
+ /**
128
+ * Extend the auto-generated User list with custom fields
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * extendUserList: {
133
+ * fields: {
134
+ * role: text({ defaultValue: 'user' }),
135
+ * company: text(),
136
+ * }
137
+ * }
138
+ * ```
139
+ */
140
+ extendUserList?: ExtendUserListConfig
141
+
142
+ /**
143
+ * Custom email sending function for verification and password reset
144
+ * If not provided, emails will be logged to console
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * sendEmail: async ({ to, subject, html }) => {
149
+ * await resend.emails.send({ to, subject, html })
150
+ * }
151
+ * ```
152
+ */
153
+ sendEmail?: (params: { to: string; subject: string; html: string }) => Promise<void>
154
+ }
155
+
156
+ /**
157
+ * Internal normalized auth configuration
158
+ * Used after parsing user config
159
+ */
160
+ export type NormalizedAuthConfig = Required<
161
+ Omit<AuthConfig, 'emailAndPassword' | 'emailVerification' | 'passwordReset'>
162
+ > & {
163
+ emailAndPassword: Required<EmailPasswordConfig>
164
+ emailVerification: Required<EmailVerificationConfig>
165
+ passwordReset: Required<PasswordResetConfig>
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @opensaas/stack-auth
3
+ *
4
+ * Better-auth integration for OpenSaas Stack
5
+ *
6
+ * This package provides:
7
+ * - Auto-generated User, Session, Account, Verification lists
8
+ * - Session integration with OpenSaas access control
9
+ * - Pre-built auth UI components (SignIn, SignUp, ForgotPassword)
10
+ * - Easy configuration with withAuth() wrapper
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // opensaas.config.ts
15
+ * import { config } from '@opensaas/stack-core'
16
+ * import { withAuth, authConfig } from '@opensaas/stack-auth'
17
+ *
18
+ * export default withAuth(
19
+ * config({
20
+ * db: { provider: 'sqlite', url: 'file:./dev.db' },
21
+ * lists: { ... }
22
+ * }),
23
+ * authConfig({
24
+ * emailAndPassword: { enabled: true },
25
+ * emailVerification: { enabled: true },
26
+ * })
27
+ * )
28
+ * ```
29
+ */
30
+
31
+ // Config exports
32
+ export { withAuth, authConfig, normalizeAuthConfig } from './config/index.js'
33
+ export type { AuthConfig, NormalizedAuthConfig } from './config/index.js'
34
+ export type * from './config/types.js'
35
+
36
+ // List generators (for advanced use cases)
37
+ export {
38
+ getAuthLists,
39
+ createUserList,
40
+ createSessionList,
41
+ createAccountList,
42
+ createVerificationList,
43
+ } from './lists/index.js'
44
+ export type { ExtendUserListConfig } from './lists/index.js'
@@ -0,0 +1,245 @@
1
+ import { list } from '@opensaas/stack-core'
2
+ import { text, timestamp, checkbox, relationship } from '@opensaas/stack-core/fields'
3
+ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
4
+
5
+ /**
6
+ * Configuration for extending the auto-generated User list
7
+ */
8
+ export type ExtendUserListConfig = {
9
+ /**
10
+ * Additional fields to add to the User list
11
+ * You can add custom fields beyond the basic better-auth fields
12
+ */
13
+ fields?: Record<string, FieldConfig>
14
+ /**
15
+ * Access control for the User list
16
+ * If not provided, defaults to basic access control (users can update their own records)
17
+ */
18
+ access?: ListConfig['access']
19
+ /**
20
+ * Hooks for the User list
21
+ */
22
+ hooks?: ListConfig['hooks']
23
+ }
24
+
25
+ /**
26
+ * Create the base User list with better-auth required fields
27
+ * This matches the better-auth user schema
28
+ */
29
+ export function createUserList(config?: ExtendUserListConfig): ListConfig {
30
+ return list({
31
+ fields: {
32
+ // Better-auth required fields
33
+ name: text({
34
+ validation: { isRequired: true },
35
+ }),
36
+ email: text({
37
+ validation: { isRequired: true },
38
+ isIndexed: 'unique',
39
+ }),
40
+ emailVerified: checkbox({
41
+ defaultValue: false,
42
+ }),
43
+ image: text(),
44
+
45
+ // Relationships to other auth tables
46
+ sessions: relationship({
47
+ ref: 'Session.user',
48
+ many: true,
49
+ }),
50
+ accounts: relationship({
51
+ ref: 'Account.user',
52
+ many: true,
53
+ }),
54
+
55
+ // Custom fields from user config
56
+ ...(config?.fields || {}),
57
+ },
58
+ access: config?.access || {
59
+ operation: {
60
+ // Anyone can query users (for displaying names, etc.)
61
+ query: () => true,
62
+ // Anyone can create a user (sign up)
63
+ create: () => true,
64
+ // Only update your own user record
65
+ update: ({ session, item }) => {
66
+ if (!session) return false
67
+ const userId = (session as { userId?: string }).userId
68
+ const itemId = (item as { id?: string })?.id
69
+ return userId === itemId
70
+ },
71
+ // Only delete your own user record
72
+ delete: ({ session, item }) => {
73
+ if (!session) return false
74
+ const userId = (session as { userId?: string }).userId
75
+ const itemId = (item as { id?: string })?.id
76
+ return userId === itemId
77
+ },
78
+ },
79
+ },
80
+ hooks: config?.hooks,
81
+ })
82
+ }
83
+
84
+ /**
85
+ * Create the Session list for better-auth
86
+ * Stores active user sessions
87
+ */
88
+ export function createSessionList(): ListConfig {
89
+ return list({
90
+ fields: {
91
+ // Session token (stored in cookie, used as primary key)
92
+ token: text({
93
+ validation: { isRequired: true },
94
+ isIndexed: 'unique',
95
+ }),
96
+ // Expiration timestamp
97
+ expiresAt: timestamp(),
98
+ // Optional: IP address for security
99
+ ipAddress: text(),
100
+ // Optional: User agent for security
101
+ userAgent: text(),
102
+ // Relationship to user (userId will be auto-generated)
103
+ user: relationship({
104
+ ref: 'User.sessions',
105
+ }),
106
+ },
107
+ access: {
108
+ operation: {
109
+ // Only the session owner can query their sessions
110
+ query: ({ session }) => {
111
+ if (!session) return false
112
+ const userId = (session as { userId?: string }).userId
113
+ if (!userId) return false
114
+ // Return Prisma filter for nested relationship
115
+ return {
116
+ user: {
117
+ id: { equals: userId },
118
+ },
119
+ } as Record<string, unknown>
120
+ },
121
+ // Better-auth handles session creation
122
+ create: () => true,
123
+ // No manual updates
124
+ update: () => false,
125
+ // Better-auth handles session deletion (logout)
126
+ delete: ({ session, item }) => {
127
+ if (!session) return false
128
+ const userId = (session as { userId?: string }).userId
129
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
130
+ return userId === itemUserId
131
+ },
132
+ },
133
+ },
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Create the Account list for better-auth
139
+ * Stores OAuth provider accounts and credentials
140
+ */
141
+ export function createAccountList(): ListConfig {
142
+ return list({
143
+ fields: {
144
+ // Account identifier from provider
145
+ accountId: text({
146
+ validation: { isRequired: true },
147
+ }),
148
+ // Provider identifier (e.g., 'github', 'google', 'credentials')
149
+ providerId: text({
150
+ validation: { isRequired: true },
151
+ }),
152
+ // Relationship to user (userId will be auto-generated)
153
+ user: relationship({
154
+ ref: 'User.accounts',
155
+ }),
156
+ // OAuth tokens
157
+ accessToken: text(),
158
+ refreshToken: text(),
159
+ accessTokenExpiresAt: timestamp(),
160
+ refreshTokenExpiresAt: timestamp(),
161
+ scope: text(),
162
+ idToken: text(),
163
+ // Password hash for credential provider (better-auth stores in account table)
164
+ password: text(),
165
+ },
166
+ access: {
167
+ operation: {
168
+ // Only the account owner can query their accounts
169
+ query: ({ session }) => {
170
+ if (!session) return false
171
+ const userId = (session as { userId?: string }).userId
172
+ if (!userId) return false
173
+ // Return Prisma filter for nested relationship
174
+ return {
175
+ user: {
176
+ id: { equals: userId },
177
+ },
178
+ } as Record<string, unknown>
179
+ },
180
+ // Better-auth handles account creation
181
+ create: () => true,
182
+ // Better-auth handles account updates (token refresh)
183
+ update: ({ session, item }) => {
184
+ if (!session) return false
185
+ const userId = (session as { userId?: string }).userId
186
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
187
+ return userId === itemUserId
188
+ },
189
+ // Account owner can delete their accounts
190
+ delete: ({ session, item }) => {
191
+ if (!session) return false
192
+ const userId = (session as { userId?: string }).userId
193
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
194
+ return userId === itemUserId
195
+ },
196
+ },
197
+ },
198
+ })
199
+ }
200
+
201
+ /**
202
+ * Create the Verification list for better-auth
203
+ * Stores email verification tokens, password reset tokens, etc.
204
+ */
205
+ export function createVerificationList(): ListConfig {
206
+ return list({
207
+ fields: {
208
+ // Identifier (e.g., email address)
209
+ identifier: text({
210
+ validation: { isRequired: true },
211
+ }),
212
+ // Token value
213
+ value: text({
214
+ validation: { isRequired: true },
215
+ }),
216
+ // Expiration timestamp
217
+ expiresAt: timestamp(),
218
+ },
219
+ access: {
220
+ operation: {
221
+ // No public querying (better-auth handles verification internally)
222
+ query: () => false,
223
+ // Better-auth creates verification tokens
224
+ create: () => true,
225
+ // No updates
226
+ update: () => false,
227
+ // Better-auth deletes used/expired tokens
228
+ delete: () => true,
229
+ },
230
+ },
231
+ })
232
+ }
233
+
234
+ /**
235
+ * Get all auth lists required by better-auth
236
+ * This is the main export used by withAuth()
237
+ */
238
+ export function getAuthLists(userConfig?: ExtendUserListConfig): Record<string, ListConfig> {
239
+ return {
240
+ User: createUserList(userConfig),
241
+ Session: createSessionList(),
242
+ Account: createAccountList(),
243
+ Verification: createVerificationList(),
244
+ }
245
+ }
@@ -0,0 +1,120 @@
1
+ import { betterAuth } from 'better-auth'
2
+ import { prismaAdapter } from 'better-auth/adapters/prisma'
3
+ import type { BetterAuthOptions } from 'better-auth'
4
+ import type { OpenSaasConfig, DatabaseConfig, AccessContext } from '@opensaas/stack-core'
5
+ import type { NormalizedAuthConfig } from '../config/types.js'
6
+
7
+ /**
8
+ * Get better-auth database configuration from OpenSaas config
9
+ */
10
+ function getDatabaseConfig(
11
+ dbConfig: DatabaseConfig,
12
+ context: AccessContext,
13
+ ): BetterAuthOptions['database'] {
14
+ return prismaAdapter(context.prisma, {
15
+ provider: dbConfig.provider,
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Create a better-auth instance from OpenSaas config
21
+ * This should be called once at app startup
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * // lib/auth.ts
26
+ * import { createAuth } from '@opensaas/stack-auth/server'
27
+ * import config from '../opensaas.config'
28
+ *
29
+ * export const auth = createAuth(config)
30
+ * ```
31
+ */
32
+ export function createAuth(
33
+ opensaasConfig: OpenSaasConfig & { __authConfig?: NormalizedAuthConfig },
34
+ context: AccessContext,
35
+ ) {
36
+ // Extract auth config (added by withAuth)
37
+ const authConfig = opensaasConfig.__authConfig
38
+
39
+ if (!authConfig) {
40
+ throw new Error(
41
+ 'Auth config not found. Make sure to wrap your config with withAuth() in opensaas.config.ts',
42
+ )
43
+ }
44
+
45
+ // Build better-auth configuration
46
+ const betterAuthConfig: BetterAuthOptions = {
47
+ database: getDatabaseConfig(opensaasConfig.db, context),
48
+
49
+ // Enable email and password if configured
50
+ emailAndPassword: authConfig.emailAndPassword.enabled
51
+ ? {
52
+ enabled: true,
53
+ requireEmailVerification: authConfig.emailVerification.enabled,
54
+ }
55
+ : undefined,
56
+
57
+ // Configure session
58
+ session: {
59
+ expiresIn: authConfig.session.expiresIn || 604800,
60
+ updateAge: authConfig.session.updateAge ? (authConfig.session.expiresIn || 604800) / 10 : 0,
61
+ },
62
+
63
+ // Trust host (required for production)
64
+ trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
65
+
66
+ // Social providers
67
+ socialProviders: Object.entries(authConfig.socialProviders)
68
+ .filter(([_, config]) => config?.enabled !== false)
69
+ .reduce(
70
+ (acc, [provider, config]) => {
71
+ if (config) {
72
+ acc[provider] = {
73
+ clientId: config.clientId,
74
+ clientSecret: config.clientSecret,
75
+ }
76
+ }
77
+ return acc
78
+ },
79
+ {} as Record<string, { clientId: string; clientSecret: string }>,
80
+ ),
81
+ }
82
+
83
+ return betterAuth(betterAuthConfig)
84
+ }
85
+
86
+ /**
87
+ * Get session from better-auth and transform it to OpenSaas session format
88
+ * This is used internally by the generated context
89
+ */
90
+ export async function getSessionFromAuth(
91
+ auth: ReturnType<typeof betterAuth>,
92
+ sessionFields: string[],
93
+ ) {
94
+ try {
95
+ const session = await auth.api.getSession({
96
+ headers: new Headers(),
97
+ })
98
+
99
+ if (!session?.user) {
100
+ return null
101
+ }
102
+
103
+ // Build session object with requested fields
104
+ const result: Record<string, unknown> = {}
105
+
106
+ for (const field of sessionFields) {
107
+ if (field === 'userId') {
108
+ result.userId = session.user.id
109
+ } else if (field in session.user) {
110
+ result[field] = session.user[field as keyof typeof session.user]
111
+ }
112
+ }
113
+
114
+ return result
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ export type { BetterAuthOptions }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import type { createAuthClient } from 'better-auth/react'
5
+
6
+ export type ForgotPasswordFormProps = {
7
+ /**
8
+ * Better-auth client instance
9
+ * Created with createAuthClient from better-auth/react
10
+ */
11
+ authClient: ReturnType<typeof createAuthClient>
12
+ /**
13
+ * Custom CSS class for the form container
14
+ */
15
+ className?: string
16
+ /**
17
+ * Callback when reset email is sent successfully
18
+ */
19
+ onSuccess?: () => void
20
+ /**
21
+ * Callback when reset fails
22
+ */
23
+ onError?: (error: Error) => void
24
+ }
25
+
26
+ /**
27
+ * Forgot password form component
28
+ * Allows users to request a password reset email
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
33
+ * import { authClient } from '@/lib/auth-client'
34
+ *
35
+ * export default function ForgotPasswordPage() {
36
+ * return <ForgotPasswordForm authClient={authClient} />
37
+ * }
38
+ * ```
39
+ */
40
+ export function ForgotPasswordForm({
41
+ authClient,
42
+ className = '',
43
+ onSuccess,
44
+ onError,
45
+ }: ForgotPasswordFormProps) {
46
+ const [email, setEmail] = useState('')
47
+ const [error, setError] = useState('')
48
+ const [success, setSuccess] = useState(false)
49
+ const [loading, setLoading] = useState(false)
50
+
51
+ const handleSubmit = async (e: React.FormEvent) => {
52
+ e.preventDefault()
53
+ setError('')
54
+ setSuccess(false)
55
+ setLoading(true)
56
+
57
+ try {
58
+ const result = await authClient.forgetPassword({
59
+ email,
60
+ redirectTo: '/reset-password',
61
+ })
62
+
63
+ if (result.error) {
64
+ throw new Error(result.error.message)
65
+ }
66
+
67
+ setSuccess(true)
68
+ onSuccess?.()
69
+ } catch (err) {
70
+ const message = err instanceof Error ? err.message : 'Failed to send reset email'
71
+ setError(message)
72
+ onError?.(err instanceof Error ? err : new Error(message))
73
+ } finally {
74
+ setLoading(false)
75
+ }
76
+ }
77
+
78
+ return (
79
+ <div className={`w-full max-w-md mx-auto p-6 ${className}`}>
80
+ <h2 className="text-2xl font-bold mb-6">Forgot Password</h2>
81
+
82
+ {error && (
83
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
84
+ {error}
85
+ </div>
86
+ )}
87
+
88
+ {success && (
89
+ <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-4">
90
+ Password reset email sent! Check your inbox for instructions.
91
+ </div>
92
+ )}
93
+
94
+ <form onSubmit={handleSubmit} className="space-y-4">
95
+ <div>
96
+ <label htmlFor="email" className="block text-sm font-medium mb-2">
97
+ Email
98
+ </label>
99
+ <input
100
+ id="email"
101
+ type="email"
102
+ value={email}
103
+ onChange={(e) => setEmail((e.target as HTMLInputElement).value)}
104
+ required
105
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
106
+ disabled={loading || success}
107
+ />
108
+ </div>
109
+
110
+ <button
111
+ type="submit"
112
+ disabled={loading || success}
113
+ 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"
114
+ >
115
+ {loading ? 'Sending...' : success ? 'Email Sent' : 'Send Reset Link'}
116
+ </button>
117
+ </form>
118
+ </div>
119
+ )
120
+ }