feat: add employee email verification on signup
Build & Deploy / Build & Push Docker Image (push) Failing after 41s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m1s
Test / Marketplace Unit Tests (push) Successful in 9m36s
Test / Admin Unit Tests (push) Successful in 9m38s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Has been cancelled

- Signup sends verification email instead of auto-login; login blocks unverified emails
- New endpoints: POST /verify-email and POST /resend-verification
- New verify-email page in dashboard with token-based email confirmation
- DB migration adds emailVerified and emailVerificationToken fields to Employee
- Update sign-in/sign-up/reset-password flows to handle verification states
- Minor UI polish across dashboard and marketplace components
- Refactor marketplace-homepage types
This commit is contained in:
root
2026-06-25 20:25:16 -04:00
parent cd67db99ed
commit c5f614b3e4
22 changed files with 594 additions and 190 deletions
@@ -1,7 +1,6 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { created } from '../../http/respond'
import { setSessionCookie } from '../../security/sessionCookies'
import { created, ok } from '../../http/respond'
import { accountStartSchema } from './auth.account.schemas'
import * as service from './auth.account.service'
@@ -11,14 +10,13 @@ const router = Router()
* POST /api/v1/auth/account/start
*
* Minimal signup — Step 0 of progressive profiling.
* Creates a DRAFT company + OWNER employee and returns a JWT session.
* See progressive-signup-plan.md Section 3.
* Creates a DRAFT company + OWNER employee and sends a verification email.
* The user must verify their email before signing in.
*/
router.post('/start', async (req, res, next) => {
try {
const body = parseBody(accountStartSchema, req)
const result = await service.startAccount(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
created(res, result)
} catch (err) { next(err) }
})
@@ -1,17 +1,30 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { signActorToken } from '../../security/tokens'
import { presentEmployeeSession } from './auth.presenter'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
type AccountStartInput = output<typeof accountStartSchema>
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
/**
* Minimal account creation — Step 0 of progressive signup.
* Creates a DRAFT company + OWNER employee, logs the user in immediately.
* Creates a DRAFT company + OWNER employee, sends a verification email.
* The user must verify their email before they can log in.
*/
export async function startAccount(body: AccountStartInput) {
if (await repo.findEmployeeByEmail(body.email)) {
@@ -20,6 +33,7 @@ export async function startAccount(body: AccountStartInput) {
const slug = `company-${Date.now().toString(36)}`
const passwordHash = await bcrypt.hash(body.password, 12)
const verificationToken = crypto.randomBytes(32).toString('hex')
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
@@ -67,6 +81,7 @@ export async function startAccount(body: AccountStartInput) {
lastName: '',
email: body.email,
passwordHash,
emailVerificationToken: verificationToken,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
@@ -74,12 +89,43 @@ export async function startAccount(body: AccountStartInput) {
include: { company: true },
})
return employee
return { employee, company }
})
const token = signActorToken(result.id, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as any,
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
}
const emailBodies: Record<string, string> = {
en: `<p>Welcome to FleetOS!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
fr: `<p>Bienvenue sur FleetOS !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
ar: `<p>مرحباً بك في FleetOS!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
}
const emailTexts: Record<string, string> = {
en: `Welcome to FleetOS!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
fr: `Bienvenue sur FleetOS !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
ar: `مرحباً بك في FleetOS!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
}
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
await sendTransactionalEmail({
to: body.email,
subject: emailSubjects[lang],
html: emailBodies[lang],
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message)
})
return { token, ...presentEmployeeSession(result as any, token) }
return {
message: 'Account created. Please check your email to verify your address before signing in.',
email: body.email,
}
}
@@ -70,3 +70,27 @@ export function resetPassword(id: string, passwordHash: string) {
},
})
}
export function setEmailVerificationToken(id: string, token: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: token },
})
}
export function findEmployeeByVerificationToken(token: string) {
return prisma.employee.findFirst({
where: { emailVerificationToken: token },
include: { company: true },
})
}
export function verifyEmployeeEmail(id: string) {
return prisma.employee.update({
where: { id },
data: {
emailVerified: new Date(),
emailVerificationToken: null,
},
})
}
@@ -61,4 +61,18 @@ router.post('/reset-password', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/verify-email', async (req, res, next) => {
try {
const { token } = parseBody(employeeResetPasswordSchema.pick({ token: true }), req)
ok(res, await service.verifyEmail(token))
} catch (err) { next(err) }
})
router.post('/resend-verification', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
ok(res, await service.resendVerification(email))
} catch (err) { next(err) }
})
export default router
@@ -105,6 +105,10 @@ export async function login(body: EmployeeLoginInput) {
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
}
if (!employee.emailVerified) {
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
}
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
if (!validPassword) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
@@ -175,3 +179,53 @@ export async function resetPassword(token: string, password: string) {
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
return { message: 'Password updated successfully. You can now sign in.' }
}
export async function verifyEmail(token: string) {
const employee = await repo.findEmployeeByVerificationToken(token)
if (!employee) {
throw new AppError('Verification link is invalid or has expired.', 400, 'invalid_token')
}
await repo.verifyEmployeeEmail(employee.id)
return { message: 'Email verified successfully. You can now sign in.', email: employee.email }
}
export async function resendVerification(email: string) {
const employee = await repo.findActiveEmployeeByEmail(email)
if (!employee) {
return { message: 'If that email is registered and not yet verified, a new verification link has been sent.' }
}
if (employee.emailVerified) {
return { message: 'This email is already verified. You can sign in.' }
}
const verificationToken = crypto.randomBytes(32).toString('hex')
await repo.setEmailVerificationToken(employee.id, verificationToken)
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const lang = (employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr') ? employee.preferredLanguage : 'en'
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
}
await sendTransactionalEmail({
to: employee.email,
subject: emailSubjects[lang],
html: `<p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p>`,
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message)
})
return { message: 'A new verification link has been sent to your email.' }
}