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
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:
@@ -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.' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user