From b961d48fdfd9b3b10104ceae512ecd9f7e50dd2f Mon Sep 17 00:00:00 2001
From: Marco de Jongh
Date: Thu, 1 Jan 2026 17:07:43 +1100
Subject: [PATCH 1/2] feat: Add OAuth providers (Google, Apple, Facebook) and
email verification
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add Google, Apple, and Facebook OAuth providers to NextAuth configuration
- Implement email verification flow for credentials-based signup
- Create email service using nodemailer with Fastmail SMTP support
- Add social login buttons component with proper branding
- Create verify-request and error pages for auth flows
- Update registration to send verification emails
- Block unverified users from logging in with credentials
- Add comprehensive OAuth setup documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
docs/oauth-setup.md | 199 ++++++++++++++++++
package-lock.json | 74 +++++++
packages/web/app/api/auth/register/route.ts | 24 ++-
.../app/api/auth/resend-verification/route.ts | 71 +++++++
.../web/app/api/auth/verify-email/route.ts | 76 +++++++
.../web/app/auth/error/auth-error-content.tsx | 85 ++++++++
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 | 138 ++++++++++++
.../web/app/components/auth/auth-modal.tsx | 45 ++--
.../components/auth/social-login-buttons.tsx | 97 +++++++++
packages/web/app/lib/auth/auth-options.ts | 48 +++++
packages/web/app/lib/email/email-service.ts | 55 +++++
packages/web/package.json | 2 +
15 files changed, 935 insertions(+), 66 deletions(-)
create mode 100644 docs/oauth-setup.md
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/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 afd85d2c..24f88dd6 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",
@@ -10947,6 +11019,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",
@@ -10967,6 +11040,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/register/route.ts b/packages/web/app/api/auth/register/route.ts
index 6164328e..d9c2d08d 100644
--- a/packages/web/app/api/auth/register/route.ts
+++ b/packages/web/app/api/auth/register/route.ts
@@ -4,6 +4,7 @@ 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";
const registerSchema = z.object({
email: z.string().email("Invalid email address"),
@@ -70,11 +71,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 +90,26 @@ 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
+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+ await sendVerificationEmail(email, token, baseUrl);
+
return NextResponse.json(
- { message: "Account created successfully", userId },
+ {
+ message: "Account created. Please check your email to verify your account.",
+ requiresVerification: true,
+ 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..153dc168
--- /dev/null
+++ b/packages/web/app/api/auth/resend-verification/route.ts
@@ -0,0 +1,71 @@
+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 { sendVerificationEmail } from "@/app/lib/email/email-service";
+
+export async function POST(request: NextRequest) {
+ try {
+ const { email } = await request.json();
+
+ if (!email) {
+ return NextResponse.json(
+ { error: "Email is required" },
+ { status: 400 }
+ );
+ }
+
+ 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);
+
+ if (user.length === 0) {
+ // Don't reveal if user exists - return success anyway
+ return NextResponse.json(
+ { message: "If an account exists, a verification email will be sent" },
+ { status: 200 }
+ );
+ }
+
+ if (user[0].emailVerified) {
+ return NextResponse.json(
+ { error: "Email is already verified" },
+ { status: 400 }
+ );
+ }
+
+ // 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);
+
+ return NextResponse.json(
+ { message: "Verification email sent" },
+ { status: 200 }
+ );
+ } catch (error) {
+ console.error("Resend verification error:", error);
+ 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..ec0b77ee
--- /dev/null
+++ b/packages/web/app/api/auth/verify-email/route.ts
@@ -0,0 +1,76 @@
+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)
+ );
+ }
+
+ // 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..7bd65d50
--- /dev/null
+++ b/packages/web/app/auth/error/auth-error-content.tsx
@@ -0,0 +1,85 @@
+'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';
+
+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..a3d369c8
--- /dev/null
+++ b/packages/web/app/auth/verify-request/verify-request-content.tsx
@@ -0,0 +1,138 @@
+'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';
+
+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..11816715
--- /dev/null
+++ b/packages/web/app/components/auth/social-login-buttons.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import React from 'react';
+import { Button, Space } from 'antd';
+import { signIn } from 'next-auth/react';
+
+const GoogleIcon = () => (
+
+);
+
+const AppleIcon = () => (
+
+);
+
+const FacebookIcon = () => (
+
+);
+
+type SocialLoginButtonsProps = {
+ callbackUrl?: string;
+ disabled?: boolean;
+};
+
+export default function SocialLoginButtons({
+ callbackUrl = '/',
+ disabled = false,
+}: SocialLoginButtonsProps) {
+ const handleSocialSignIn = (provider: string) => {
+ signIn(provider, { callbackUrl });
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/app/lib/auth/auth-options.ts b/packages/web/app/lib/auth/auth-options.ts
index 6c9451a8..d4c54a96 100644
--- a/packages/web/app/lib/auth/auth-options.ts
+++ b/packages/web/app/lib/auth/auth-options.ts
@@ -1,6 +1,8 @@
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";
@@ -19,6 +21,14 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
+ AppleProvider({
+ clientId: process.env.APPLE_ID!,
+ clientSecret: process.env.APPLE_SECRET!,
+ }),
+ FacebookProvider({
+ clientId: process.env.FACEBOOK_CLIENT_ID!,
+ clientSecret: process.env.FACEBOOK_CLIENT_SECRET!,
+ }),
CredentialsProvider({
name: "Email",
credentials: {
@@ -81,8 +91,35 @@ export const authOptions: NextAuthOptions = {
},
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 +135,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/email/email-service.ts b/packages/web/app/lib/email/email-service.ts
new file mode 100644
index 00000000..36985a05
--- /dev/null
+++ b/packages/web/app/lib/email/email-service.ts
@@ -0,0 +1,55 @@
+import nodemailer from 'nodemailer';
+
+const 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,
+ },
+});
+
+export async function sendVerificationEmail(
+ email: string,
+ token: string,
+ baseUrl: string
+): Promise {
+ const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
+
+ await transporter.sendMail({
+ from: process.env.EMAIL_FROM || process.env.SMTP_USER,
+ to: email,
+ 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:
+
+
+ ${verifyUrl}
+
+
+
+ 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",
From f246de281f0bef5fdfc951fcea1463998372c3c8 Mon Sep 17 00:00:00 2001
From: Marco de Jongh
Date: Thu, 1 Jan 2026 21:53:48 +1100
Subject: [PATCH 2/2] fix: Address code review security and style issues
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Security fixes:
- Fix verification token enumeration by returning same message for all cases
- Lazy-load email transporter to avoid initialization at module load
- Add HTML escaping for email in verification templates
- Conditionally load OAuth providers only when env vars are configured
- Handle email sending failure gracefully in registration
Style fixes:
- Replace hardcoded colors with theme tokens in auth pages
- Add comment explaining brand-specific OAuth button colors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
packages/web/app/api/auth/register/route.ts | 16 ++++-
.../app/api/auth/resend-verification/route.ts | 15 ++---
.../web/app/auth/error/auth-error-content.tsx | 3 +-
.../verify-request/verify-request-content.tsx | 5 +-
.../components/auth/social-login-buttons.tsx | 10 ++-
packages/web/app/lib/auth/auth-options.ts | 65 +++++++++++++------
packages/web/app/lib/email/email-service.ts | 51 +++++++++++----
7 files changed, 113 insertions(+), 52 deletions(-)
diff --git a/packages/web/app/api/auth/register/route.ts b/packages/web/app/api/auth/register/route.ts
index d9c2d08d..4972ff19 100644
--- a/packages/web/app/api/auth/register/route.ts
+++ b/packages/web/app/api/auth/register/route.ts
@@ -100,14 +100,24 @@ export async function POST(request: NextRequest) {
expires,
});
- // Send verification email
+ // Send verification email (don't fail registration if email fails)
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
- await sendVerificationEmail(email, token, baseUrl);
+ 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. Please check your email to verify your account.",
+ 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 }
diff --git a/packages/web/app/api/auth/resend-verification/route.ts b/packages/web/app/api/auth/resend-verification/route.ts
index 153dc168..7f917eb3 100644
--- a/packages/web/app/api/auth/resend-verification/route.ts
+++ b/packages/web/app/api/auth/resend-verification/route.ts
@@ -24,18 +24,13 @@ export async function POST(request: NextRequest) {
.where(eq(schema.users.email, email))
.limit(1);
- if (user.length === 0) {
- // Don't reveal if user exists - return success anyway
- return NextResponse.json(
- { message: "If an account exists, a verification email will be sent" },
- { status: 200 }
- );
- }
+ // Don't reveal user status - return same message for all cases
+ const genericMessage = "If an account exists and needs verification, a verification email will be sent";
- if (user[0].emailVerified) {
+ if (user.length === 0 || user[0].emailVerified) {
return NextResponse.json(
- { error: "Email is already verified" },
- { status: 400 }
+ { message: genericMessage },
+ { status: 200 }
);
}
diff --git a/packages/web/app/auth/error/auth-error-content.tsx b/packages/web/app/auth/error/auth-error-content.tsx
index 7bd65d50..7a7f49c1 100644
--- a/packages/web/app/auth/error/auth-error-content.tsx
+++ b/packages/web/app/auth/error/auth-error-content.tsx
@@ -6,6 +6,7 @@ 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;
@@ -71,7 +72,7 @@ export default function AuthErrorContent() {
>
-
+
Authentication Error