Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 93 additions & 14 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,22 +301,101 @@ Before deploying to production, ensure:

### Email Verification

Enable in `src/lib/server/auth.ts`:
Email verification and password reset are configured in `src/lib/server/auth.ts` using Resend as the email provider.

```typescript
export function createAuth(db: D1Database, secret: string, url: string) {
return betterAuth({
// ... other config
emailAndPassword: {
enabled: true,
requireEmailVerification: true // Enable verification for production
}
// ... rest of config
});
}
```
#### Setup

1. **Get a Resend API Key**
- Sign up at [resend.com](https://resend.com)
- Create an API key in your dashboard
- Verify your domain (or use their test domain for development)

2. **Configure the Secret in Cloudflare**

For **production**:

```bash
wrangler secret put RESEND_API_KEY --env production
```

For **preview/staging**:

```bash
wrangler secret put RESEND_API_KEY --env preview
```

For **local development**:
Add to `.dev.vars` (create if doesn't exist):

```
RESEND_API_KEY=re_your_api_key_here
```

3. **Email Configuration**

The email functions are configured in `src/lib/server/auth.ts`:

```typescript
emailAndPassword: {
enabled: true,
requireEmailVerification: !!resendApiKey, // Auto-enabled when Resend is configured

sendVerificationEmail: async ({ user, url }) => {
const resend = new Resend(resendApiKey);
await resend.emails.send({
from: 'UHabit <noreply@uhabit.xyz>',
to: user.email,
subject: 'Verify your email',
html: `...`
});
},

sendResetPassword: async ({ user, url }) => {
const resend = new Resend(resendApiKey);
await resend.emails.send({
from: 'UHabit <noreply@uhabit.xyz>',
to: user.email,
subject: 'Reset your password',
html: `...`
});
}
}
```

#### Email Verification Flow

1. User signs up
2. Better Auth creates unverified account
3. Verification email sent via Resend
4. User clicks link in email → `/api/auth/verify-email?token=...`
5. Better Auth verifies token and marks email as verified
6. User redirected to login or dashboard

#### Password Reset Flow

1. User clicks "Forgot Password"
2. Frontend calls `authClient.forgetPassword({ email })`
3. Better Auth generates reset token
4. Reset email sent via Resend
5. User clicks link → `/api/auth/reset-password?token=...`
6. User enters new password
7. Better Auth validates token and updates password

#### Testing Locally

Without a Resend API key:

- Email verification is disabled
- Users can sign up and log in immediately
- Password reset won't work

With Resend (development mode):

- Use Resend's test domain or verify your own domain
- Emails will be sent normally
- Check Resend dashboard for sent emails

**Note**: Email verification is automatically disabled in dev mode. To test email verification, you'll need to configure an email provider and disable dev mode.
**Note**: Better Auth handles all the routing and logic automatically. You don't need to create custom pages unless you want to customize the UI.

### Custom User Fields

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"better-auth": "^1.4.3",
"lucide-svelte": "^0.556.0",
"postcss": "^8.5.6",
"resend": "^6.5.2",
"zod": "^4.1.13"
}
}
2 changes: 2 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ declare global {
BETTER_AUTH_URL?: string; // Optional: auto-detects from request if not set
DEV_MODE?: string; // Optional: set to "true" for dev mode
RATE_LIMIT?: KVNamespace; // Optional: KV namespace for rate limiting
QUOTES_CACHE?: KVNamespace; // Optional: KV namespace for quotes caching
RESEND_API_KEY?: string; // Optional: Resend API key for sending emails
};
context: {
waitUntil(promise: Promise<unknown>): void;
Expand Down
3 changes: 2 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const secret = event.platform?.env?.BETTER_AUTH_SECRET;
const url = event.platform?.env?.BETTER_AUTH_URL || event.url.origin;
const devModeEnv = event.platform?.env?.DEV_MODE === 'true';
const resendApiKey = event.platform?.env?.RESEND_API_KEY;

// Detect staging/preview environments
const isStagingOrPreview =
Expand Down Expand Up @@ -54,7 +55,7 @@ export const handle: Handle = async ({ event, resolve }) => {

// Create auth instance and store in locals
// The route handler at /api/auth/[...all] will use this
const auth = createAuth(db, secret, url, devMode);
const auth = createAuth(db, secret, url, devMode, resendApiKey);
event.locals.auth = auth;

// For non-auth routes, fetch session to populate locals.user
Expand Down
15 changes: 15 additions & 0 deletions src/lib/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ export async function signOut() {
return result.data;
}

export async function forgetPassword(email: string) {
// Better Auth uses forgetPassword method from the email/password plugin
// @ts-ignore - Type definitions may be incomplete for this method
const result = await authClient.forgetPassword({
email,
redirectTo: '/reset-password'
});

if (result.error) {
throw new Error(result.error.message || 'Failed to send reset email');
}

return result.data;
}

export async function getSession() {
const result = await authClient.getSession();
return result.data;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export type HabitType = 'progressive' | 'single';

export const routes = {
login: '/login',
register: '/register',
forgotPassword: '/forgot-password',
resetPassword: '/reset-password',
overview: '/overview',

habits: {
Expand Down
44 changes: 36 additions & 8 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { haveIBeenPwned } from 'better-auth/plugins';
import { APIError } from 'better-auth/api';
import { Resend } from 'resend';
import { getDB } from './db';
import * as schema from './db/schema';
import { getVerificationEmailTemplate, getPasswordResetEmailTemplate } from './email-templates';

export function createAuth(db: D1Database, secret: string, url: string, devMode = false) {
export function createAuth(
db: D1Database,
secret: string,
url: string,
devMode = false,
resendApiKey?: string
) {
// Detect if URL is a staging/preview environment (dev mode allowed)
// Preview: preview-123.uhabit.pages.dev
// Staging: staging.uhabit.pages.dev
Expand Down Expand Up @@ -63,18 +71,16 @@ export function createAuth(db: D1Database, secret: string, url: string, devMode
],
emailAndPassword: {
enabled: true,
// In dev mode, skip email verification for easier testing
requireEmailVerification: false,
// In production enable this:
// requireEmailVerification: !isDev
// Enable email verification only in production
requireEmailVerification: !!resendApiKey && !isDev,

// Password requirements (enforced by Better Auth)
minPasswordLength: isDev ? 4 : 8,
minPasswordLength: isDev ? 1 : 8,
// Note: Additional password validation happens client-side
// via PasswordStrengthIndicator component

// Lower bcrypt cost for Cloudflare Workers (10ms CPU limit)
// Default is 10, but that exceeds Workers CPU limits
// Default is 10, that exceeds Workers CPU limits
password: {
hash: async (password: string) => {
const bcrypt = await import('bcryptjs');
Expand All @@ -84,7 +90,29 @@ export function createAuth(db: D1Database, secret: string, url: string, devMode
const bcrypt = await import('bcryptjs');
return bcrypt.compare(data.password, data.hash);
}
}
},

// Send verification and password reset emails
...(resendApiKey && {
sendVerificationEmail: async ({ user, url }: { user: any; url: string }) => {
const resend = new Resend(resendApiKey);
await resend.emails.send({
from: 'UHabit <noreply@uhabit.xyz>',
to: user.email,
subject: 'Verify your email - UHabit',
html: getVerificationEmailTemplate(url, user.name)
});
},
sendResetPassword: async ({ user, url }: { user: any; url: string }) => {
const resend = new Resend(resendApiKey);
await resend.emails.send({
from: 'UHabit <noreply@uhabit.xyz>',
to: user.email,
subject: 'Reset your password - UHabit',
html: getPasswordResetEmailTemplate(url, user.name)
});
}
})
},
session: {
// Extended session in dev mode for convenience
Expand Down
37 changes: 37 additions & 0 deletions src/lib/server/email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readFileSync } from 'fs';
import { join } from 'path';

/**
* Load and populate an email template
*/
function loadTemplate(templateName: string, variables: Record<string, string>): string {
const templatePath = join(process.cwd(), 'src/lib/server/email-templates', templateName);
let html = readFileSync(templatePath, 'utf-8');

// Replace all variables in the template
for (const [key, value] of Object.entries(variables)) {
html = html.replace(new RegExp(`{{${key}}}`, 'g'), value);
}

return html;
}

/**
* Get verification email HTML
*/
export function getVerificationEmailTemplate(verifyUrl: string, userName?: string): string {
return loadTemplate('verify-email.html', {
VERIFY_URL: verifyUrl,
USER_NAME: userName || 'there'
});
}

/**
* Get password reset email HTML
*/
export function getPasswordResetEmailTemplate(resetUrl: string, userName?: string): string {
return loadTemplate('reset-password.html', {
RESET_URL: resetUrl,
USER_NAME: userName || 'there'
});
}
Loading