@amirulabu/create-recurring-rabbit-app 0.0.0-alpha
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/bin/index.js +2 -0
- package/dist/index.js +592 -0
- package/package.json +43 -0
- package/templates/default/.editorconfig +21 -0
- package/templates/default/.env.example +15 -0
- package/templates/default/.eslintrc.json +35 -0
- package/templates/default/.prettierrc.json +7 -0
- package/templates/default/README.md +346 -0
- package/templates/default/app.config.ts +20 -0
- package/templates/default/docs/adding-features.md +439 -0
- package/templates/default/docs/adr/001-use-sqlite-for-development-database.md +22 -0
- package/templates/default/docs/adr/002-use-tanstack-start-over-nextjs.md +22 -0
- package/templates/default/docs/adr/003-use-better-auth-over-nextauth.md +22 -0
- package/templates/default/docs/adr/004-use-drizzle-over-prisma.md +22 -0
- package/templates/default/docs/adr/005-use-trpc-for-api-layer.md +22 -0
- package/templates/default/docs/adr/006-use-tailwind-css-v4-with-shadcn-ui.md +22 -0
- package/templates/default/docs/architecture.md +241 -0
- package/templates/default/docs/database.md +376 -0
- package/templates/default/docs/deployment.md +435 -0
- package/templates/default/docs/troubleshooting.md +668 -0
- package/templates/default/drizzle/migrations/0001_initial_schema.sql +39 -0
- package/templates/default/drizzle/migrations/meta/0001_snapshot.json +225 -0
- package/templates/default/drizzle/migrations/meta/_journal.json +12 -0
- package/templates/default/drizzle.config.ts +10 -0
- package/templates/default/lighthouserc.json +78 -0
- package/templates/default/src/app/__root.tsx +32 -0
- package/templates/default/src/app/api/auth/$.ts +15 -0
- package/templates/default/src/app/api/trpc.server.ts +12 -0
- package/templates/default/src/app/auth/forgot-password.tsx +107 -0
- package/templates/default/src/app/auth/login.tsx +34 -0
- package/templates/default/src/app/auth/register.tsx +34 -0
- package/templates/default/src/app/auth/reset-password.tsx +171 -0
- package/templates/default/src/app/auth/verify-email.tsx +111 -0
- package/templates/default/src/app/dashboard/index.tsx +122 -0
- package/templates/default/src/app/dashboard/settings.tsx +161 -0
- package/templates/default/src/app/globals.css +55 -0
- package/templates/default/src/app/index.tsx +83 -0
- package/templates/default/src/components/features/auth/login-form.tsx +172 -0
- package/templates/default/src/components/features/auth/register-form.tsx +202 -0
- package/templates/default/src/components/layout/dashboard-layout.tsx +27 -0
- package/templates/default/src/components/layout/header.tsx +29 -0
- package/templates/default/src/components/layout/sidebar.tsx +38 -0
- package/templates/default/src/components/ui/button.tsx +57 -0
- package/templates/default/src/components/ui/card.tsx +79 -0
- package/templates/default/src/components/ui/input.tsx +24 -0
- package/templates/default/src/lib/api.ts +42 -0
- package/templates/default/src/lib/auth.ts +292 -0
- package/templates/default/src/lib/email.ts +221 -0
- package/templates/default/src/lib/env.ts +119 -0
- package/templates/default/src/lib/hydration-timing.ts +289 -0
- package/templates/default/src/lib/monitoring.ts +336 -0
- package/templates/default/src/lib/utils.ts +6 -0
- package/templates/default/src/server/api/root.ts +10 -0
- package/templates/default/src/server/api/routers/dashboard.ts +37 -0
- package/templates/default/src/server/api/routers/user.ts +31 -0
- package/templates/default/src/server/api/trpc.ts +132 -0
- package/templates/default/src/server/auth/config.ts +241 -0
- package/templates/default/src/server/db/index.ts +153 -0
- package/templates/default/src/server/db/migrate.ts +125 -0
- package/templates/default/src/server/db/schema.ts +170 -0
- package/templates/default/src/server/db/seed.ts +130 -0
- package/templates/default/src/types/global.d.ts +25 -0
- package/templates/default/tailwind.config.js +46 -0
- package/templates/default/tsconfig.json +36 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
6
|
+
|
|
7
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
8
|
+
({ className, type, ...props }, ref) => {
|
|
9
|
+
return (
|
|
10
|
+
<input
|
|
11
|
+
type={type}
|
|
12
|
+
className={cn(
|
|
13
|
+
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
ref={ref}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
Input.displayName = 'Input'
|
|
23
|
+
|
|
24
|
+
export { Input }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tRPC client configuration module for @trpc/tanstack-react-query v11.
|
|
3
|
+
*
|
|
4
|
+
* This module sets up tRPC React client for type-safe API communication between
|
|
5
|
+
* frontend and backend. tRPC provides end-to-end type safety without requiring
|
|
6
|
+
* separate API contracts or code generation steps.
|
|
7
|
+
*
|
|
8
|
+
* Key patterns and conventions:
|
|
9
|
+
* - Use `useTRPC()` hook to get the tRPC proxy in components
|
|
10
|
+
* - Use `useQuery(trpc.procedure.queryOptions({ input }))` for queries
|
|
11
|
+
* - Use `useMutation(trpc.procedure.mutationOptions())` for mutations
|
|
12
|
+
* - Type definitions are inferred from backend AppRouter
|
|
13
|
+
* - Uses TanStack React Query under the hood for caching and deduplication
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Query usage in a component
|
|
17
|
+
* function Dashboard() {
|
|
18
|
+
* const trpc = useTRPC()
|
|
19
|
+
* const { data: stats } = useQuery(trpc.dashboard.getStats.queryOptions())
|
|
20
|
+
* return <div>{stats?.totalUsers}</div>
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Mutation usage in a component
|
|
25
|
+
* function ProfileSettings() {
|
|
26
|
+
* const trpc = useTRPC()
|
|
27
|
+
* const { data: user } = useQuery(trpc.user.getProfile.queryOptions())
|
|
28
|
+
* const updateProfile = useMutation(trpc.user.updateProfile.mutationOptions())
|
|
29
|
+
*
|
|
30
|
+
* const handleSubmit = async () => {
|
|
31
|
+
* await updateProfile.mutateAsync({ name: 'New Name' })
|
|
32
|
+
* }
|
|
33
|
+
* return <form onSubmit={handleSubmit}>...</form>
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createTRPCContext } from '@trpc/tanstack-react-query'
|
|
38
|
+
import type { AppRouter } from '@/server/api/root'
|
|
39
|
+
|
|
40
|
+
export const { TRPCProvider, useTRPC, useTRPCClient } =
|
|
41
|
+
createTRPCContext<AppRouter>()
|
|
42
|
+
export type { AppRouter }
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-Side Authentication Module
|
|
3
|
+
*
|
|
4
|
+
* This module provides the React hooks and client functions for authentication
|
|
5
|
+
* operations in the browser. It communicates with the server-side auth API
|
|
6
|
+
* to handle user sessions, sign in, sign out, and password recovery.
|
|
7
|
+
*
|
|
8
|
+
* KEY AUTHENTICATION FLOWS:
|
|
9
|
+
* 1. Sign In - Authenticate with credentials (email/password or OAuth)
|
|
10
|
+
* 2. Sign Up - Create new user account
|
|
11
|
+
* 3. Sign Out - Terminate current session
|
|
12
|
+
* 4. Session Management - Query and react to session state changes
|
|
13
|
+
* 5. Password Recovery - Request and complete password reset
|
|
14
|
+
*
|
|
15
|
+
* SECURITY CONSIDERATIONS:
|
|
16
|
+
* - All authentication requests go through the server (never directly to database)
|
|
17
|
+
* - Session tokens are stored in HTTP-only cookies (inaccessible to JavaScript)
|
|
18
|
+
* - Client only maintains session state, never raw credentials
|
|
19
|
+
* - Session state is reactive and automatically updates from server
|
|
20
|
+
* - SSR (Server-Side Rendering) safe: checks window object before accessing browser APIs
|
|
21
|
+
*
|
|
22
|
+
* SSR/SSG COMPATIBILITY:
|
|
23
|
+
* - Checks for window object before accessing browser-only APIs
|
|
24
|
+
* - Falls back to PUBLIC_APP_URL for server-side rendering
|
|
25
|
+
* - Default to localhost:3000 for development
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createAuthClient } from 'better-auth/react'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Authentication client instance configured for the application.
|
|
32
|
+
*
|
|
33
|
+
* This client is the main entry point for all client-side authentication operations.
|
|
34
|
+
* It's initialized with the appropriate base URL depending on whether it's running
|
|
35
|
+
* in the browser or on the server (for SSR).
|
|
36
|
+
*
|
|
37
|
+
* BASE URL RESOLUTION:
|
|
38
|
+
* 1. Browser context: Uses window.location.origin (current domain)
|
|
39
|
+
* 2. Server context (SSR): Uses PUBLIC_APP_URL environment variable
|
|
40
|
+
* 3. Fallback: Uses localhost:3000 for local development
|
|
41
|
+
*
|
|
42
|
+
* SECURITY:
|
|
43
|
+
* - Base URL must match the server auth endpoint to prevent origin errors
|
|
44
|
+
* - In production, ensure PUBLIC_APP_URL is set correctly
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* import { authClient } from '@/lib/auth'
|
|
49
|
+
*
|
|
50
|
+
* // Sign in with email and password
|
|
51
|
+
* const result = await authClient.signIn.email({
|
|
52
|
+
* email: 'user@example.com',
|
|
53
|
+
* password: 'password123'
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export const authClient = createAuthClient({
|
|
58
|
+
/**
|
|
59
|
+
* Base URL for authentication API endpoints.
|
|
60
|
+
*
|
|
61
|
+
* Dynamically determined based on execution context:
|
|
62
|
+
* - Browser: Current origin (domain + protocol + port)
|
|
63
|
+
* - Server: PUBLIC_APP_URL environment variable
|
|
64
|
+
* - Fallback: http://localhost:3000
|
|
65
|
+
*
|
|
66
|
+
* SECURITY: Must match the actual auth server URL. Mismatched origins
|
|
67
|
+
* will cause CORS errors and prevent authentication from working.
|
|
68
|
+
*/
|
|
69
|
+
baseURL:
|
|
70
|
+
typeof window !== 'undefined'
|
|
71
|
+
? window.location.origin
|
|
72
|
+
: process.env.PUBLIC_APP_URL || 'http://localhost:3000',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Destructured authentication client methods for easier imports.
|
|
77
|
+
*
|
|
78
|
+
* These functions and hooks provide the primary authentication interface
|
|
79
|
+
* for React components. Each method is typed and ready to use throughout
|
|
80
|
+
* the application.
|
|
81
|
+
*
|
|
82
|
+
* EXPORTED METHODS:
|
|
83
|
+
* - signIn: General sign-in function (supports OAuth and other providers)
|
|
84
|
+
* - signUp: Create new user account
|
|
85
|
+
* - signOut: Terminate current user session
|
|
86
|
+
* - useSession: React hook for accessing session state
|
|
87
|
+
* - signInEmail: Sign in specifically with email/password
|
|
88
|
+
* - signUpEmail: Create account with email/password
|
|
89
|
+
* - forgotPassword: Request password reset email
|
|
90
|
+
* - resetPassword: Complete password reset with token
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sign in with credentials.
|
|
95
|
+
*
|
|
96
|
+
* Authenticates a user with email/password or OAuth providers.
|
|
97
|
+
*
|
|
98
|
+
* @param credentials - User credentials (email, password, provider)
|
|
99
|
+
* @returns Promise resolving to session data and user object
|
|
100
|
+
*
|
|
101
|
+
* SECURITY:
|
|
102
|
+
* - Credentials are sent over HTTPS in production
|
|
103
|
+
* - Server validates credentials before creating session
|
|
104
|
+
* - Session token stored in HTTP-only cookie after success
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```tsx
|
|
108
|
+
* const { data, error } = await signIn({
|
|
109
|
+
* email: 'user@example.com',
|
|
110
|
+
* password: 'password123'
|
|
111
|
+
* })
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export const { signIn } = authClient
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Sign up new user account.
|
|
118
|
+
*
|
|
119
|
+
* Creates a new user account with the provided credentials.
|
|
120
|
+
* Email verification is required before sign in is allowed.
|
|
121
|
+
*
|
|
122
|
+
* @param credentials - Registration credentials (email, password)
|
|
123
|
+
* @returns Promise resolving to created user data
|
|
124
|
+
*
|
|
125
|
+
* SECURITY:
|
|
126
|
+
* - Password is hashed on the server before storage
|
|
127
|
+
* - Email verification required prevents spam accounts
|
|
128
|
+
* - Same password rules apply as sign-in
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```tsx
|
|
132
|
+
* const { data, error } = await signUp({
|
|
133
|
+
* email: 'user@example.com',
|
|
134
|
+
* password: 'securePassword123'
|
|
135
|
+
* })
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export const { signUp } = authClient
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sign out current user.
|
|
142
|
+
*
|
|
143
|
+
* Terminates the current user session and clears authentication state.
|
|
144
|
+
*
|
|
145
|
+
* @returns Promise resolving when sign out is complete
|
|
146
|
+
*
|
|
147
|
+
* SECURITY:
|
|
148
|
+
* - Invalidates session token on server
|
|
149
|
+
* - Clears session cookie
|
|
150
|
+
* - All subsequent requests will be unauthenticated
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```tsx
|
|
154
|
+
* await signOut()
|
|
155
|
+
* // User is now signed out
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export const { signOut } = authClient
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* React hook for accessing session state.
|
|
162
|
+
*
|
|
163
|
+
* Provides reactive access to the current user's session and user data.
|
|
164
|
+
* Automatically updates when session changes (sign in, sign out, expiry).
|
|
165
|
+
*
|
|
166
|
+
* @returns Object containing:
|
|
167
|
+
* - data: Current session and user data (null if not authenticated)
|
|
168
|
+
* - error: Error object if session check failed
|
|
169
|
+
* - isLoading: Boolean indicating if session is being fetched
|
|
170
|
+
* - isPending: Boolean indicating if session is being refreshed
|
|
171
|
+
*
|
|
172
|
+
* SECURITY:
|
|
173
|
+
* - Only exposes non-sensitive user data
|
|
174
|
+
* - Automatically handles session expiry
|
|
175
|
+
* - Safe to call from any component
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```tsx
|
|
179
|
+
* function UserProfile() {
|
|
180
|
+
* const { data: session, isLoading } = useSession()
|
|
181
|
+
*
|
|
182
|
+
* if (isLoading) return <LoadingSpinner />
|
|
183
|
+
* if (!session) return <SignInButton />
|
|
184
|
+
*
|
|
185
|
+
* return <div>Welcome, {session.user.name}</div>
|
|
186
|
+
* }
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export const { useSession } = authClient
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sign in with email and password.
|
|
193
|
+
*
|
|
194
|
+
* In better-auth v1.0.0+, signIn is an object with provider-specific
|
|
195
|
+
* methods. Use signIn.email() for email/password authentication.
|
|
196
|
+
*
|
|
197
|
+
* @param credentials - Object containing email and password
|
|
198
|
+
* @returns Promise resolving to session data
|
|
199
|
+
*
|
|
200
|
+
* SECURITY:
|
|
201
|
+
* - Validates email format and password strength on server
|
|
202
|
+
* - Rate limiting prevents brute force attacks
|
|
203
|
+
* - Failed attempts are logged for security monitoring
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```tsx
|
|
207
|
+
* const { data, error } = await authClient.signIn.email({
|
|
208
|
+
* email: 'user@example.com',
|
|
209
|
+
* password: 'password123'
|
|
210
|
+
* })
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
// Note: signIn.email() is the method for email/password auth
|
|
214
|
+
// Example usage: await authClient.signIn.email({ email, password })
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Sign up with email and password.
|
|
218
|
+
*
|
|
219
|
+
* In better-auth v1.0.0+, signUp is an object with provider-specific
|
|
220
|
+
* methods. Use signUp.email() for email/password registration.
|
|
221
|
+
*
|
|
222
|
+
* @param credentials - Object containing email, password, and name
|
|
223
|
+
* @returns Promise resolving to created user data
|
|
224
|
+
*
|
|
225
|
+
* SECURITY:
|
|
226
|
+
* - Password must meet strength requirements
|
|
227
|
+
* - Email is sent to verify ownership
|
|
228
|
+
* - User cannot sign in until email is verified
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```tsx
|
|
232
|
+
* const { data, error } = await authClient.signUp.email({
|
|
233
|
+
* email: 'user@example.com',
|
|
234
|
+
* password: 'securePassword123',
|
|
235
|
+
* name: 'John Doe'
|
|
236
|
+
* })
|
|
237
|
+
* // Email verification required before sign-in
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
// Note: signUp.email() is the method for email/password registration
|
|
241
|
+
// Example usage: await authClient.signUp.email({ email, password, name })
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Request password reset email.
|
|
245
|
+
*
|
|
246
|
+
* Initiates password reset flow by sending a reset link to user's email.
|
|
247
|
+
* The link contains a single-use token that expires after a set time.
|
|
248
|
+
*
|
|
249
|
+
* @param email - User's registered email address
|
|
250
|
+
* @returns Promise resolving when email is sent
|
|
251
|
+
*
|
|
252
|
+
* SECURITY:
|
|
253
|
+
* - Only reveals if email exists in timing-attack-resistant way
|
|
254
|
+
* - Reset link is single-use and time-limited
|
|
255
|
+
* - Old password remains valid until reset is completed
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```tsx
|
|
259
|
+
* const { data, error } = await authClient.forgetPassword({
|
|
260
|
+
* email: 'user@example.com'
|
|
261
|
+
* })
|
|
262
|
+
* // Email sent with reset link
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
// Note: forgetPassword() is the method for password reset (not forgotPassword)
|
|
266
|
+
// Example usage: await authClient.forgetPassword({ email })
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Complete password reset.
|
|
270
|
+
*
|
|
271
|
+
* Finalizes the password reset process using the token sent to the user's email.
|
|
272
|
+
*
|
|
273
|
+
* @param resetData - Object containing newPassword and the reset token
|
|
274
|
+
* @returns Promise resolving when password is reset
|
|
275
|
+
*
|
|
276
|
+
* SECURITY:
|
|
277
|
+
* - Token is validated against server records
|
|
278
|
+
* - Token is single-use and expires automatically
|
|
279
|
+
* - Old password is immediately invalidated
|
|
280
|
+
* - New password must meet strength requirements
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```tsx
|
|
284
|
+
* const { data, error } = await resetPassword({
|
|
285
|
+
* newPassword: 'newSecurePassword123',
|
|
286
|
+
* token: 'reset-token-from-email'
|
|
287
|
+
* })
|
|
288
|
+
* // User can now sign in with new password
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
// Note: resetPassword() completes the password reset flow
|
|
292
|
+
// Example usage: await authClient.resetPassword({ newPassword, token })
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email sending module using Resend API.
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions to send transactional emails for user authentication
|
|
5
|
+
* flows including email verification and password reset. It uses the Resend service
|
|
6
|
+
* for reliable email delivery with built-in bounce handling and analytics.
|
|
7
|
+
*
|
|
8
|
+
* Key patterns and conventions:
|
|
9
|
+
* - All email sending is async and non-blocking for the main application flow
|
|
10
|
+
* - Errors are logged but don't expose detailed error messages to users
|
|
11
|
+
* (to prevent information leakage about email infrastructure)
|
|
12
|
+
* - HTML emails use inline styles for maximum client compatibility
|
|
13
|
+
* - Responsive design with max-width 600px for optimal mobile rendering
|
|
14
|
+
* - Verification/reset links are included both as buttons and plain text URLs
|
|
15
|
+
* to handle cases where buttons are blocked by email clients
|
|
16
|
+
*
|
|
17
|
+
* Security considerations:
|
|
18
|
+
* - RESEND_API_KEY is loaded from environment and never exposed to client
|
|
19
|
+
* - Email content is HTML-escaped by template literals to prevent XSS
|
|
20
|
+
* - Verification and reset URLs should be generated server-side with
|
|
21
|
+
* short-lived tokens (24 hours for verification, 1 hour for password reset)
|
|
22
|
+
* - From address uses noreply@yourdomain.com to prevent confusion
|
|
23
|
+
* - No user data beyond what's necessary is included in emails
|
|
24
|
+
* - Rate limiting should be implemented at the route level to prevent email spam
|
|
25
|
+
*
|
|
26
|
+
* Configuration decisions:
|
|
27
|
+
* - Short token expiration times (24h for verify, 1h for reset) balance security
|
|
28
|
+
* with user experience. Password reset tokens expire faster because they grant
|
|
29
|
+
* access to accounts.
|
|
30
|
+
* - Inline CSS styles instead of external stylesheets because many email clients
|
|
31
|
+
* block external stylesheets and the <style> tag
|
|
32
|
+
* - System font stack (Arial, sans-serif) for best rendering across all clients
|
|
33
|
+
* without loading external fonts
|
|
34
|
+
* - 600px max-width is industry standard for email newsletters and ensures good
|
|
35
|
+
* readability on both desktop and mobile devices
|
|
36
|
+
* - Blue (#0070f3) is used for CTA buttons as it has high contrast and is
|
|
37
|
+
* commonly recognized as an interactive element color
|
|
38
|
+
*
|
|
39
|
+
* Performance implications:
|
|
40
|
+
* - Email sending is I/O-bound and can take 200-1000ms per email
|
|
41
|
+
* - These functions should be called after database operations complete,
|
|
42
|
+
* so email failures don't prevent user actions
|
|
43
|
+
* - Consider using a queue system for high-volume email sending to avoid blocking
|
|
44
|
+
* - Errors are caught and re-thrown with generic messages to prevent information
|
|
45
|
+
* leakage while still allowing upstream handlers to take action
|
|
46
|
+
*
|
|
47
|
+
* Error handling strategy:
|
|
48
|
+
* - Errors are logged with context for debugging
|
|
49
|
+
* - Generic error messages are thrown to prevent exposing email service details
|
|
50
|
+
* - This allows the calling code to show user-friendly messages while
|
|
51
|
+
* maintaining security
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Send verification email after user registration
|
|
55
|
+
* await sendVerificationEmail('user@example.com', 'https://app.com/verify/abc123')
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Send password reset email after request
|
|
59
|
+
* await sendPasswordResetEmail('user@example.com', 'https://app.com/reset/xyz789')
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { Resend } from 'resend'
|
|
63
|
+
import { env } from '@/lib/env'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resend API client instance.
|
|
67
|
+
*
|
|
68
|
+
* Initialized with the RESEND_API_KEY from environment variables.
|
|
69
|
+
* This instance is reused across all email sending operations to maintain
|
|
70
|
+
* a single connection and avoid multiple initializations.
|
|
71
|
+
*/
|
|
72
|
+
const resend = new Resend(env.RESEND_API_KEY)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sends a verification email to a newly registered user.
|
|
76
|
+
*
|
|
77
|
+
* This function sends an HTML email with a verification link that the user must
|
|
78
|
+
* click to confirm their email address. The link should be generated by the
|
|
79
|
+
* backend with a short-lived token (typically 24 hours).
|
|
80
|
+
*
|
|
81
|
+
* Performance characteristics:
|
|
82
|
+
* - Network latency: 200-1000ms typically
|
|
83
|
+
* - Non-blocking: Should be called after database write completes
|
|
84
|
+
* - No retry logic: Resend handles retries internally
|
|
85
|
+
*
|
|
86
|
+
* Security implications:
|
|
87
|
+
* - Verification URL should contain a cryptographically signed token
|
|
88
|
+
* - Token should expire after 24 hours to limit window for exploitation
|
|
89
|
+
* - Email content contains no sensitive data beyond the verification URL
|
|
90
|
+
* - URL is truncated in display to prevent layout issues with long URLs
|
|
91
|
+
*
|
|
92
|
+
* @param email - The recipient's email address. Should be validated before calling.
|
|
93
|
+
* @param verificationUrl - The full URL the user must click to verify their email.
|
|
94
|
+
* Should include a token with expiration.
|
|
95
|
+
* Example: https://app.com/verify/abc123token
|
|
96
|
+
* @returns Promise that resolves when email is sent successfully.
|
|
97
|
+
* @throws {Error} If email sending fails (network error, API error, etc.).
|
|
98
|
+
* Error message is generic to prevent information leakage.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* try {
|
|
102
|
+
* await sendVerificationEmail('user@example.com', 'https://app.com/verify/token123')
|
|
103
|
+
* console.log('Verification email sent')
|
|
104
|
+
* } catch (error) {
|
|
105
|
+
* console.error('Failed to send verification email')
|
|
106
|
+
* // Show user-friendly message: "Email sent. Please check your inbox."
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
export async function sendVerificationEmail(
|
|
110
|
+
email: string,
|
|
111
|
+
verificationUrl: string
|
|
112
|
+
) {
|
|
113
|
+
try {
|
|
114
|
+
await resend.emails.send({
|
|
115
|
+
from: 'noreply@yourdomain.com',
|
|
116
|
+
to: email,
|
|
117
|
+
subject: 'Verify your email address',
|
|
118
|
+
html: `
|
|
119
|
+
<!DOCTYPE html>
|
|
120
|
+
<html>
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="utf-8">
|
|
123
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
124
|
+
<title>Verify Your Email</title>
|
|
125
|
+
</head>
|
|
126
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
127
|
+
<div style="background: #f4f4f4; padding: 30px; border-radius: 8px;">
|
|
128
|
+
<h1 style="color: #333; margin-bottom: 20px;">Welcome!</h1>
|
|
129
|
+
<p style="margin-bottom: 20px;">Thank you for signing up. Please click the button below to verify your email address:</p>
|
|
130
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
131
|
+
<a href="${verificationUrl}" style="display: inline-block; padding: 12px 30px; background: #0070f3; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold;">Verify Email</a>
|
|
132
|
+
</div>
|
|
133
|
+
<p style="color: #666; font-size: 14px;">If the button doesn't work, you can copy and paste this link into your browser:</p>
|
|
134
|
+
<p style="color: #666; font-size: 12px; word-break: break-all;">${verificationUrl}</p>
|
|
135
|
+
<p style="margin-top: 30px; color: #999; font-size: 12px;">This link will expire in 24 hours.</p>
|
|
136
|
+
</div>
|
|
137
|
+
</body>
|
|
138
|
+
</html>
|
|
139
|
+
`,
|
|
140
|
+
})
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('Failed to send verification email:', error)
|
|
143
|
+
throw new Error('Failed to send verification email')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Sends a password reset email to a user who requested a password reset.
|
|
149
|
+
*
|
|
150
|
+
* This function sends an HTML email with a password reset link. The link should be
|
|
151
|
+
* generated by the backend with a short-lived token (typically 1 hour) to limit
|
|
152
|
+
* the window for potential account takeover attempts.
|
|
153
|
+
*
|
|
154
|
+
* Performance characteristics:
|
|
155
|
+
* - Network latency: 200-1000ms typically
|
|
156
|
+
* - Non-blocking: Should be called after database write completes
|
|
157
|
+
* - No retry logic: Resend handles retries internally
|
|
158
|
+
*
|
|
159
|
+
* Security implications:
|
|
160
|
+
* - Reset URL should contain a cryptographically signed token
|
|
161
|
+
* - Token should expire after 1 hour to limit account takeover window
|
|
162
|
+
* - Email confirms that a reset was requested (prevents silent resets)
|
|
163
|
+
* - If the user didn't request a reset, this serves as an early warning
|
|
164
|
+
* of potential unauthorized access attempts
|
|
165
|
+
* - No account details are revealed to prevent email enumeration attacks
|
|
166
|
+
* (email is sent even if the account doesn't exist in production)
|
|
167
|
+
*
|
|
168
|
+
* @param email - The recipient's email address. May not exist in the system.
|
|
169
|
+
* In production, send even for non-existent accounts to prevent
|
|
170
|
+
* email enumeration attacks.
|
|
171
|
+
* @param resetUrl - The full URL the user must click to reset their password.
|
|
172
|
+
* Should include a token with short expiration (1 hour recommended).
|
|
173
|
+
* Example: https://app.com/reset/xyz789token
|
|
174
|
+
* @returns Promise that resolves when email is sent successfully.
|
|
175
|
+
* @throws {Error} If email sending fails (network error, API error, etc.).
|
|
176
|
+
* Error message is generic to prevent information leakage.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* try {
|
|
180
|
+
* await sendPasswordResetEmail('user@example.com', 'https://app.com/reset/token456')
|
|
181
|
+
* console.log('Password reset email sent')
|
|
182
|
+
* } catch (error) {
|
|
183
|
+
* console.error('Failed to send password reset email')
|
|
184
|
+
* // Show user-friendly message: "If an account exists with this email,
|
|
185
|
+
* // you will receive a password reset link."
|
|
186
|
+
* }
|
|
187
|
+
*/
|
|
188
|
+
export async function sendPasswordResetEmail(email: string, resetUrl: string) {
|
|
189
|
+
try {
|
|
190
|
+
await resend.emails.send({
|
|
191
|
+
from: 'noreply@yourdomain.com',
|
|
192
|
+
to: email,
|
|
193
|
+
subject: 'Reset Your Password',
|
|
194
|
+
html: `
|
|
195
|
+
<!DOCTYPE html>
|
|
196
|
+
<html>
|
|
197
|
+
<head>
|
|
198
|
+
<meta charset="utf-8">
|
|
199
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
200
|
+
<title>Reset Your Password</title>
|
|
201
|
+
</head>
|
|
202
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
203
|
+
<div style="background: #f4f4f4; padding: 30px; border-radius: 8px;">
|
|
204
|
+
<h1 style="color: #333; margin-bottom: 20px;">Reset Password</h1>
|
|
205
|
+
<p style="margin-bottom: 20px;">We received a request to reset your password. Click the button below to set a new password:</p>
|
|
206
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
207
|
+
<a href="${resetUrl}" style="display: inline-block; padding: 12px 30px; background: #0070f3; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold;">Reset Password</a>
|
|
208
|
+
</div>
|
|
209
|
+
<p style="color: #666; font-size: 14px;">If the button doesn't work, you can copy and paste this link into your browser:</p>
|
|
210
|
+
<p style="color: #666; font-size: 12px; word-break: break-all;">${resetUrl}</p>
|
|
211
|
+
<p style="margin-top: 30px; color: #999; font-size: 12px;">This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.</p>
|
|
212
|
+
</div>
|
|
213
|
+
</body>
|
|
214
|
+
</html>
|
|
215
|
+
`,
|
|
216
|
+
})
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error('Failed to send password reset email:', error)
|
|
219
|
+
throw new Error('Failed to send password reset email')
|
|
220
|
+
}
|
|
221
|
+
}
|