From 3350267fa905464096cffad9a3db821d1f26b315 Mon Sep 17 00:00:00 2001 From: Marco de Jongh <1107647+marcodejongh@users.noreply.github.com> Date: Fri, 2 Jan 2026 12:08:12 +1100 Subject: [PATCH] =?UTF-8?q?Revert=20"Revert=20"feat:=20Add=20OAuth=20provi?= =?UTF-8?q?ders=20(Google,=20Apple,=20Facebook)=20and=20email=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 422e21bdf1308af0261aed3ca2716f76eafc4cda. --- docs/oauth-setup.md | 199 ++++++++++++++++++ package-lock.json | 74 +++++++ .../app/api/auth/providers-config/route.ts | 13 ++ packages/web/app/api/auth/register/route.ts | 51 ++++- .../app/api/auth/resend-verification/route.ts | 112 ++++++++++ .../web/app/api/auth/verify-email/route.ts | 99 +++++++++ .../web/app/auth/error/auth-error-content.tsx | 86 ++++++++ packages/web/app/auth/error/page.tsx | 16 ++ .../web/app/auth/login/auth-page-content.tsx | 55 ++--- packages/web/app/auth/verify-request/page.tsx | 16 ++ .../verify-request/verify-request-content.tsx | 139 ++++++++++++ .../web/app/components/auth/auth-modal.tsx | 45 ++-- .../components/auth/social-login-buttons.tsx | 147 +++++++++++++ .../lib/auth/__tests__/rate-limiter.test.ts | 153 ++++++++++++++ packages/web/app/lib/auth/auth-options.ts | 101 +++++++-- packages/web/app/lib/auth/rate-limiter.ts | 98 +++++++++ packages/web/app/lib/email/email-service.ts | 98 +++++++++ packages/web/package.json | 2 + 18 files changed, 1424 insertions(+), 80 deletions(-) create mode 100644 docs/oauth-setup.md create mode 100644 packages/web/app/api/auth/providers-config/route.ts create mode 100644 packages/web/app/api/auth/resend-verification/route.ts create mode 100644 packages/web/app/api/auth/verify-email/route.ts create mode 100644 packages/web/app/auth/error/auth-error-content.tsx create mode 100644 packages/web/app/auth/error/page.tsx create mode 100644 packages/web/app/auth/verify-request/page.tsx create mode 100644 packages/web/app/auth/verify-request/verify-request-content.tsx create mode 100644 packages/web/app/components/auth/social-login-buttons.tsx create mode 100644 packages/web/app/lib/auth/__tests__/rate-limiter.test.ts create mode 100644 packages/web/app/lib/auth/rate-limiter.ts create mode 100644 packages/web/app/lib/email/email-service.ts diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md new file mode 100644 index 00000000..8ccf9b8a --- /dev/null +++ b/docs/oauth-setup.md @@ -0,0 +1,199 @@ +# OAuth and Email Verification Setup + +This document covers setting up OAuth providers (Google, Apple, Facebook) and email verification for the Boardsesh application. + +## Environment Variables + +Add the following to `packages/web/.env.development.local` (for local development) or your production environment: + +```bash +# Google OAuth +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret + +# Apple Sign-In +APPLE_ID=your_apple_service_id +APPLE_SECRET=your_apple_jwt_secret + +# Facebook OAuth +FACEBOOK_CLIENT_ID=your_facebook_app_id +FACEBOOK_CLIENT_SECRET=your_facebook_app_secret + +# Email (Fastmail SMTP) +SMTP_HOST=smtp.fastmail.com +SMTP_PORT=465 +SMTP_USER=your_fastmail_email@fastmail.com +SMTP_PASSWORD=your_fastmail_app_password +EMAIL_FROM=your_fastmail_email@fastmail.com +``` + +--- + +## Provider Setup Instructions + +### 1. Google OAuth + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Navigate to **APIs & Services** → **Credentials** +4. Click **Create Credentials** → **OAuth client ID** +5. Select **Web application** as the application type +6. Add authorized JavaScript origins: + - `http://localhost:3000` (development) + - `https://your-domain.com` (production) +7. Add authorized redirect URIs: + - `http://localhost:3000/api/auth/callback/google` + - `https://your-domain.com/api/auth/callback/google` +8. Copy the **Client ID** and **Client Secret** to your environment variables + +### 2. Apple Sign-In + +Apple Sign-In is more complex and requires a paid Apple Developer account. + +1. Go to [Apple Developer Portal](https://developer.apple.com/) +2. Navigate to **Certificates, Identifiers & Profiles** + +**Create a Services ID:** +1. Go to **Identifiers** → Click **+** +2. Select **Services IDs** → Continue +3. Enter a description and identifier (e.g., `com.boardsesh.signin`) +4. Enable **Sign In with Apple** +5. Configure: + - Primary App ID: Select your app + - Domains: `your-domain.com` (and `localhost` for dev via ngrok) + - Return URLs: `https://your-domain.com/api/auth/callback/apple` + +**Create a Key:** +1. Go to **Keys** → Click **+** +2. Enter a name for the key +3. Enable **Sign In with Apple** +4. Configure the key and associate it with your Primary App ID +5. Download the `.p8` key file and save it securely + +**Generate the Apple Secret (JWT):** + +Apple requires a JWT secret that must be regenerated every 6 months. Use the following Node.js script: + +```javascript +const jwt = require('jsonwebtoken'); +const fs = require('fs'); + +const privateKey = fs.readFileSync('path/to/AuthKey_XXXXXXXX.p8'); + +const token = jwt.sign({}, privateKey, { + algorithm: 'ES256', + expiresIn: '180d', // 6 months + audience: 'https://appleid.apple.com', + issuer: 'YOUR_TEAM_ID', // Found in Apple Developer account + subject: 'YOUR_SERVICE_ID', // The Services ID you created + keyid: 'YOUR_KEY_ID', // The Key ID from the key you created +}); + +console.log(token); +``` + +**Important Notes:** +- Apple Sign-In **requires HTTPS** - it won't work on `http://localhost` +- For local development, use [ngrok](https://ngrok.com/) to create an HTTPS tunnel: + ```bash + ngrok http 3000 + ``` +- Add the ngrok URL to your Apple Services ID configuration + +### 3. Facebook OAuth + +1. Go to [Facebook Developers](https://developers.facebook.com/) +2. Click **My Apps** → **Create App** +3. Select **Consumer** as the app type +4. Fill in the app details and create the app +5. In the app dashboard, click **Add Product** → Find **Facebook Login** → **Set Up** +6. Go to **Settings** → **Basic** to find your **App ID** and **App Secret** +7. Go to **Facebook Login** → **Settings** +8. Add Valid OAuth Redirect URIs: + - `http://localhost:3000/api/auth/callback/facebook` + - `https://your-domain.com/api/auth/callback/facebook` +9. Make sure your app is in **Live** mode for production use + +--- + +## Email Verification (Fastmail SMTP) + +### Fastmail Setup + +1. Log in to [Fastmail](https://www.fastmail.com/) +2. Go to **Settings** → **Password & Security** → **Third-party apps** +3. Click **New app password** +4. Give it a name (e.g., "Boardsesh Email") +5. Copy the generated password to your `SMTP_PASSWORD` environment variable +6. Use your full Fastmail email address for `SMTP_USER` and `EMAIL_FROM` + +### SMTP Settings + +| Setting | Value | +|---------|-------| +| Host | `smtp.fastmail.com` | +| Port | `465` (SSL) or `587` (STARTTLS) | +| Security | SSL/TLS | +| Username | Your full email address | +| Password | App-specific password | + +--- + +## Testing the Setup + +### 1. Test Email Verification + +1. Start the development server: `npm run dev` +2. Go to `http://localhost:3000/auth/login` +3. Create a new account with email/password +4. Check your inbox for the verification email +5. Click the verification link +6. You should be redirected to the login page with a success message + +### 2. Test OAuth Providers + +**Google:** +1. Click "Continue with Google" +2. Complete the Google sign-in flow +3. You should be redirected back and logged in + +**Apple:** +1. Ensure you're using HTTPS (via ngrok for local dev) +2. Click "Continue with Apple" +3. Complete the Apple sign-in flow + +**Facebook:** +1. Click "Continue with Facebook" +2. Complete the Facebook sign-in flow +3. You should be redirected back and logged in + +--- + +## Troubleshooting + +### "redirect_uri_mismatch" Error +- Ensure the redirect URI in your OAuth provider console exactly matches the callback URL +- Check for trailing slashes and protocol (http vs https) + +### Apple Sign-In Not Working +- Apple requires HTTPS - use ngrok for local development +- Ensure your Apple secret JWT is not expired (6-month lifetime) +- Verify the return URL is added to your Services ID configuration + +### Email Not Sending +- Check SMTP credentials are correct +- Verify the app password is active in Fastmail +- Check the server logs for SMTP errors + +### "OAuthAccountNotLinked" Error +- User tried to sign in with OAuth but email already exists with password auth +- They need to sign in with their original method (email/password) + +--- + +## Security Considerations + +1. **Never commit secrets** - Use `.env.development.local` (gitignored) for sensitive values +2. **Rotate Apple secret** - The JWT expires every 6 months; set a reminder +3. **Use strong NEXTAUTH_SECRET** - Generate with `openssl rand -base64 32` +4. **Enable rate limiting** - Consider adding rate limiting to auth endpoints in production diff --git a/package-lock.json b/package-lock.json index e2f2b9a1..93419d45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -801,6 +801,58 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.958.0.tgz", + "integrity": "sha512-3x3n8IIxIMAkdpt9wy9zS7MO2lqTcJwQTdHMn6BlD7YUohb+r5Q4KCOEQ2uHWd4WIJv2tlbXnfypHaXReO/WXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-node": "3.958.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/signature-v4-multi-region": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sso": { "version": "3.958.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", @@ -5930,6 +5982,17 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz", + "integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -8129,6 +8192,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/oauth": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", @@ -10948,6 +11020,7 @@ "iron-session": "^8.0.4", "next": "^16.1.1", "next-auth": "^4.24.13", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "react": "^19.2.3", "react-chartjs-2": "^5.3.1", @@ -10968,6 +11041,7 @@ "@testing-library/react": "^16.3.1", "@types/d3-scale": "^4.0.9", "@types/node": "^25", + "@types/nodemailer": "^7.0.4", "@types/react": "^19", "@types/react-dom": "^19", "@types/uuid": "^11.0.0", diff --git a/packages/web/app/api/auth/providers-config/route.ts b/packages/web/app/api/auth/providers-config/route.ts new file mode 100644 index 00000000..c91f3d9b --- /dev/null +++ b/packages/web/app/api/auth/providers-config/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +/** + * Returns which OAuth providers are configured. + * This allows the client to show/hide social login buttons appropriately. + */ +export async function GET() { + return NextResponse.json({ + google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET), + apple: !!(process.env.APPLE_ID && process.env.APPLE_SECRET), + facebook: !!(process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET), + }); +} diff --git a/packages/web/app/api/auth/register/route.ts b/packages/web/app/api/auth/register/route.ts index 6164328e..473f1960 100644 --- a/packages/web/app/api/auth/register/route.ts +++ b/packages/web/app/api/auth/register/route.ts @@ -4,6 +4,8 @@ import * as schema from "@/app/lib/db/schema"; import bcrypt from "bcryptjs"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { sendVerificationEmail } from "@/app/lib/email/email-service"; +import { checkRateLimit, getClientIp } from "@/app/lib/auth/rate-limiter"; const registerSchema = z.object({ email: z.string().email("Invalid email address"), @@ -16,6 +18,22 @@ const registerSchema = z.object({ export async function POST(request: NextRequest) { try { + // Rate limiting - 10 requests per minute per IP for registration + const clientIp = getClientIp(request); + const rateLimitResult = checkRateLimit(`register:${clientIp}`, 10, 60_000); + + if (rateLimitResult.limited) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { + status: 429, + headers: { + "Retry-After": String(rateLimitResult.retryAfterSeconds), + }, + } + ); + } + const body = await request.json(); // Validate input @@ -70,11 +88,12 @@ export async function POST(request: NextRequest) { const userId = crypto.randomUUID(); const passwordHash = await bcrypt.hash(password, 12); - // Insert user + // Insert user (emailVerified is null for unverified accounts) await db.insert(schema.users).values({ id: userId, email, name: name || email.split("@")[0], + emailVerified: null, }); // Insert credentials @@ -88,8 +107,36 @@ export async function POST(request: NextRequest) { userId, }); + // Generate verification token + const token = crypto.randomUUID(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + await db.insert(schema.verificationTokens).values({ + identifier: email, + token, + expires, + }); + + // Send verification email (don't fail registration if email fails) + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + let emailSent = false; + try { + await sendVerificationEmail(email, token, baseUrl); + emailSent = true; + } catch (emailError) { + console.error("Failed to send verification email:", emailError); + // User is created, they can use resend functionality + } + return NextResponse.json( - { message: "Account created successfully", userId }, + { + message: emailSent + ? "Account created. Please check your email to verify your account." + : "Account created. Please request a new verification email.", + requiresVerification: true, + emailSent, + userId + }, { status: 201 } ); } catch (error) { diff --git a/packages/web/app/api/auth/resend-verification/route.ts b/packages/web/app/api/auth/resend-verification/route.ts new file mode 100644 index 00000000..2cc6b519 --- /dev/null +++ b/packages/web/app/api/auth/resend-verification/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/app/lib/db/db"; +import * as schema from "@/app/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { sendVerificationEmail } from "@/app/lib/email/email-service"; +import { checkRateLimit, getClientIp } from "@/app/lib/auth/rate-limiter"; + +// Zod schema for email validation +const resendVerificationSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + +// Minimum response time to prevent timing attacks +// Set high enough to cover typical email sending time (1-3 seconds) +const MIN_RESPONSE_TIME_MS = 2500; + +// Helper to introduce consistent delay to prevent timing attacks +async function consistentDelay(startTime: number): Promise { + const elapsed = Date.now() - startTime; + const remaining = MIN_RESPONSE_TIME_MS - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } +} + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const genericMessage = "If an account exists and needs verification, a verification email will be sent"; + + try { + // Rate limiting - 5 requests per minute per IP + const clientIp = getClientIp(request); + const rateLimitResult = checkRateLimit(`resend-verification:${clientIp}`, 5, 60_000); + + if (rateLimitResult.limited) { + await consistentDelay(startTime); + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { + status: 429, + headers: { + "Retry-After": String(rateLimitResult.retryAfterSeconds), + }, + } + ); + } + + const body = await request.json(); + + // Validate input with Zod + const validationResult = resendVerificationSchema.safeParse(body); + if (!validationResult.success) { + await consistentDelay(startTime); + return NextResponse.json( + { error: validationResult.error.issues[0].message }, + { status: 400 } + ); + } + + const { email } = validationResult.data; + const db = getDb(); + + // Check if user exists and is unverified + const user = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + + // Don't reveal user status - return same message for all cases + // Use consistent delay for all paths to prevent timing attacks + if (user.length === 0 || user[0].emailVerified) { + await consistentDelay(startTime); + return NextResponse.json( + { message: genericMessage }, + { status: 200 } + ); + } + + // Delete any existing tokens for this email + await db + .delete(schema.verificationTokens) + .where(eq(schema.verificationTokens.identifier, email)); + + // Generate new token + const token = crypto.randomUUID(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + await db.insert(schema.verificationTokens).values({ + identifier: email, + token, + expires, + }); + + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + await sendVerificationEmail(email, token, baseUrl); + + await consistentDelay(startTime); + return NextResponse.json( + { message: genericMessage }, + { status: 200 } + ); + } catch (error) { + console.error("Resend verification error:", error); + await consistentDelay(startTime); + return NextResponse.json( + { error: "Failed to send verification email" }, + { status: 500 } + ); + } +} diff --git a/packages/web/app/api/auth/verify-email/route.ts b/packages/web/app/api/auth/verify-email/route.ts new file mode 100644 index 00000000..31eb79d6 --- /dev/null +++ b/packages/web/app/api/auth/verify-email/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/app/lib/db/db"; +import * as schema from "@/app/lib/db/schema"; +import { eq, and } from "drizzle-orm"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const token = searchParams.get("token"); + const email = searchParams.get("email"); + + if (!token || !email) { + return NextResponse.redirect( + new URL("/auth/verify-request?error=InvalidToken", request.url) + ); + } + + const db = getDb(); + + // Find the verification token + const verificationToken = await db + .select() + .from(schema.verificationTokens) + .where( + and( + eq(schema.verificationTokens.identifier, email), + eq(schema.verificationTokens.token, token) + ) + ) + .limit(1); + + if (verificationToken.length === 0) { + return NextResponse.redirect( + new URL("/auth/verify-request?error=InvalidToken", request.url) + ); + } + + const tokenData = verificationToken[0]; + + // Check if token has expired + if (new Date() > tokenData.expires) { + // Delete expired token + await db + .delete(schema.verificationTokens) + .where( + and( + eq(schema.verificationTokens.identifier, email), + eq(schema.verificationTokens.token, token) + ) + ); + + return NextResponse.redirect( + new URL("/auth/verify-request?error=TokenExpired", request.url) + ); + } + + // Verify user exists before updating + const user = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + + if (user.length === 0) { + // Token exists but user doesn't - cleanup the orphan token + await db + .delete(schema.verificationTokens) + .where( + and( + eq(schema.verificationTokens.identifier, email), + eq(schema.verificationTokens.token, token) + ) + ); + + return NextResponse.redirect( + new URL("/auth/verify-request?error=InvalidToken", request.url) + ); + } + + // Update user emailVerified + await db + .update(schema.users) + .set({ emailVerified: new Date() }) + .where(eq(schema.users.email, email)); + + // Delete the used token + await db + .delete(schema.verificationTokens) + .where( + and( + eq(schema.verificationTokens.identifier, email), + eq(schema.verificationTokens.token, token) + ) + ); + + // Redirect to login with success message + return NextResponse.redirect( + new URL("/auth/login?verified=true", request.url) + ); +} diff --git a/packages/web/app/auth/error/auth-error-content.tsx b/packages/web/app/auth/error/auth-error-content.tsx new file mode 100644 index 00000000..22551ee3 --- /dev/null +++ b/packages/web/app/auth/error/auth-error-content.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import { Layout, Card, Typography, Button, Space, Alert } from 'antd'; +import { CloseCircleOutlined } from '@ant-design/icons'; +import { useSearchParams } from 'next/navigation'; +import Logo from '@/app/components/brand/logo'; +import BackButton from '@/app/components/back-button'; +import { themeTokens } from '@/app/theme/theme-config'; + +const { Content, Header } = Layout; +const { Title } = Typography; + +export default function AuthErrorContent() { + const searchParams = useSearchParams(); + const error = searchParams.get('error'); + + const getErrorMessage = () => { + switch (error) { + case 'Configuration': + return 'There is a problem with the server configuration.'; + case 'AccessDenied': + return 'Access denied. You do not have permission to sign in.'; + case 'Verification': + return 'The verification link has expired or is invalid.'; + case 'OAuthSignin': + return 'Error starting the sign-in flow. Please try again.'; + case 'OAuthCallback': + return 'Error completing the sign-in. Please try again.'; + case 'OAuthCreateAccount': + return 'Could not create an account with this provider.'; + case 'EmailCreateAccount': + return 'Could not create an email account.'; + case 'Callback': + return 'Error in the authentication callback.'; + case 'OAuthAccountNotLinked': + return 'This email is already associated with another account. Please sign in using your original method.'; + case 'SessionRequired': + return 'You must be signed in to access this page.'; + default: + return 'An unexpected authentication error occurred.'; + } + }; + + return ( + +
+ + + + Authentication Error + +
+ + + + + + Authentication Error + + + + + +
+ ); +} diff --git a/packages/web/app/auth/error/page.tsx b/packages/web/app/auth/error/page.tsx new file mode 100644 index 00000000..e91cfc42 --- /dev/null +++ b/packages/web/app/auth/error/page.tsx @@ -0,0 +1,16 @@ +import React, { Suspense } from 'react'; +import { Metadata } from 'next'; +import AuthErrorContent from './auth-error-content'; + +export const metadata: Metadata = { + title: 'Authentication Error | Boardsesh', + description: 'An error occurred during authentication', +}; + +export default function AuthErrorPage() { + return ( + + + + ); +} diff --git a/packages/web/app/auth/login/auth-page-content.tsx b/packages/web/app/auth/login/auth-page-content.tsx index 373a8b3a..350f6f4c 100644 --- a/packages/web/app/auth/login/auth-page-content.tsx +++ b/packages/web/app/auth/login/auth-page-content.tsx @@ -7,6 +7,7 @@ import { signIn, useSession } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Logo from '@/app/components/brand/logo'; import BackButton from '@/app/components/back-button'; +import SocialLoginButtons from '@/app/components/auth/social-login-buttons'; const { Content, Header } = Layout; const { Title, Text } = Typography; @@ -24,6 +25,8 @@ export default function AuthPageContent() { const [registerLoading, setRegisterLoading] = useState(false); const [activeTab, setActiveTab] = useState('login'); + const verified = searchParams.get('verified'); + // Show error message from NextAuth useEffect(() => { if (error) { @@ -35,6 +38,13 @@ export default function AuthPageContent() { } }, [error]); + // Show success message when email is verified + useEffect(() => { + if (verified === 'true') { + message.success('Email verified! You can now log in.'); + } + }, [verified]); + // Redirect if already authenticated useEffect(() => { if (status === 'authenticated') { @@ -91,9 +101,17 @@ export default function AuthPageContent() { return; } - message.success('Account created! Logging you in...'); + // Check if email verification is required + if (data.requiresVerification) { + message.info('Please check your email to verify your account'); + setActiveTab('login'); + loginForm.setFieldValue('email', values.email); + return; + } + + // Fallback for accounts that don't require verification (e.g., adding password to OAuth account) + message.success('Account updated! Logging you in...'); - // Auto-login after registration const loginResult = await signIn('credentials', { email: values.email, password: values.password, @@ -103,10 +121,9 @@ export default function AuthPageContent() { if (loginResult?.ok) { router.push(callbackUrl); } else { - // If auto-login fails, switch to login tab setActiveTab('login'); loginForm.setFieldValue('email', values.email); - message.info('Please log in with your new account'); + message.info('Please log in with your account'); } } catch (error) { console.error('Registration error:', error); @@ -261,35 +278,7 @@ export default function AuthPageContent() { or - + diff --git a/packages/web/app/auth/verify-request/page.tsx b/packages/web/app/auth/verify-request/page.tsx new file mode 100644 index 00000000..d95338fd --- /dev/null +++ b/packages/web/app/auth/verify-request/page.tsx @@ -0,0 +1,16 @@ +import React, { Suspense } from 'react'; +import { Metadata } from 'next'; +import VerifyRequestContent from './verify-request-content'; + +export const metadata: Metadata = { + title: 'Verify Email | Boardsesh', + description: 'Verify your email address', +}; + +export default function VerifyRequestPage() { + return ( + + + + ); +} diff --git a/packages/web/app/auth/verify-request/verify-request-content.tsx b/packages/web/app/auth/verify-request/verify-request-content.tsx new file mode 100644 index 00000000..2dfccc29 --- /dev/null +++ b/packages/web/app/auth/verify-request/verify-request-content.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React, { useState } from 'react'; +import { Layout, Card, Typography, Button, Space, Alert, Input, Form, message } from 'antd'; +import { MailOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { useSearchParams } from 'next/navigation'; +import Logo from '@/app/components/brand/logo'; +import BackButton from '@/app/components/back-button'; +import { themeTokens } from '@/app/theme/theme-config'; + +const { Content, Header } = Layout; +const { Title, Text, Paragraph } = Typography; + +export default function VerifyRequestContent() { + const searchParams = useSearchParams(); + const error = searchParams.get('error'); + const [resendLoading, setResendLoading] = useState(false); + const [form] = Form.useForm(); + + const getErrorMessage = () => { + switch (error) { + case 'EmailNotVerified': + return 'Please verify your email before signing in.'; + case 'InvalidToken': + return 'The verification link is invalid. Please request a new one.'; + case 'TokenExpired': + return 'The verification link has expired. Please request a new one.'; + default: + return null; + } + }; + + const handleResend = async () => { + try { + const values = await form.validateFields(); + setResendLoading(true); + + const response = await fetch('/api/auth/resend-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: values.email }), + }); + + const data = await response.json(); + + if (response.ok) { + message.success('Verification email sent! Check your inbox.'); + } else { + message.error(data.error || 'Failed to send verification email'); + } + } catch (err) { + console.error('Resend error:', err); + } finally { + setResendLoading(false); + } + }; + + const errorMessage = getErrorMessage(); + + return ( + +
+ + + + Email Verification + +
+ + + + + {errorMessage ? ( + <> + + + + ) : ( + <> + + Check your email + + We sent you a verification link. Click the link in your email to verify your account. + + + )} + +
+ + } + placeholder="Enter your email to resend" + size="large" + /> + + + +
+ + +
+
+
+
+ ); +} diff --git a/packages/web/app/components/auth/auth-modal.tsx b/packages/web/app/components/auth/auth-modal.tsx index f1d0e47b..596159fb 100644 --- a/packages/web/app/components/auth/auth-modal.tsx +++ b/packages/web/app/components/auth/auth-modal.tsx @@ -4,6 +4,7 @@ import React, { useState } from 'react'; import { Modal, Form, Input, Button, Tabs, Typography, Divider, message, Space } from 'antd'; import { UserOutlined, LockOutlined, MailOutlined, HeartFilled } from '@ant-design/icons'; import { signIn } from 'next-auth/react'; +import SocialLoginButtons from '@/app/components/auth/social-login-buttons'; const { Text } = Typography; @@ -78,7 +79,17 @@ export default function AuthModal({ return; } - message.success('Account created! Logging you in...'); + // Check if email verification is required + if (data.requiresVerification) { + message.info('Please check your email to verify your account'); + setActiveTab('login'); + loginForm.setFieldValue('email', values.email); + registerForm.resetFields(); + return; + } + + // Fallback for accounts that don't require verification (e.g., adding password to OAuth account) + message.success('Account updated! Logging you in...'); const loginResult = await signIn('credentials', { email: values.email, @@ -93,7 +104,7 @@ export default function AuthModal({ } else { setActiveTab('login'); loginForm.setFieldValue('email', values.email); - message.info('Please log in with your new account'); + message.info('Please log in with your account'); } } catch (error) { console.error('Registration error:', error); @@ -227,35 +238,7 @@ export default function AuthModal({ or - + ); diff --git a/packages/web/app/components/auth/social-login-buttons.tsx b/packages/web/app/components/auth/social-login-buttons.tsx new file mode 100644 index 00000000..119ea468 --- /dev/null +++ b/packages/web/app/components/auth/social-login-buttons.tsx @@ -0,0 +1,147 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Button, Space, Skeleton } from 'antd'; +import { signIn } from 'next-auth/react'; +import { themeTokens } from '@/app/theme/theme-config'; + +// Note: OAuth provider icons and button colors use brand-specific colors +// per Google/Apple/Facebook brand guidelines, not design system tokens + +const GoogleIcon = () => ( + + + + + + +); + +const AppleIcon = () => ( + + + +); + +const FacebookIcon = () => ( + + + +); + +type ProvidersConfig = { + google: boolean; + apple: boolean; + facebook: boolean; +}; + +type SocialLoginButtonsProps = { + callbackUrl?: string; + disabled?: boolean; +}; + +export default function SocialLoginButtons({ + callbackUrl = '/', + disabled = false, +}: SocialLoginButtonsProps) { + const [providers, setProviders] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/auth/providers-config') + .then((res) => res.json()) + .then((data) => { + setProviders(data); + setLoading(false); + }) + .catch(() => { + // On error, don't show any OAuth buttons + setProviders({ google: false, apple: false, facebook: false }); + setLoading(false); + }); + }, []); + + const handleSocialSignIn = (provider: string) => { + signIn(provider, { callbackUrl }); + }; + + // Don't render anything if no providers are configured + const hasAnyProvider = providers && (providers.google || providers.apple || providers.facebook); + + if (loading) { + return ( + + + + + + ); + } + + if (!hasAnyProvider) { + return null; + } + + return ( + + {providers.google && ( + + )} + + {providers.apple && ( + + )} + + {providers.facebook && ( + + )} + + ); +} diff --git a/packages/web/app/lib/auth/__tests__/rate-limiter.test.ts b/packages/web/app/lib/auth/__tests__/rate-limiter.test.ts new file mode 100644 index 00000000..07014923 --- /dev/null +++ b/packages/web/app/lib/auth/__tests__/rate-limiter.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { checkRateLimit, getClientIp } from '../rate-limiter'; + +describe('rate-limiter', () => { + beforeEach(() => { + // Reset the module to clear the in-memory store between tests + vi.resetModules(); + }); + + describe('checkRateLimit', () => { + it('should allow requests under the limit', async () => { + const identifier = `test-${Date.now()}-1`; + + // First request should be allowed + const result1 = checkRateLimit(identifier, 5, 60000); + expect(result1.limited).toBe(false); + expect(result1.retryAfterSeconds).toBe(0); + + // Second request should also be allowed + const result2 = checkRateLimit(identifier, 5, 60000); + expect(result2.limited).toBe(false); + }); + + it('should block requests when limit is exceeded', async () => { + const identifier = `test-${Date.now()}-2`; + const maxRequests = 3; + + // Make requests up to the limit + for (let i = 0; i < maxRequests; i++) { + const result = checkRateLimit(identifier, maxRequests, 60000); + expect(result.limited).toBe(false); + } + + // The next request should be blocked + const blockedResult = checkRateLimit(identifier, maxRequests, 60000); + expect(blockedResult.limited).toBe(true); + expect(blockedResult.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('should use different limits for different identifiers', async () => { + const identifier1 = `test-${Date.now()}-3a`; + const identifier2 = `test-${Date.now()}-3b`; + + // Exhaust limit for identifier1 + for (let i = 0; i < 2; i++) { + checkRateLimit(identifier1, 2, 60000); + } + + // identifier1 should be blocked + const result1 = checkRateLimit(identifier1, 2, 60000); + expect(result1.limited).toBe(true); + + // identifier2 should still be allowed + const result2 = checkRateLimit(identifier2, 2, 60000); + expect(result2.limited).toBe(false); + }); + + it('should reset after window expires', async () => { + const identifier = `test-${Date.now()}-4`; + const shortWindow = 100; // 100ms window for testing + + // Exhaust the limit + for (let i = 0; i < 2; i++) { + checkRateLimit(identifier, 2, shortWindow); + } + + // Should be blocked + const blockedResult = checkRateLimit(identifier, 2, shortWindow); + expect(blockedResult.limited).toBe(true); + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, shortWindow + 50)); + + // Should be allowed again + const allowedResult = checkRateLimit(identifier, 2, shortWindow); + expect(allowedResult.limited).toBe(false); + }); + + it('should use default values when not specified', async () => { + const identifier = `test-${Date.now()}-5`; + + // Should use defaults (5 requests, 60 seconds) + const result = checkRateLimit(identifier); + expect(result.limited).toBe(false); + expect(result.retryAfterSeconds).toBe(0); + }); + }); + + describe('getClientIp', () => { + it('should extract IP from x-forwarded-for header', () => { + const request = new Request('http://localhost', { + headers: { + 'x-forwarded-for': '192.168.1.1, 10.0.0.1', + }, + }); + + const ip = getClientIp(request); + expect(ip).toBe('192.168.1.1'); + }); + + it('should extract IP from single x-forwarded-for value', () => { + const request = new Request('http://localhost', { + headers: { + 'x-forwarded-for': '203.0.113.195', + }, + }); + + const ip = getClientIp(request); + expect(ip).toBe('203.0.113.195'); + }); + + it('should use x-real-ip when x-forwarded-for is not present', () => { + const request = new Request('http://localhost', { + headers: { + 'x-real-ip': '10.0.0.1', + }, + }); + + const ip = getClientIp(request); + expect(ip).toBe('10.0.0.1'); + }); + + it('should prefer x-forwarded-for over x-real-ip', () => { + const request = new Request('http://localhost', { + headers: { + 'x-forwarded-for': '192.168.1.1', + 'x-real-ip': '10.0.0.1', + }, + }); + + const ip = getClientIp(request); + expect(ip).toBe('192.168.1.1'); + }); + + it('should return "unknown" when no IP headers are present', () => { + const request = new Request('http://localhost'); + + const ip = getClientIp(request); + expect(ip).toBe('unknown'); + }); + + it('should trim whitespace from IP addresses', () => { + const request = new Request('http://localhost', { + headers: { + 'x-forwarded-for': ' 192.168.1.1 ', + }, + }); + + const ip = getClientIp(request); + expect(ip).toBe('192.168.1.1'); + }); + }); +}); diff --git a/packages/web/app/lib/auth/auth-options.ts b/packages/web/app/lib/auth/auth-options.ts index 6c9451a8..1b6879a7 100644 --- a/packages/web/app/lib/auth/auth-options.ts +++ b/packages/web/app/lib/auth/auth-options.ts @@ -1,25 +1,51 @@ import { NextAuthOptions } from "next-auth"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; import GoogleProvider from "next-auth/providers/google"; +import AppleProvider from "next-auth/providers/apple"; +import FacebookProvider from "next-auth/providers/facebook"; import CredentialsProvider from "next-auth/providers/credentials"; import { getDb } from "@/app/lib/db/db"; import * as schema from "@/app/lib/db/schema"; import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; -export const authOptions: NextAuthOptions = { - adapter: DrizzleAdapter(getDb(), { - usersTable: schema.users, - accountsTable: schema.accounts, - sessionsTable: schema.sessions, - verificationTokensTable: schema.verificationTokens, - }), - providers: [ +// Build providers array conditionally based on available env vars +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const providers: any[] = []; + +// Only add Google provider if credentials are configured +if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + providers.push( GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }), - CredentialsProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }) + ); +} + +// Only add Apple provider if credentials are configured +if (process.env.APPLE_ID && process.env.APPLE_SECRET) { + providers.push( + AppleProvider({ + clientId: process.env.APPLE_ID, + clientSecret: process.env.APPLE_SECRET, + }) + ); +} + +// Only add Facebook provider if credentials are configured +if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET) { + providers.push( + FacebookProvider({ + clientId: process.env.FACEBOOK_CLIENT_ID, + clientSecret: process.env.FACEBOOK_CLIENT_SECRET, + }) + ); +} + +// Always add credentials provider +providers.push( + CredentialsProvider({ name: "Email", credentials: { email: { label: "Email", type: "email", placeholder: "your@email.com" }, @@ -74,15 +100,51 @@ export const authOptions: NextAuthOptions = { image: user.image, }; }, - }), - ], + }) +); + +export const authOptions: NextAuthOptions = { + adapter: DrizzleAdapter(getDb(), { + usersTable: schema.users, + accountsTable: schema.accounts, + sessionsTable: schema.sessions, + verificationTokensTable: schema.verificationTokens, + }), + providers, session: { strategy: "jwt", // Required for credentials provider }, pages: { signIn: "/auth/login", + verifyRequest: "/auth/verify-request", + error: "/auth/error", }, callbacks: { + async signIn({ user, account }) { + // OAuth providers - allow sign in (emails are pre-verified by provider) + if (account?.provider !== "credentials") { + return true; + } + + // For credentials, check if email is verified + if (!user.email) { + return false; + } + + const db = getDb(); + const existingUser = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, user.email)) + .limit(1); + + if (existingUser.length > 0 && !existingUser[0].emailVerified) { + // Redirect to verification page with error + return "/auth/verify-request?error=EmailNotVerified"; + } + + return true; + }, async session({ session, token }) { // Include user ID in session from JWT if (session?.user && token?.sub) { @@ -98,4 +160,15 @@ export const authOptions: NextAuthOptions = { return token; }, }, + events: { + async createUser({ user }) { + // Create profile for new OAuth users + if (user.id) { + const db = getDb(); + await db.insert(schema.userProfiles).values({ + userId: user.id, + }).onConflictDoNothing(); + } + }, + }, }; diff --git a/packages/web/app/lib/auth/rate-limiter.ts b/packages/web/app/lib/auth/rate-limiter.ts new file mode 100644 index 00000000..dd4eab8b --- /dev/null +++ b/packages/web/app/lib/auth/rate-limiter.ts @@ -0,0 +1,98 @@ +/** + * Rate limiter for API endpoints. + * + * IMPORTANT: This uses in-memory storage which has limitations: + * - In serverless environments (Vercel), each function instance has its own memory + * - Rate limits are not shared across instances + * - This provides best-effort protection, not guaranteed rate limiting + * + * For production deployments requiring strict rate limiting, consider: + * - Redis (add ioredis to dependencies and use REDIS_URL) + * - Vercel KV (@vercel/kv) + * - Upstash Redis (@upstash/redis) + * + * The current implementation still provides value by: + * - Limiting rapid-fire requests within a single function instance + * - Deterring casual abuse + * - Providing a framework for upgrading to distributed storage + */ + +// In-memory store for rate limiting +const memoryStore = new Map(); + +// Default limits for email endpoints +const DEFAULT_WINDOW_MS = 60_000; // 1 minute +const DEFAULT_MAX_REQUESTS = 5; + +/** + * Check if a request should be rate limited. + * @param identifier - Unique identifier for the rate limit bucket (e.g., "register:192.168.1.1") + * @param maxRequests - Maximum requests allowed in the time window + * @param windowMs - Time window in milliseconds + * @returns Object with limited flag and retry-after seconds + */ +export function checkRateLimit( + identifier: string, + maxRequests: number = DEFAULT_MAX_REQUESTS, + windowMs: number = DEFAULT_WINDOW_MS +): { limited: boolean; retryAfterSeconds: number } { + const now = Date.now(); + const entry = memoryStore.get(identifier); + + // If no entry or window expired, create new entry + if (!entry || now > entry.resetAt) { + memoryStore.set(identifier, { + count: 1, + resetAt: now + windowMs, + }); + return { limited: false, retryAfterSeconds: 0 }; + } + + // Check if limit exceeded + if (entry.count >= maxRequests) { + const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000); + return { limited: true, retryAfterSeconds }; + } + + // Increment counter + entry.count++; + return { limited: false, retryAfterSeconds: 0 }; +} + +/** + * Get client IP address from request headers. + * Handles common proxy headers (x-forwarded-for, x-real-ip). + */ +export function getClientIp(request: Request): string { + // Check x-forwarded-for first (most common proxy header) + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + // x-forwarded-for can contain multiple IPs; the first is the original client + return forwarded.split(',')[0].trim(); + } + + // Check x-real-ip (used by some proxies like nginx) + const realIp = request.headers.get('x-real-ip'); + if (realIp) { + return realIp.trim(); + } + + // Fallback - still rate limit but with a shared bucket + return 'unknown'; +} + +// Cleanup expired entries periodically to prevent memory leaks +// Uses unref() to allow the process to exit even with the interval running +const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of memoryStore) { + if (now > entry.resetAt) { + memoryStore.delete(key); + } + } +}, 60_000); + +// Allow the Node.js process to exit even if this interval is pending +if (typeof cleanupInterval.unref === 'function') { + cleanupInterval.unref(); +} diff --git a/packages/web/app/lib/email/email-service.ts b/packages/web/app/lib/email/email-service.ts new file mode 100644 index 00000000..40380558 --- /dev/null +++ b/packages/web/app/lib/email/email-service.ts @@ -0,0 +1,98 @@ +import nodemailer, { Transporter } from 'nodemailer'; +import { z } from 'zod'; +import { themeTokens } from '@/app/theme/theme-config'; + +// Email validation schema - validates format before using in URLs +const emailSchema = z.string().email(); + +// Email color palette derived from design tokens +// These are inline styles for HTML emails, so we extract the actual hex values +const emailColors = { + primary: themeTokens.colors.primary, // Cyan primary + textPrimary: themeTokens.neutral[800], // Dark text + textSecondary: themeTokens.neutral[500], // Medium text + textMuted: themeTokens.neutral[400], // Light text + border: themeTokens.neutral[200], // Light border +} as const; + +// Lazy-loaded transporter to avoid initialization at module load +let transporter: Transporter | null = null; + +function getTransporter(): Transporter { + if (!transporter) { + if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) { + throw new Error('SMTP credentials not configured. Set SMTP_USER and SMTP_PASSWORD environment variables.'); + } + + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.fastmail.com', + port: parseInt(process.env.SMTP_PORT || '465', 10), + secure: true, // true for 465, false for 587 + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, + }); + } + return transporter; +} + +// HTML escape function to prevent XSS +function escapeHtml(text: string): string { + const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); +} + +export async function sendVerificationEmail( + email: string, + token: string, + baseUrl: string +): Promise { + // Validate email format before using in URL to prevent injection + const validatedEmail = emailSchema.parse(email); + + const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${token}&email=${encodeURIComponent(validatedEmail)}`; + const safeVerifyUrl = escapeHtml(verifyUrl); + + await getTransporter().sendMail({ + from: process.env.EMAIL_FROM || process.env.SMTP_USER, + to: validatedEmail, + subject: 'Verify your Boardsesh email', + html: ` +
+

Welcome to Boardsesh!

+

+ Please verify your email address by clicking the button below: +

+ Verify Email +

+ Or copy and paste this link into your browser: +

+

+ ${safeVerifyUrl} +

+
+

+ This link expires in 24 hours. If you didn't create a Boardsesh account, you can safely ignore this email. +

+
+ `, + text: `Welcome to Boardsesh!\n\nPlease verify your email address by clicking this link:\n\n${verifyUrl}\n\nThis link expires in 24 hours.\n\nIf you didn't create a Boardsesh account, you can safely ignore this email.`, + }); +} diff --git a/packages/web/package.json b/packages/web/package.json index f72f969e..d5c529db 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -50,6 +50,7 @@ "iron-session": "^8.0.4", "next": "^16.1.1", "next-auth": "^4.24.13", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "react": "^19.2.3", "react-chartjs-2": "^5.3.1", @@ -70,6 +71,7 @@ "@testing-library/react": "^16.3.1", "@types/d3-scale": "^4.0.9", "@types/node": "^25", + "@types/nodemailer": "^7.0.4", "@types/react": "^19", "@types/react-dom": "^19", "@types/uuid": "^11.0.0",