diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md deleted file mode 100644 index 8ccf9b8a..00000000 --- a/docs/oauth-setup.md +++ /dev/null @@ -1,199 +0,0 @@ -# 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 eb14cff8..1bc19a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -801,58 +801,6 @@ "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", @@ -5982,17 +5930,6 @@ "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", @@ -8192,15 +8129,6 @@ "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", @@ -11016,7 +10944,6 @@ "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", @@ -11037,7 +10964,6 @@ "@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 deleted file mode 100644 index c91f3d9b..00000000 --- a/packages/web/app/api/auth/providers-config/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 473f1960..6164328e 100644 --- a/packages/web/app/api/auth/register/route.ts +++ b/packages/web/app/api/auth/register/route.ts @@ -4,8 +4,6 @@ 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"), @@ -18,22 +16,6 @@ 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 @@ -88,12 +70,11 @@ export async function POST(request: NextRequest) { const userId = crypto.randomUUID(); const passwordHash = await bcrypt.hash(password, 12); - // Insert user (emailVerified is null for unverified accounts) + // Insert user await db.insert(schema.users).values({ id: userId, email, name: name || email.split("@")[0], - emailVerified: null, }); // Insert credentials @@ -107,36 +88,8 @@ 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: emailSent - ? "Account created. Please check your email to verify your account." - : "Account created. Please request a new verification email.", - requiresVerification: true, - emailSent, - userId - }, + { message: "Account created successfully", 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 deleted file mode 100644 index 2cc6b519..00000000 --- a/packages/web/app/api/auth/resend-verification/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 31eb79d6..00000000 --- a/packages/web/app/api/auth/verify-email/route.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 22551ee3..00000000 --- a/packages/web/app/auth/error/auth-error-content.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'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 deleted file mode 100644 index e91cfc42..00000000 --- a/packages/web/app/auth/error/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 350f6f4c..373a8b3a 100644 --- a/packages/web/app/auth/login/auth-page-content.tsx +++ b/packages/web/app/auth/login/auth-page-content.tsx @@ -7,7 +7,6 @@ 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; @@ -25,8 +24,6 @@ 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) { @@ -38,13 +35,6 @@ 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') { @@ -101,17 +91,9 @@ export default function AuthPageContent() { return; } - // 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...'); + message.success('Account created! Logging you in...'); + // Auto-login after registration const loginResult = await signIn('credentials', { email: values.email, password: values.password, @@ -121,9 +103,10 @@ 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 account'); + message.info('Please log in with your new account'); } } catch (error) { console.error('Registration error:', error); @@ -278,7 +261,35 @@ 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 deleted file mode 100644 index d95338fd..00000000 --- a/packages/web/app/auth/verify-request/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 2dfccc29..00000000 --- a/packages/web/app/auth/verify-request/verify-request-content.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'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 596159fb..f1d0e47b 100644 --- a/packages/web/app/components/auth/auth-modal.tsx +++ b/packages/web/app/components/auth/auth-modal.tsx @@ -4,7 +4,6 @@ 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; @@ -79,17 +78,7 @@ export default function AuthModal({ return; } - // 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...'); + message.success('Account created! Logging you in...'); const loginResult = await signIn('credentials', { email: values.email, @@ -104,7 +93,7 @@ export default function AuthModal({ } else { setActiveTab('login'); loginForm.setFieldValue('email', values.email); - message.info('Please log in with your account'); + message.info('Please log in with your new account'); } } catch (error) { console.error('Registration error:', error); @@ -238,7 +227,35 @@ 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 deleted file mode 100644 index 119ea468..00000000 --- a/packages/web/app/components/auth/social-login-buttons.tsx +++ /dev/null @@ -1,147 +0,0 @@ -'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 deleted file mode 100644 index 07014923..00000000 --- a/packages/web/app/lib/auth/__tests__/rate-limiter.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 1b6879a7..6c9451a8 100644 --- a/packages/web/app/lib/auth/auth-options.ts +++ b/packages/web/app/lib/auth/auth-options.ts @@ -1,51 +1,25 @@ 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"; -// 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( +export const authOptions: NextAuthOptions = { + adapter: DrizzleAdapter(getDb(), { + usersTable: schema.users, + accountsTable: schema.accounts, + sessionsTable: schema.sessions, + verificationTokensTable: schema.verificationTokens, + }), + providers: [ GoogleProvider({ - 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({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + CredentialsProvider({ name: "Email", credentials: { email: { label: "Email", type: "email", placeholder: "your@email.com" }, @@ -100,51 +74,15 @@ providers.push( 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) { @@ -160,15 +98,4 @@ 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 deleted file mode 100644 index dd4eab8b..00000000 --- a/packages/web/app/lib/auth/rate-limiter.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 deleted file mode 100644 index 40380558..00000000 --- a/packages/web/app/lib/email/email-service.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 d5c529db..f72f969e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -50,7 +50,6 @@ "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", @@ -71,7 +70,6 @@ "@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",