feat: password reset flow with welcome emails
- POST /auth/forgot-password with welcome/reset email templates - POST /auth/reset-password with Zod validation, 4-hour tokens - Per-email rate limiting (3/hr) via Valkey, no user enumeration - Login page "Forgot password?" toggle with inline form - /reset-password page for setting new password from email link - Initial user seed sends welcome email instead of requiring password - CLI script for force-resetting passwords via kubectl exec - APP_URL env var in chart, removed INITIAL_USER_PASSWORD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,21 +39,55 @@ import { AppConfigService } from './services/config.service.js'
|
||||
import { SettingsService } from './services/settings.service.js'
|
||||
import { users } from './db/schema/users.js'
|
||||
import { companies } from './db/schema/stores.js'
|
||||
import { EmailService } from './services/email.service.js'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
async function seedInitialUser(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const email = process.env.INITIAL_USER_EMAIL
|
||||
const password = process.env.INITIAL_USER_PASSWORD
|
||||
const firstName = process.env.INITIAL_USER_FIRST_NAME
|
||||
const lastName = process.env.INITIAL_USER_LAST_NAME
|
||||
if (!email || !password || !firstName || !lastName) return
|
||||
if (!email || !firstName || !lastName) return
|
||||
|
||||
const existing = await app.db.select({ id: users.id }).from(users).limit(1)
|
||||
if (existing.length > 0) return
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' })
|
||||
// Create user with a random password — they'll set their real one via the welcome email
|
||||
const tempPassword = crypto.randomUUID()
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10)
|
||||
const [user] = await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' }).returning({ id: users.id })
|
||||
app.log.info({ email }, 'Initial admin user created')
|
||||
|
||||
// Send welcome email with password setup link
|
||||
try {
|
||||
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
|
||||
const appUrl = process.env.APP_URL ?? `https://${process.env.HOSTNAME ?? 'localhost'}`
|
||||
const resetLink = `${appUrl}/reset-password?token=${resetToken}`
|
||||
|
||||
const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1)
|
||||
const storeName = store?.name ?? process.env.BUSINESS_NAME ?? 'LunarFront'
|
||||
|
||||
await EmailService.send(app.db, {
|
||||
to: email,
|
||||
subject: `Welcome to ${storeName} — Set your password`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
|
||||
<p style="color: #555; margin-bottom: 24px;">Hi ${firstName},</p>
|
||||
<p style="color: #555;">Your account has been created. Click the button below to set your password and get started:</p>
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Set Your Password</a>
|
||||
</div>
|
||||
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If it expires, you can request a new one from the login page.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`,
|
||||
})
|
||||
app.log.info({ email }, 'Welcome email sent to initial user')
|
||||
} catch (err) {
|
||||
app.log.error({ email, error: (err as Error).message }, 'Failed to send welcome email — user can use forgot password')
|
||||
}
|
||||
}
|
||||
|
||||
async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from '@lunarfront/shared/schemas'
|
||||
import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from '@lunarfront/shared/schemas'
|
||||
import { users } from '../../db/schema/users.js'
|
||||
import { companies } from '../../db/schema/stores.js'
|
||||
import { EmailService } from '../../services/email.service.js'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
@@ -151,24 +153,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1)
|
||||
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
||||
|
||||
// Generate a signed reset token that expires in 1 hour
|
||||
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' })
|
||||
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
|
||||
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
|
||||
|
||||
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')
|
||||
return reply.send({ resetLink, expiresIn: '1 hour' })
|
||||
return reply.send({ resetLink, expiresIn: '4 hours' })
|
||||
})
|
||||
|
||||
// Reset password with token
|
||||
app.post('/auth/reset-password', async (request, reply) => {
|
||||
const { token, newPassword } = request.body as { token?: string; newPassword?: string }
|
||||
if (!token || !newPassword) {
|
||||
return reply.status(400).send({ error: { message: 'token and newPassword are required', statusCode: 400 } })
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } })
|
||||
const parsed = ResetPasswordSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
|
||||
const { token, newPassword } = parsed.data
|
||||
|
||||
try {
|
||||
const payload = app.jwt.verify<{ userId: string; purpose: string }>(token)
|
||||
if (payload.purpose !== 'password-reset') {
|
||||
@@ -185,6 +185,86 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Forgot password / resend welcome — public, always returns success (no user enumeration)
|
||||
// Pass ?type=welcome for welcome emails, defaults to reset
|
||||
app.post('/auth/forgot-password', rateLimitConfig, async (request, reply) => {
|
||||
const parsed = ForgotPasswordSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
|
||||
const { email } = parsed.data
|
||||
const isWelcome = (request.query as { type?: string }).type === 'welcome'
|
||||
|
||||
// Rate limit per email — max 3 emails per hour
|
||||
const emailKey = `pwd-reset:${email.toLowerCase()}`
|
||||
const count = await app.redis.incr(emailKey)
|
||||
if (count === 1) await app.redis.expire(emailKey, 3600)
|
||||
if (count > 3) {
|
||||
return reply.send({ message: 'If an account exists with that email, you will receive an email.' })
|
||||
}
|
||||
|
||||
// Always return success — don't reveal whether the email exists
|
||||
const [user] = await app.db.select({ id: users.id, firstName: users.firstName }).from(users).where(eq(users.email, email)).limit(1)
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
|
||||
const appUrl = process.env.APP_URL ?? 'http://localhost:5173'
|
||||
const resetLink = `${appUrl}/reset-password?token=${resetToken}`
|
||||
|
||||
const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1)
|
||||
const storeName = store?.name ?? 'LunarFront'
|
||||
|
||||
if (isWelcome) {
|
||||
await EmailService.send(app.db, {
|
||||
to: email,
|
||||
subject: `Welcome to ${storeName} — Set your password`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
|
||||
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
|
||||
<p style="color: #555;">Your account has been created. Click the button below to set your password and get started:</p>
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Set Your Password</a>
|
||||
</div>
|
||||
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If it expires, you can request a new one from the login page.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${user.firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`,
|
||||
})
|
||||
} else {
|
||||
await EmailService.send(app.db, {
|
||||
to: email,
|
||||
subject: `Reset your password — ${storeName}`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
|
||||
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
|
||||
<p style="color: #555;">We received a request to reset your password. Click the button below to choose a new one:</p>
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Reset Password</a>
|
||||
</div>
|
||||
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If you didn't request this, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${user.firstName}, reset your password here: ${resetLink} — This link expires in 4 hours.`,
|
||||
})
|
||||
}
|
||||
|
||||
request.log.info({ userId: user.id, type: isWelcome ? 'welcome' : 'reset' }, 'Password email sent')
|
||||
} catch (err) {
|
||||
request.log.error({ email, error: (err as Error).message }, 'Failed to send password email')
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ message: 'If an account exists with that email, you will receive a password reset link.' })
|
||||
})
|
||||
|
||||
// Get current user profile
|
||||
app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||
const [user] = await app.db
|
||||
|
||||
51
packages/backend/src/scripts/reset-password.ts
Normal file
51
packages/backend/src/scripts/reset-password.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Force-reset a user's password from the command line.
|
||||
*
|
||||
* Usage:
|
||||
* bun run packages/backend/src/scripts/reset-password.ts <email> <new-password>
|
||||
*
|
||||
* From a customer pod:
|
||||
* kubectl exec -n customer-tvs deploy/customer-tvs-backend -- \
|
||||
* bun run src/scripts/reset-password.ts user@example.com NewPassword123!
|
||||
*/
|
||||
import postgres from 'postgres'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { users } from '../db/schema/users.js'
|
||||
|
||||
const [email, newPassword] = process.argv.slice(2)
|
||||
|
||||
if (!email || !newPassword) {
|
||||
console.error('Usage: bun run reset-password.ts <email> <new-password>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (newPassword.length < 12) {
|
||||
console.error('Error: Password must be at least 12 characters')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) {
|
||||
console.error('Error: DATABASE_URL is not set')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sql = postgres(databaseUrl)
|
||||
const db = drizzle(sql)
|
||||
|
||||
const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, email)).limit(1)
|
||||
|
||||
if (!user) {
|
||||
console.error(`Error: No user found with email "${email}"`)
|
||||
await sql.end()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10)
|
||||
await db.update(users).set({ passwordHash: hash, updatedAt: new Date() }).where(eq(users.id, user.id))
|
||||
|
||||
console.log(`Password reset for ${email} (user ${user.id})`)
|
||||
await sql.end()
|
||||
Reference in New Issue
Block a user