import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import crypto from 'node:crypto'; import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { user, userEmailChanges } from '@/lib/db/schema/users'; import { createAuditLog } from '@/lib/audit'; import { ConflictError, errorResponse, ValidationError } from '@/lib/errors'; import { env } from '@/lib/env'; const updateEmailSchema = z.object({ email: z.string().email().toLowerCase(), }); const VERIFY_TOKEN_TTL_MINUTES = 60; const REQUIRES_VERIFICATION = process.env.EMAIL_CHANGE_INSTANT !== 'true'; /** * Initiate an email-change for the signed-in user. * * Production flow (REQUIRES_VERIFICATION=true, default): * 1. Create a user_email_changes row with sha256(token) * 2. Email OLD address with a cancel link * 3. Email NEW address with a confirm link * 4. Change applies only when /api/v1/me/email/confirm/ is called * * Dev shortcut (set EMAIL_CHANGE_INSTANT=true): * - Updates user.email immediately, skipping the email round-trip. * - Useful for local testing where SMTP isn't wired. */ export const PATCH = withAuth(async (req, ctx) => { try { const { email } = await parseBody(req, updateEmailSchema); if (email === ctx.user.email) { return NextResponse.json({ ok: true, unchanged: true }); } // Reject if another account already owns this address. const conflict = await db.query.user.findFirst({ where: eq(user.email, email) }); if (conflict && conflict.id !== ctx.userId) { throw new ConflictError('That email is already in use by another account'); } if (!REQUIRES_VERIFICATION) { // Instant change - dev only. const [updated] = await db .update(user) .set({ email, emailVerified: false, updatedAt: new Date() }) .where(eq(user.id, ctx.userId)) .returning({ email: user.email }); if (!updated) throw new ValidationError('Failed to update email'); void createAuditLog({ userId: ctx.userId, portId: ctx.portId || null, action: 'update', entityType: 'user', entityId: ctx.userId, oldValue: { email: ctx.user.email }, newValue: { email: updated.email }, metadata: { type: 'email_change_instant' }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); return NextResponse.json({ data: { email: updated.email, instant: true } }); } // Verification flow - generate a single-use token, hash it, persist. const rawToken = crypto.randomBytes(32).toString('base64url'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_MINUTES * 60 * 1000); const [pending] = await db .insert(userEmailChanges) .values({ userId: ctx.userId, oldEmail: ctx.user.email, newEmail: email, confirmTokenHash: tokenHash, expiresAt, }) .returning(); if (!pending) throw new ValidationError('Failed to create pending email-change row'); const baseUrl = env.APP_URL.replace(/\/+$/, ''); const confirmUrl = `${baseUrl}/api/v1/me/email/confirm/${rawToken}`; const cancelUrl = `${baseUrl}/api/v1/me/email/cancel/${rawToken}`; try { const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] = await Promise.all([ import('@/lib/email'), import('@/lib/email/shell'), import('@/lib/email/auth-shell-branding'), ]); const branding = await resolveAuthShellBranding(); const appName = branding?.appName?.trim() || 'CRM'; const brandingShell = branding ? { logoUrl: branding.logoUrl, backgroundUrl: branding.backgroundUrl, primaryColor: null, emailHeaderHtml: null, emailFooterHtml: null, } : null; const safeOldEmail = ctx.user.email.replace(/[<>&]/g, ''); const safeNewEmail = email.replace(/[<>&]/g, ''); const confirmBody = `

Hi,

You (or someone using your account) requested to change the sign-in email on your ${appName} account from ${safeOldEmail} to ${safeNewEmail}.

Click here to confirm this change - the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.

If you didn't request this, ignore this email.

`; const cancelBody = `

Hi,

A change to your sign-in email was requested. If this wasn't you, click here to cancel the change immediately and consider rotating your password.

`; const confirmSubject = `Confirm your new ${appName} email address`; const noticeSubject = `A change to your ${appName} email was requested`; await Promise.allSettled([ sendEmail( email, confirmSubject, renderShell({ title: confirmSubject, body: confirmBody, branding: brandingShell }), undefined, `Confirm new email: ${confirmUrl}`, ), sendEmail( ctx.user.email, noticeSubject, renderShell({ title: noticeSubject, body: cancelBody, branding: brandingShell }), undefined, `Cancel email change: ${cancelUrl}`, ), ]); } catch { // Email send is best-effort; the row stays so the user can re-request. } void createAuditLog({ userId: ctx.userId, portId: ctx.portId || null, action: 'create', entityType: 'user_email_change', entityId: pending.id, newValue: { newEmail: email }, metadata: { type: 'email_change_requested' }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); return NextResponse.json({ data: { pendingChangeId: pending.id, verificationSentTo: email, }, }); } catch (error) { return errorResponse(error); } });