From bc8613bbbcb5d149daecaeda1d667f8aadefb7ac Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 5 Apr 2026 17:09:23 +0000 Subject: [PATCH] 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) --- chart/templates/backend-deployment.yaml | 8 +- packages/admin/src/api/auth.ts | 8 + packages/admin/src/routeTree.gen.ts | 21 +++ packages/admin/src/routes/login.tsx | 151 ++++++++++++----- packages/admin/src/routes/reset-password.tsx | 154 ++++++++++++++++++ packages/backend/src/main.ts | 42 ++++- packages/backend/src/routes/v1/auth.ts | 100 ++++++++++-- .../backend/src/scripts/reset-password.ts | 51 ++++++ packages/shared/src/schemas/auth.schema.ts | 11 ++ packages/shared/src/schemas/index.ts | 4 +- 10 files changed, 491 insertions(+), 59 deletions(-) create mode 100644 packages/admin/src/routes/reset-password.tsx create mode 100644 packages/backend/src/scripts/reset-password.ts diff --git a/chart/templates/backend-deployment.yaml b/chart/templates/backend-deployment.yaml index 62ff1b1..fb2293b 100644 --- a/chart/templates/backend-deployment.yaml +++ b/chart/templates/backend-deployment.yaml @@ -93,18 +93,14 @@ spec: secretKeyRef: name: lunarfront-secrets key: business-name + - name: APP_URL + value: "https://{{ .Values.ingress.host }}" - name: INITIAL_USER_EMAIL valueFrom: secretKeyRef: name: lunarfront-secrets key: initial-user-email optional: true - - name: INITIAL_USER_PASSWORD - valueFrom: - secretKeyRef: - name: lunarfront-secrets - key: initial-user-password - optional: true - name: INITIAL_USER_FIRST_NAME valueFrom: secretKeyRef: diff --git a/packages/admin/src/api/auth.ts b/packages/admin/src/api/auth.ts index 37dfb68..2529681 100644 --- a/packages/admin/src/api/auth.ts +++ b/packages/admin/src/api/auth.ts @@ -14,3 +14,11 @@ interface LoginResponse { export async function login(email: string, password: string): Promise { return api.post('/v1/auth/login', { email, password }) } + +export async function forgotPassword(email: string): Promise<{ message: string }> { + return api.post<{ message: string }>('/v1/auth/forgot-password', { email }) +} + +export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { + return api.post<{ message: string }>('/v1/auth/reset-password', { token, newPassword }) +} diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index 36e5b7f..a3ef0aa 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as PosRouteImport } from './routes/pos' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' @@ -58,6 +59,11 @@ import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './rou import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments' import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId' +const ResetPasswordRoute = ResetPasswordRouteImport.update({ + id: '/reset-password', + path: '/reset-password', + getParentRoute: () => rootRouteImport, +} as any) const PosRoute = PosRouteImport.update({ id: '/pos', path: '/pos', @@ -337,6 +343,7 @@ export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute '/login': typeof LoginRoute '/pos': typeof PosRoute + '/reset-password': typeof ResetPasswordRoute '/help': typeof AuthenticatedHelpRoute '/profile': typeof AuthenticatedProfileRoute '/settings': typeof AuthenticatedSettingsRoute @@ -385,6 +392,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/pos': typeof PosRoute + '/reset-password': typeof ResetPasswordRoute '/help': typeof AuthenticatedHelpRoute '/profile': typeof AuthenticatedProfileRoute '/settings': typeof AuthenticatedSettingsRoute @@ -435,6 +443,7 @@ export interface FileRoutesById { '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute '/pos': typeof PosRoute + '/reset-password': typeof ResetPasswordRoute '/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute @@ -487,6 +496,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/pos' + | '/reset-password' | '/help' | '/profile' | '/settings' @@ -535,6 +545,7 @@ export interface FileRouteTypes { to: | '/login' | '/pos' + | '/reset-password' | '/help' | '/profile' | '/settings' @@ -584,6 +595,7 @@ export interface FileRouteTypes { | '/_authenticated' | '/login' | '/pos' + | '/reset-password' | '/_authenticated/help' | '/_authenticated/profile' | '/_authenticated/settings' @@ -635,10 +647,18 @@ export interface RootRouteChildren { AuthenticatedRoute: typeof AuthenticatedRouteWithChildren LoginRoute: typeof LoginRoute PosRoute: typeof PosRoute + ResetPasswordRoute: typeof ResetPasswordRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/reset-password': { + id: '/reset-password' + path: '/reset-password' + fullPath: '/reset-password' + preLoaderRoute: typeof ResetPasswordRouteImport + parentRoute: typeof rootRouteImport + } '/pos': { id: '/pos' path: '/pos' @@ -1112,6 +1132,7 @@ const rootRouteChildren: RootRouteChildren = { AuthenticatedRoute: AuthenticatedRouteWithChildren, LoginRoute: LoginRoute, PosRoute: PosRoute, + ResetPasswordRoute: ResetPasswordRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/admin/src/routes/login.tsx b/packages/admin/src/routes/login.tsx index 2938eb7..93deb7c 100644 --- a/packages/admin/src/routes/login.tsx +++ b/packages/admin/src/routes/login.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { createFileRoute, useRouter, redirect } from '@tanstack/react-router' import { useAuthStore } from '@/stores/auth.store' -import { login } from '@/api/auth' +import { login, forgotPassword } from '@/api/auth' interface Branding { name: string | null @@ -26,6 +26,8 @@ function LoginPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [branding, setBranding] = useState(null) + const [forgotMode, setForgotMode] = useState(false) + const [forgotSent, setForgotSent] = useState(false) useEffect(() => { fetch('/v1/store/branding') @@ -72,42 +74,117 @@ function LoginPage() {

Small Business Management

)} -
-
- - setEmail(e.target.value)} - required - className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" - /> -
-
- - setPassword(e.target.value)} - required - className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" - /> -
- {error && ( -

{error}

- )} - -
+ {forgotMode ? ( + forgotSent ? ( +
+

If an account exists with that email, you will receive a password reset link.

+ +
+ ) : ( +
{ + e.preventDefault() + setError('') + setLoading(true) + try { + await forgotPassword(email) + setForgotSent(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setLoading(false) + } + }} className="space-y-4"> +

Enter your email and we'll send you a reset link.

+
+ + setEmail(e.target.value)} + required + className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" + /> +
+ {error && ( +

{error}

+ )} + +
+ +
+
+ ) + ) : ( +
+
+ + setEmail(e.target.value)} + required + className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" + /> +
+
+ + setPassword(e.target.value)} + required + className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" + /> +
+ {error && ( +

{error}

+ )} + +
+ +
+
+ )} ) diff --git a/packages/admin/src/routes/reset-password.tsx b/packages/admin/src/routes/reset-password.tsx new file mode 100644 index 0000000..3b7e3da --- /dev/null +++ b/packages/admin/src/routes/reset-password.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' +import { resetPassword } from '@/api/auth' + +interface Branding { + name: string | null + hasLogo: boolean +} + +export const Route = createFileRoute('/reset-password')({ + component: ResetPasswordPage, +}) + +function ResetPasswordPage() { + const { token } = Route.useSearch() as { token?: string } + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + const [branding, setBranding] = useState(null) + + useEffect(() => { + fetch('/v1/store/branding') + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data) setBranding(data) }) + .catch(() => {}) + }, []) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + + if (!token) { + setError('Missing reset token. Please use the link from your email.') + return + } + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + if (password.length < 12) { + setError('Password must be at least 12 characters') + return + } + + setLoading(true) + try { + await resetPassword(token, password) + setSuccess(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Reset failed. The link may have expired.') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ {branding?.hasLogo ? ( + {branding.name + ) : ( +

{branding?.name ?? 'LunarFront'}

+ )} + {branding?.name ? ( +

Powered by LunarFront

+ ) : ( +

Small Business Management

+ )} +
+ + {success ? ( +
+

Password reset successfully.

+ + Sign in + +
+ ) : !token ? ( +
+

Invalid reset link. Please request a new one.

+ + Back to sign in + +
+ ) : ( + <> +

Set new password

+
+
+ + setPassword(e.target.value)} + required + minLength={12} + placeholder="At least 12 characters" + className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input" + /> +
+ {error && ( +

{error}

+ )} + +
+ + Back to sign in + +
+
+ + )} +
+
+ ) +} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 3f6ccc3..414d14f 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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>) { 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: ` +
+

${storeName}

+

Hi ${firstName},

+

Your account has been created. Click the button below to set your password and get started:

+ +

This link expires in 4 hours. If it expires, you can request a new one from the login page.

+
+

Powered by LunarFront

+
+ `, + 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>) { diff --git a/packages/backend/src/routes/v1/auth.ts b/packages/backend/src/routes/v1/auth.ts index dc40768..be309ca 100644 --- a/packages/backend/src/routes/v1/auth.ts +++ b/packages/backend/src/routes/v1/auth.ts @@ -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: ` +
+

${storeName}

+

Hi ${user.firstName},

+

Your account has been created. Click the button below to set your password and get started:

+ +

This link expires in 4 hours. If it expires, you can request a new one from the login page.

+
+

Powered by LunarFront

+
+ `, + 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: ` +
+

${storeName}

+

Hi ${user.firstName},

+

We received a request to reset your password. Click the button below to choose a new one:

+ +

This link expires in 4 hours. If you didn't request this, you can safely ignore this email.

+
+

Powered by LunarFront

+
+ `, + 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 diff --git a/packages/backend/src/scripts/reset-password.ts b/packages/backend/src/scripts/reset-password.ts new file mode 100644 index 0000000..2a57600 --- /dev/null +++ b/packages/backend/src/scripts/reset-password.ts @@ -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 + * + * 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 ') + 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() diff --git a/packages/shared/src/schemas/auth.schema.ts b/packages/shared/src/schemas/auth.schema.ts index 3b6f567..dc017ff 100644 --- a/packages/shared/src/schemas/auth.schema.ts +++ b/packages/shared/src/schemas/auth.schema.ts @@ -27,3 +27,14 @@ export const SetPinSchema = z.object({ pin: z.string().min(4).max(6).regex(/^\d+$/, 'PIN must be digits only'), }) export type SetPinInput = z.infer + +export const ForgotPasswordSchema = z.object({ + email: z.string().email(), +}) +export type ForgotPasswordInput = z.infer + +export const ResetPasswordSchema = z.object({ + token: z.string().min(1), + newPassword: z.string().min(12, 'Password must be at least 12 characters').max(128), +}) +export type ResetPasswordInput = z.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 5f14409..c904d4f 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,8 +1,8 @@ export { PaginationSchema } from './pagination.schema.js' export type { PaginationInput, PaginatedResponse } from './pagination.schema.js' -export { UserRole, RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from './auth.schema.js' -export type { RegisterInput, LoginInput, PinLoginInput, SetPinInput } from './auth.schema.js' +export { UserRole, RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from './auth.schema.js' +export type { RegisterInput, LoginInput, PinLoginInput, SetPinInput, ForgotPasswordInput, ResetPasswordInput } from './auth.schema.js' export { BillingMode,