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..4972ff19 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,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..7f917eb3 --- /dev/null +++ b/packages/web/app/api/auth/resend-verification/route.ts @@ -0,0 +1,66 @@ +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); + + // 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.length === 0 || user[0].emailVerified) { + 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); + + 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..7a7f49c1 --- /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..87b00e05 --- /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..a789130a --- /dev/null +++ b/packages/web/app/components/auth/social-login-buttons.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React from 'react'; +import { Button, Space } 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 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..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/email/email-service.ts b/packages/web/app/lib/email/email-service.ts new file mode 100644 index 00000000..87272f59 --- /dev/null +++ b/packages/web/app/lib/email/email-service.ts @@ -0,0 +1,80 @@ +import nodemailer, { Transporter } from 'nodemailer'; + +// 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 { + const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`; + const safeVerifyUrl = escapeHtml(verifyUrl); + + await getTransporter().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: +

+

+ ${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",