Merchant Auth Decoupling Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Decouple merchant portal sign-in from Firebase Auth so resetting a merchant's portal password never destroys their Lantern encryption keys. Mirror the admin auth pattern (Issue #245) end-to-end across services/api/auth/ and apps/admin/.
Architecture: Add a separate merchantPasswordHash on merchantProfiles, parallel /auth/merchant/* endpoints mirroring /auth/admin/*, a new merchantPasswordResetTokens collection, a <SetMerchantPassword> component, and three-tier sign-in fall-through (admin โ merchant โ Firebase legacy). The two existing create-merchant endpoints both switch to the new token flow. Firebase Auth password (= Lantern passphrase, derives encryption keys) and merchant portal password live as separate credentials end-state.
Tech Stack: Express 5 + Firebase Admin + zod + scrypt (auth API); React 19 + Vite + Vitest + Testing Library (admin app). No new dependencies.
Spec: docs/superpowers/specs/2026-04-27-merchant-auth-decoupling-design.md
File Structure โ
New files โ
Backend (services/api/auth/):
src/routes/merchantAuth.jsโ five endpoints: signin, password (set/change), password/reset, password/reset/:token, statussrc/services/passwordHash.service.jsโ renamed fromadminAuth.service.js(rename, no content change)
Frontend (apps/admin/):
src/components/SetMerchantPassword.jsxโ mirror ofSetAdminPassword.jsx; handlessetupKind: 'fresh' | 'promotion' | 'reset'src/components/__tests__/SetMerchantPassword.test.jsxโ branch behavior + error states
Modified files โ
Backend:
src/index.jsโ mountmerchantAuthRouterat/auth/merchantsrc/routes/adminAuth.jsโ update import path for renamed service filesrc/routes/adminMerchants.jsโPOST /:merchantId/usersswitches to new merchant-token flowsrc/routes/adminUsers.jsโPOST /merchantswitches to new merchant-token flowsrc/lib/email.jsโ addsendMerchantPasswordResetEmail; revisesendMerchantInviteEmailURL pattern +setupKindargument
Frontend:
src/App.jsxโ addmode === 'merchantReset'URL handler; three-tier sign-in fall-throughsrc/components/LoginScreen.jsxโhandlePasswordResetcalls both admin + merchant reset in parallelsrc/firebase.jsโ add merchant auth re-exportssrc/lib/authApi.jsโ add merchant auth API client methods
Untouched โ
apps/web/โ Lantern consumer app uses Firebase Auth as todayfirestore.rulesโ collections live under existing admin pathsapps/admin/src/components/SetAdminPassword.jsx,CreateAdminForm.jsx, etc. โ admin flow unchanged
Conventions to Follow โ
- Mirror admin patterns: every block in
merchantAuth.jsshould have a counterpart inadminAuth.js. When in doubt, copy the admin version verbatim and substituteadminโmerchant. - scrypt password hashing:
services/passwordHash.service.jsexportshashPassword(password, existingSalt = null)andverifyPassword(password, storedHash, salt). Same params:N=16384, r=8, p=1, keyLen=64. Salts are 32 random bytes, base64-encoded. - Validation: zod for all request bodies (
SignInBody,SetPasswordBody,ResetRequestBody). - Audit logging: every merchant auth event appends a record to the existing
adminActionscollection (consistent with admin auth events). Action names:merchantLogin,merchantLoginFailed,requestMerchantPasswordReset,setMerchantPassword. - Safe responses: the reset-request endpoint always returns the same
{ success: true, message: 'If your email is registered, a reset link has been sent.' }regardless of whether the email matches. This preserves anti-enumeration and matches admin. - Token expiry: 24 hours from
createdAt. Mirror the admin check. - Commits: small, frequent. After each task, commit with a
feat:/fix:/refactor:/test:prefix. - Manual smoke tests: the auth-API workspace lacks route-level integration test infrastructure. Each backend task includes a curl smoke test against a locally-running server. Setting up emulator + supertest is deferred (see "Known Gap" at the end).
Task 1: Branch + spec audit โ
Files:
Read:
docs/superpowers/specs/2026-04-27-merchant-auth-decoupling-design.md[ ] Step 1.1: Verify branch + clean tree
git status
git log --oneline -5
git branch --show-currentExpected: on claude/merchant-integration, working tree clean. The branch already contains the previous merchant-user-attach work + this design spec.
- [ ] Step 1.2: Re-read the spec end-to-end
Make sure the v1 scope matches the plan tasks. Everything in "Out of Scope" stays untouched.
Task 2: Rename adminAuth.service.js โ passwordHash.service.js โ
The hashing helpers were never admin-specific. Rename for reuse before adding the merchant routes.
Files:
Rename:
services/api/auth/src/services/adminAuth.service.jsโservices/api/auth/src/services/passwordHash.service.jsModify:
services/api/auth/src/routes/adminAuth.js(update import)[ ] Step 2.1: Rename the service file with
git mv
git mv services/api/auth/src/services/adminAuth.service.js \
services/api/auth/src/services/passwordHash.service.js- [ ] Step 2.2: Update the import in
adminAuth.js
Find the import near the top of services/api/auth/src/routes/adminAuth.js:
import { hashPassword, verifyPassword } from '../services/adminAuth.service.js'Replace with:
import { hashPassword, verifyPassword } from '../services/passwordHash.service.js'- [ ] Step 2.3: Update the JSDoc header in the service file
Open services/api/auth/src/services/passwordHash.service.js and replace the top comment:
/**
* Admin auth helper โ password hashing with scrypt (same params as the CF).
*/With:
/**
* Password hashing helpers (scrypt). Used by both /auth/admin and /auth/merchant.
* Single source of truth for password verification across the auth API.
*/- [ ] Step 2.4: Verify auth-API tests still pass
cd services/api/auth && npm run test:runExpected: all existing tests pass (4/4 from merchantIds.test.js).
- [ ] Step 2.5: Verify syntax
cd services/api/auth && node --check src/routes/adminAuth.jsExpected: no output.
- [ ] Step 2.6: Commit
git -C /home/mechelle/repos/lantern_app add -A
git -C /home/mechelle/repos/lantern_app commit -m "refactor(auth-api): rename adminAuth.service to passwordHash.service
Hashing helpers are not admin-specific; merchant auth routes will reuse them."Task 3: Add sendMerchantPasswordResetEmail and revise sendMerchantInviteEmail โ
Files:
Modify:
services/api/auth/src/lib/email.js[ ] Step 3.1: Inspect the existing
sendAdminPasswordResetEmailto mirror its structure
grep -n "sendAdminPasswordResetEmail\b" /home/mechelle/repos/lantern_app/services/api/auth/src/lib/email.js | head -3Open the file at the matched line and read the function's body. The new function will mirror it.
- [ ] Step 3.2: Add
sendMerchantPasswordResetEmailnext tosendMerchantInviteEmail
After the existing sendMerchantInviteEmail export, add:
/**
* Send a merchant portal password reset email via Resend.
*
* @param {object} args
* @param {string} args.apiKey - RESEND_API_KEY
* @param {string} args.toEmail
* @param {string} args.resetLink - Full merchant-token URL (?mode=merchantReset&token=...)
* @returns {Promise<{success: boolean, error?: any}>}
*/
export async function sendMerchantPasswordResetEmail({ apiKey, toEmail, resetLink }) {
if (!apiKey) return { success: false, error: 'RESEND_API_KEY not configured' }
try {
const { Resend } = await import('resend')
const resend = new Resend(apiKey)
const { error } = await resend.emails.send({
from: 'Lantern <noreply@ourlantern.app>',
to: toEmail,
subject: 'Reset your Lantern merchant portal password',
html: `
<p>You requested a password reset for your Lantern merchant portal.</p>
<p><a href="${resetLink}">Reset your password</a></p>
<p>This link is valid for 24 hours. If you didn't request this, you can safely ignore this email.</p>
`,
})
if (error) return { success: false, error }
return { success: true }
} catch (err) {
return { success: false, error: err }
}
}- [ ] Step 3.3: Revise
sendMerchantInviteEmailto acceptsetupKind
Find the existing export async function sendMerchantInviteEmail(...). Update the JSDoc + signature to accept a new setupKind argument and adjust the subject/copy:
/**
* Send a merchant invite email via Resend.
*
* @param {object} args
* @param {string} args.apiKey
* @param {string} args.toEmail
* @param {string} args.displayName
* @param {string} args.businessName
* @param {string} args.resetLink - merchant-token URL (?mode=merchantReset&token=...)
* @param {string} args.inviterName
* @param {'fresh'|'promotion'} args.setupKind - controls subject/copy
*/
export async function sendMerchantInviteEmail({
apiKey, toEmail, displayName, businessName, resetLink, inviterName, setupKind = 'fresh',
}) {
if (!apiKey) return { success: false, error: 'RESEND_API_KEY not configured' }
try {
const { Resend } = await import('resend')
const resend = new Resend(apiKey)
const subject = setupKind === 'promotion'
? `You've been added to ${businessName} on Lantern`
: `Welcome to ${businessName} on Lantern โ set up your account`
const intro = setupKind === 'promotion'
? `<p>${inviterName} added you as a merchant for <strong>${businessName}</strong>. Set your merchant portal password to get started.</p>`
: `<p>${inviterName} created a Lantern merchant account for you at <strong>${businessName}</strong>. Set your Lantern passphrase and your merchant portal password to get started.</p>`
const { error } = await resend.emails.send({
from: 'Lantern <noreply@ourlantern.app>',
to: toEmail,
subject,
html: `
${intro}
<p><a href="${resetLink}">Set up your account</a></p>
<p>This link is valid for 24 hours.</p>
`,
})
if (error) return { success: false, error }
return { success: true }
} catch (err) {
return { success: false, error: err }
}
}(If the existing signature differs slightly, adapt โ the goal is just to add the setupKind arg + the conditional subject/copy.)
- [ ] Step 3.4: Verify syntax
cd services/api/auth && node --check src/lib/email.jsExpected: no output.
- [ ] Step 3.5: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/lib/email.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): add sendMerchantPasswordResetEmail; sendMerchantInviteEmail takes setupKind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 4: Scaffold merchantAuth.js and mount it โ
Create the route file with all five endpoints stubbed (returning 501 NOT_IMPLEMENTED), and mount it at /auth/merchant. This lets us verify wiring before filling in handlers.
Files:
Create:
services/api/auth/src/routes/merchantAuth.jsModify:
services/api/auth/src/index.js[ ] Step 4.1: Create the route file with stubs
Write services/api/auth/src/routes/merchantAuth.js:
/**
* Merchant portal authentication routes
*
* POST /auth/merchant/signin โ sign in with merchant password โ custom token
* POST /auth/merchant/password โ set / change merchant password (auth required)
* POST /auth/merchant/password/reset โ request reset email (unauthenticated)
* GET /auth/merchant/password/reset/:tok โ verify reset token validity
* GET /auth/merchant/status โ check whether merchant password has been set
*/
import { Router } from 'express'
import { getAuth } from 'firebase-admin/auth'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
import { randomBytes } from 'crypto'
import { z } from 'zod'
import { hashPassword, verifyPassword } from '../services/passwordHash.service.js'
import { sendMerchantPasswordResetEmail } from '../lib/email.js'
const router = Router()
const SignInBody = z.object({ email: z.string().email(), password: z.string().min(1) })
const SetPasswordBody = z.object({
password: z.string().min(8),
resetToken: z.string().optional(),
})
const ResetRequestBody = z.object({ email: z.string().email() })
const SAFE_RESPONSE = {
success: true,
message: 'If your email is registered, a reset link has been sent.',
}
router.post('/signin', (req, res) =>
res.status(501).json({ error: 'NOT_IMPLEMENTED' })
)
router.post('/password', (req, res) =>
res.status(501).json({ error: 'NOT_IMPLEMENTED' })
)
router.post('/password/reset', (req, res) =>
res.status(501).json({ error: 'NOT_IMPLEMENTED' })
)
router.get('/password/reset/:token', (req, res) =>
res.status(501).json({ error: 'NOT_IMPLEMENTED' })
)
router.get('/status', (req, res) =>
res.status(501).json({ error: 'NOT_IMPLEMENTED' })
)
export default router- [ ] Step 4.2: Mount the router in
index.js
Find the line where adminAuth is mounted in services/api/auth/src/index.js (search for '/auth/admin' or adminAuthRouter). Add the merchant mount alongside it:
import merchantAuthRouter from './routes/merchantAuth.js'
// ...
app.use('/auth/merchant', merchantAuthRouter)(Match the exact import path and app.use(...) style of the surrounding admin auth mount.)
- [ ] Step 4.3: Verify syntax + boot
cd services/api/auth && node --check src/routes/merchantAuth.js
cd services/api/auth && node --check src/index.jsExpected: no output for both.
- [ ] Step 4.4: Boot the server and hit each stub
In one terminal:
npm run dev -w services/api/authIn another:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8084/auth/merchant/signin
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8084/auth/merchant/password
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8084/auth/merchant/password/reset
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/password/reset/abc
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8084/auth/merchant/status?email=x@y.z"Expected: 501 for all five. (Confirms the routes are mounted.)
- [ ] Step 4.5: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js services/api/auth/src/index.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): scaffold /auth/merchant route stubs and mount router
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 5: Implement POST /auth/merchant/signin โ
Files:
Modify:
services/api/auth/src/routes/merchantAuth.js[ ] Step 5.1: Replace the signin stub with the full implementation
In merchantAuth.js, replace the router.post('/signin', ...) stub with:
// โโ POST /auth/merchant/signin โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
router.post('/signin', async (req, res, next) => {
try {
const { email, password } = SignInBody.parse(req.body)
const auth = getAuth()
const db = getFirestore()
let user
try {
user = await auth.getUserByEmail(email.trim())
} catch (err) {
if (err.code === 'auth/user-not-found') {
return res.status(401).json({ error: 'INVALID_CREDENTIALS', message: 'Invalid email or password' })
}
throw err
}
const claims = user.customClaims || {}
if (claims.role !== 'merchant') {
return res.status(401).json({ error: 'INVALID_CREDENTIALS', message: 'Invalid email or password' })
}
const profileSnap = await db.collection('merchantProfiles').doc(user.uid).get()
if (!profileSnap.exists) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS', message: 'Invalid email or password' })
}
const profile = profileSnap.data()
if (!profile.merchantPasswordHash || !profile.merchantPasswordSalt) {
return res.status(428).json({ error: 'MERCHANT_PASSWORD_NOT_SET', message: 'Merchant password has not been set. Complete setup first.' })
}
const valid = await verifyPassword(password, profile.merchantPasswordHash, profile.merchantPasswordSalt)
if (!valid) {
await db.collection('merchantProfiles').doc(user.uid).update({
failedLoginAttempts: FieldValue.increment(1),
lastFailedLoginAt: FieldValue.serverTimestamp(),
})
await db.collection('adminActions').add({
action: 'merchantLoginFailed', targetUserId: user.uid,
performedBy: user.uid, performedAt: FieldValue.serverTimestamp(),
})
return res.status(401).json({ error: 'INVALID_CREDENTIALS', message: 'Invalid email or password' })
}
if (profile.merchantPasswordResetRequired) {
return res.status(428).json({ error: 'MERCHANT_PASSWORD_RESET_REQUIRED', message: 'Password reset required before sign-in.' })
}
const customToken = await auth.createCustomToken(user.uid, { role: 'merchant', merchantAuth: true })
await db.collection('merchantProfiles').doc(user.uid).update({
failedLoginAttempts: 0,
lastMerchantLoginAt: FieldValue.serverTimestamp(),
})
const userDocRef = db.collection('users').doc(user.uid)
await userDocRef.update({ lastMerchantLoginAt: FieldValue.serverTimestamp() }).catch(() => {})
await db.collection('adminActions').add({
action: 'merchantLogin', targetUserId: user.uid, performedBy: user.uid,
performedAt: FieldValue.serverTimestamp(), success: true,
})
const userDoc = await userDocRef.get()
const merchantId = userDoc.exists ? userDoc.data().merchantId || null : null
return res.json({
customToken, userId: user.uid, email: user.email,
displayName: profile.contactName || user.displayName || '',
merchantId,
})
} catch (err) {
next(err)
}
})- [ ] Step 5.2: Verify syntax
cd services/api/auth && node --check src/routes/merchantAuth.jsExpected: no output.
- [ ] Step 5.3: Smoke test โ happy path
With a server running and a known merchant user (created via the previous PR's Create Merchant User flow) whose merchantPasswordHash you've manually populated in Firestore (you can run a one-off script or wait until Task 11 wires the create flow; for now, manually create the doc fields in Firestore Console with a hash generated by services/passwordHash.service.js):
curl -i -X POST http://localhost:8084/auth/merchant/signin \
-H "Content-Type: application/json" \
-d '{"email":"<merchant-email>","password":"<known-password>"}'Expected: 200 with { customToken, userId, merchantId, ... }.
- [ ] Step 5.4: Smoke test โ wrong password
curl -i -X POST http://localhost:8084/auth/merchant/signin \
-H "Content-Type: application/json" \
-d '{"email":"<merchant-email>","password":"wrong"}'Expected: 401 with { error: 'INVALID_CREDENTIALS' }. Verify merchantProfiles/{uid}.failedLoginAttempts increments in Firestore.
- [ ] Step 5.5: Smoke test โ admin email returns 401 (not 428)
curl -i -X POST http://localhost:8084/auth/merchant/signin \
-H "Content-Type: application/json" \
-d '{"email":"<admin-email>","password":"x"}'Expected: 401 INVALID_CREDENTIALS. (Admins are not merchants โ must look identical to "wrong password" to prevent enumeration.)
- [ ] Step 5.6: Smoke test โ merchant without
merchantPasswordHashreturns 428
Pick a merchant whose merchantProfiles doc has no merchantPasswordHash. Call signin:
curl -i -X POST http://localhost:8084/auth/merchant/signin \
-H "Content-Type: application/json" \
-d '{"email":"<unset-merchant-email>","password":"x"}'Expected: 428 MERCHANT_PASSWORD_NOT_SET.
- [ ] Step 5.7: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): implement POST /auth/merchant/signin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 6: Implement POST /auth/merchant/password (set/change) โ
This handler covers two cases: setting via resetToken (unauthenticated) and changing while authenticated (bearer auth โ defer to admin-style; v1 only the resetToken path is used).
Files:
Modify:
services/api/auth/src/routes/merchantAuth.js[ ] Step 6.1: Replace the password stub
Replace router.post('/password', ...) with:
// โโ POST /auth/merchant/password โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Set merchant portal password using a one-time reset token (unauthenticated).
router.post('/password', async (req, res, next) => {
try {
const { password, resetToken } = SetPasswordBody.parse(req.body)
const db = getFirestore()
if (!resetToken) {
return res.status(400).json({ error: 'MISSING_TOKEN', message: 'resetToken is required' })
}
const tokenRef = db.collection('merchantPasswordResetTokens').doc(resetToken)
const tokenSnap = await tokenRef.get()
if (!tokenSnap.exists) {
return res.status(404).json({ error: 'INVALID_TOKEN', message: 'Invalid or expired token' })
}
const tokenData = tokenSnap.data()
if (tokenData.used) {
return res.status(409).json({ error: 'TOKEN_USED', message: 'Token has already been used' })
}
if (Date.now() - tokenData.createdAt.toMillis() > 24 * 60 * 60 * 1000) {
return res.status(410).json({ error: 'TOKEN_EXPIRED', message: 'Token has expired' })
}
const { hash, salt } = await hashPassword(password)
const profileRef = db.collection('merchantProfiles').doc(tokenData.userId)
await profileRef.set(
{
merchantPasswordHash: hash,
merchantPasswordSalt: salt,
merchantPasswordResetRequired: false,
failedLoginAttempts: 0,
},
{ merge: true }
)
await tokenRef.update({ used: true, usedAt: FieldValue.serverTimestamp() })
await db.collection('adminActions').add({
action: 'setMerchantPassword',
targetUserId: tokenData.userId,
performedBy: 'self',
performedAt: FieldValue.serverTimestamp(),
via: tokenData.setupKind || 'reset',
})
return res.json({ success: true, userId: tokenData.userId, email: tokenData.email })
} catch (err) {
next(err)
}
})- [ ] Step 6.2: Verify syntax
cd services/api/auth && node --check src/routes/merchantAuth.jsExpected: no output.
- [ ] Step 6.3: Smoke โ manually create a token doc and call /password
In Firestore Console, manually add a doc to merchantPasswordResetTokens/{token=foo123} with:
{
userId: '<existing merchant uid>',
email: '<merchant email>',
setupKind: 'reset',
firebaseOobCode: null,
createdAt: <recent serverTimestamp>,
used: false,
}Call:
curl -i -X POST http://localhost:8084/auth/merchant/password \
-H "Content-Type: application/json" \
-d '{"resetToken":"foo123","password":"NewPortalPass123"}'Expected: 200 with { success: true, userId, email }. Verify merchantProfiles/{uid} now has merchantPasswordHash/merchantPasswordSalt/failedLoginAttempts: 0. Verify token doc has used: true.
- [ ] Step 6.4: Smoke โ re-using the same token returns 409
Call the same curl again. Expected: 409 TOKEN_USED.
- [ ] Step 6.5: Smoke โ invalid token returns 404
curl -i -X POST http://localhost:8084/auth/merchant/password \
-H "Content-Type: application/json" \
-d '{"resetToken":"does-not-exist","password":"NewPortalPass123"}'Expected: 404 INVALID_TOKEN.
- [ ] Step 6.6: Smoke โ too-short password returns 400
curl -i -X POST http://localhost:8084/auth/merchant/password \
-H "Content-Type: application/json" \
-d '{"resetToken":"foo123","password":"short"}'Expected: 400 (zod validation). Body contains the validation error.
- [ ] Step 6.7: Now sign in with the password you just set (closes the loop)
curl -i -X POST http://localhost:8084/auth/merchant/signin \
-H "Content-Type: application/json" \
-d '{"email":"<merchant-email>","password":"NewPortalPass123"}'Expected: 200 with a custom token.
- [ ] Step 6.8: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): implement POST /auth/merchant/password (set via reset token)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 7: Implement POST /auth/merchant/password/reset โ
Files:
Modify:
services/api/auth/src/routes/merchantAuth.js[ ] Step 7.1: Replace the reset stub
Replace router.post('/password/reset', ...) with:
// โโ POST /auth/merchant/password/reset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Request a merchant portal password reset email. Always safe-responds to
// prevent email enumeration.
router.post('/password/reset', async (req, res, next) => {
try {
const { email } = ResetRequestBody.parse(req.body)
const auth = getAuth()
const db = getFirestore()
let user
try {
user = await auth.getUserByEmail(email.trim())
} catch {
return res.json(SAFE_RESPONSE)
}
if ((user.customClaims || {}).role !== 'merchant') return res.json(SAFE_RESPONSE)
const profileSnap = await db.collection('merchantProfiles').doc(user.uid).get()
if (!profileSnap.exists) return res.json(SAFE_RESPONSE)
const resetToken = randomBytes(32).toString('hex')
await db.collection('merchantPasswordResetTokens').doc(resetToken).set({
userId: user.uid,
email: user.email,
setupKind: 'reset',
firebaseOobCode: null,
createdAt: FieldValue.serverTimestamp(),
used: false,
})
const isProd = process.env.GOOGLE_CLOUD_PROJECT === 'lantern-app-prod'
const baseUrl = isProd ? 'https://admin.ourlantern.app' : 'https://admin.dev.ourlantern.app'
const resetUrl = `${baseUrl}?mode=merchantReset&token=${resetToken}`
const resendKey = process.env.RESEND_API_KEY
if (resendKey) {
const emailResult = await sendMerchantPasswordResetEmail({
apiKey: resendKey, toEmail: user.email, resetLink: resetUrl,
})
if (!emailResult.success) {
console.error('Failed to send merchant password reset email:', emailResult.error)
}
} else {
console.warn('RESEND_API_KEY not configured โ merchant password reset email not sent')
}
await db.collection('adminActions').add({
action: 'requestMerchantPasswordReset', targetUserId: user.uid,
performedBy: 'self', performedAt: FieldValue.serverTimestamp(),
})
return res.json({
...SAFE_RESPONSE,
...(!isProd && { resetUrl, resetToken }),
})
} catch (err) {
next(err)
}
})- [ ] Step 7.2: Verify syntax + smoke
cd services/api/auth && node --check src/routes/merchantAuth.jsSmoke โ unknown email returns SAFE_RESPONSE (with no resetUrl since email doesn't exist):
curl -s -X POST http://localhost:8084/auth/merchant/password/reset \
-H "Content-Type: application/json" \
-d '{"email":"nobody@example.com"}'Expected: { "success": true, "message": "If your email is registered, ..." } with no resetUrl field.
- [ ] Step 7.3: Smoke โ admin email returns SAFE_RESPONSE (no token created)
curl -s -X POST http://localhost:8084/auth/merchant/password/reset \
-H "Content-Type: application/json" \
-d '{"email":"<admin-email>"}'Expected: same safe response, no resetUrl. Verify no doc was added to merchantPasswordResetTokens.
- [ ] Step 7.4: Smoke โ merchant email returns SAFE_RESPONSE + creates token (and in dev, returns resetUrl)
curl -s -X POST http://localhost:8084/auth/merchant/password/reset \
-H "Content-Type: application/json" \
-d '{"email":"<merchant-email>"}'Expected: safe response + (in dev only) resetUrl: "https://admin.dev.ourlantern.app?mode=merchantReset&token=..." and resetToken. Verify the token doc exists in merchantPasswordResetTokens with setupKind: 'reset'. Verify a Resend email is logged (or attempted).
- [ ] Step 7.5: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): implement POST /auth/merchant/password/reset
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 8: Implement GET /auth/merchant/password/reset/:token โ
Files:
Modify:
services/api/auth/src/routes/merchantAuth.js[ ] Step 8.1: Replace the verify stub
Replace router.get('/password/reset/:token', ...) with:
// โโ GET /auth/merchant/password/reset/:token โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
router.get('/password/reset/:token', async (req, res, next) => {
try {
const db = getFirestore()
const snap = await db.collection('merchantPasswordResetTokens').doc(req.params.token).get()
if (!snap.exists) {
return res.status(404).json({ error: 'INVALID_TOKEN', message: 'Invalid or expired token' })
}
const data = snap.data()
if (data.used) {
return res.status(410).json({ error: 'TOKEN_USED', message: 'Token has already been used' })
}
if (Date.now() - data.createdAt.toMillis() > 24 * 60 * 60 * 1000) {
return res.status(410).json({ error: 'TOKEN_EXPIRED', message: 'Token has expired' })
}
return res.json({
valid: true,
email: data.email,
setupKind: data.setupKind || 'reset',
firebaseOobCode: data.firebaseOobCode || null,
})
} catch (err) {
next(err)
}
})- [ ] Step 8.2: Verify syntax + smoke
cd services/api/auth && node --check src/routes/merchantAuth.jsSmoke โ valid token (use one from Task 7.4 output):
curl -s http://localhost:8084/auth/merchant/password/reset/<token>Expected: { valid: true, email, setupKind: 'reset', firebaseOobCode: null }.
- [ ] Step 8.3: Smoke โ invalid token returns 404
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/password/reset/does-not-existExpected: 404.
- [ ] Step 8.4: Smoke โ used token returns 410
Use a token already redeemed in Task 6 (foo123 if you didn't delete it):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/password/reset/foo123Expected: 410.
- [ ] Step 8.5: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): implement GET /auth/merchant/password/reset/:token
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 9: Implement GET /auth/merchant/status โ
Files:
Modify:
services/api/auth/src/routes/merchantAuth.js[ ] Step 9.1: Replace the status stub
Replace router.get('/status', ...) with:
// โโ GET /auth/merchant/status โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
router.get('/status', async (req, res, next) => {
try {
const { email } = req.query
if (!email) return res.status(400).json({ error: 'MISSING_EMAIL', message: 'email query param required' })
const auth = getAuth()
const db = getFirestore()
let user
try {
user = await auth.getUserByEmail(String(email).trim())
} catch {
return res.json({ isMerchant: false })
}
if ((user.customClaims || {}).role !== 'merchant') return res.json({ isMerchant: false })
const profileSnap = await db.collection('merchantProfiles').doc(user.uid).get()
if (!profileSnap.exists) return res.json({ isMerchant: true, passwordSet: false })
const profile = profileSnap.data()
return res.json({
isMerchant: true,
passwordSet: !!(profile.merchantPasswordHash && profile.merchantPasswordSalt),
resetRequired: profile.merchantPasswordResetRequired || false,
})
} catch (err) {
next(err)
}
})- [ ] Step 9.2: Verify syntax + smoke
cd services/api/auth && node --check src/routes/merchantAuth.jsSmoke โ five branches:
# missing email
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/status
# unknown email
curl -s "http://localhost:8084/auth/merchant/status?email=nobody@example.com"
# admin email
curl -s "http://localhost:8084/auth/merchant/status?email=<admin-email>"
# merchant with password set
curl -s "http://localhost:8084/auth/merchant/status?email=<merchant-with-pw>"
# merchant without password set
curl -s "http://localhost:8084/auth/merchant/status?email=<merchant-no-pw>"Expected:
400
{ isMerchant: false }{ isMerchant: false }{ isMerchant: true, passwordSet: true, resetRequired: false }{ isMerchant: true, passwordSet: false }[ ] Step 9.3: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantAuth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): implement GET /auth/merchant/status
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 10: Update POST /auth/admin/users/merchant (used by /merchants/new) to issue merchant tokens โ
This endpoint creates a brand-new merchant business + first user atomically. Switch its email-link logic to the new merchant-token flow.
Files:
Modify:
services/api/auth/src/routes/adminUsers.js[ ] Step 10.1: Find the existing
generatePasswordResetLinkcall
grep -n "generatePasswordResetLink" /home/mechelle/repos/lantern_app/services/api/auth/src/routes/adminUsers.jsThe merchant create endpoint is around router.post('/merchant', ...) (find the handler โ line ~269).
- [ ] Step 10.2: Replace the link generation + invite email block
Inside that handler, find the block that runs after a successful Firestore commit, where it currently does roughly:
const resetLink = await auth.generatePasswordResetLink(email, { url: ... })
// ... sendMerchantInviteEmail(... resetLink ...)Replace that block with the new flow:
// โโ Generate Firebase oobCode (so the merchant can also set their Lantern
// passphrase if they want to use the consumer app), embed it in a
// merchantPasswordResetTokens doc, and send the merchant-token email.
let firebaseOobCode = null
let resetLink = null
let emailSent = false
const isProd = process.env.GOOGLE_CLOUD_PROJECT === 'lantern-app-prod'
const baseUrl = isProd ? 'https://admin.ourlantern.app' : 'https://admin.dev.ourlantern.app'
if (!isPromotion) {
// For fresh users, capture the oobCode by parsing the Firebase reset link.
// generatePasswordResetLink returns a URL like ".../?mode=resetPassword&oobCode=XXX&continueUrl=..."
const fbResetLink = await auth.generatePasswordResetLink(email, { url: baseUrl })
const url = new URL(fbResetLink)
firebaseOobCode = url.searchParams.get('oobCode')
}
const merchantToken = randomBytes(32).toString('hex')
await db.collection('merchantPasswordResetTokens').doc(merchantToken).set({
userId: targetUser.uid,
email,
setupKind: isPromotion ? 'promotion' : 'fresh',
firebaseOobCode,
createdAt: FieldValue.serverTimestamp(),
used: false,
})
resetLink = `${baseUrl}?mode=merchantReset&token=${merchantToken}`
if (body.sendInvite !== false) {
let inviterName = 'The Lantern Team'
try {
const callerProfile = await db.collection('adminProfiles').doc(callerUid).get()
if (callerProfile.exists) inviterName = callerProfile.data().displayName || inviterName
} catch { /* default */ }
const businessName = body.businessName || ''
const emailResult = await sendMerchantInviteEmail({
apiKey: process.env.RESEND_API_KEY,
toEmail: email,
displayName: contactName,
businessName,
resetLink,
inviterName,
setupKind: isPromotion ? 'promotion' : 'fresh',
})
emailSent = emailResult.success
}(Notes on the substitution:
The
randomBytesimport already exists inadminUsers.js? If not, addimport { randomBytes } from 'crypto'at the top of the file.isPromotion,targetUser,email,contactName,callerUidare local variables in the existing handler. Match the existing names exactly.The response body should still include
resetLinkandemailSentfor the admin UI; nothing else needs to change.)[ ] Step 10.3: Verify syntax
cd services/api/auth && node --check src/routes/adminUsers.jsExpected: no output.
- [ ] Step 10.4: Smoke โ create a brand-new merchant business
Through the admin UI at /merchants/new (or via curl directly to POST /auth/admin/users/merchant), create a fresh merchant. Verify in Firestore:
merchantPasswordResetTokens/{token}exists withsetupKind: 'fresh'and a non-nullfirebaseOobCodeThe Resend email log shows the new merchant-token URL (
?mode=merchantReset&token=...)[ ] Step 10.5: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/adminUsers.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): /merchants/new flow issues merchantPasswordResetTokens
Replaces the legacy generatePasswordResetLink invite path with a
merchant-token URL. The Firebase oobCode is captured and embedded in the
token doc so the setup screen can run step 1 (set Lantern passphrase)
client-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 11: Update POST /auth/admin/merchants/:merchantId/users to issue merchant tokens โ
The endpoint shipped in the previous PR. Same change as Task 10, scoped to this endpoint.
Files:
Modify:
services/api/auth/src/routes/adminMerchants.js[ ] Step 11.1: Find the invite-email block
grep -n "generatePasswordResetLink\|sendMerchantInviteEmail" /home/mechelle/repos/lantern_app/services/api/auth/src/routes/adminMerchants.js- [ ] Step 11.2: Replace with the new token flow
Inside the POST /:merchantId/users handler, replace the invite block (currently calls auth.generatePasswordResetLink(email, ...) then sendMerchantInviteEmail) with the same code as Task 10.2. The local variables (isPromotion, targetUser, email, contactName, callerUid) are present in this handler too. The businessName here comes from merchantSnap.data().businessName (which is already loaded earlier in the handler), not from body.businessName.
Concretely, replace the existing block with:
let firebaseOobCode = null
let resetLink = null
let emailSent = false
const isProd = process.env.GOOGLE_CLOUD_PROJECT === 'lantern-app-prod'
const baseUrl = isProd ? 'https://admin.ourlantern.app' : 'https://admin.dev.ourlantern.app'
if (!isPromotion) {
const fbResetLink = await auth.generatePasswordResetLink(email, { url: baseUrl })
const url = new URL(fbResetLink)
firebaseOobCode = url.searchParams.get('oobCode')
}
const merchantToken = randomBytes(32).toString('hex')
await db.collection('merchantPasswordResetTokens').doc(merchantToken).set({
userId: targetUser.uid,
email,
setupKind: isPromotion ? 'promotion' : 'fresh',
firebaseOobCode,
createdAt: FieldValue.serverTimestamp(),
used: false,
})
resetLink = `${baseUrl}?mode=merchantReset&token=${merchantToken}`
if (body.sendInvite !== false) {
let inviterName = 'The Lantern Team'
try {
const callerProfile = await db.collection('adminProfiles').doc(callerUid).get()
if (callerProfile.exists) inviterName = callerProfile.data().displayName || inviterName
} catch { /* default */ }
const businessName = merchantSnap.data().businessName || ''
const emailResult = await sendMerchantInviteEmail({
apiKey: process.env.RESEND_API_KEY,
toEmail: email,
displayName: contactName,
businessName,
resetLink,
inviterName,
setupKind: isPromotion ? 'promotion' : 'fresh',
})
emailSent = emailResult.success
}Add import { randomBytes } from 'crypto' at the top of adminMerchants.js if not already present.
- [ ] Step 11.3: Verify syntax
cd services/api/auth && node --check src/routes/adminMerchants.jsExpected: no output.
- [ ] Step 11.4: Smoke โ fresh user
Through the admin UI's Create Merchant User tab, create a brand-new merchant user with a never-before-used email. Verify in Firestore:
merchantPasswordResetTokens/{token}withsetupKind: 'fresh', non-nullfirebaseOobCodeEmail sent contains the merchant-token URL
[ ] Step 11.5: Smoke โ promoted user
Through the same UI, create a merchant user with the email of an existing Lantern user. Verify:
merchantPasswordResetTokens/{token}withsetupKind: 'promotion', nullfirebaseOobCodeEmail subject reflects the "added to" copy
[ ] Step 11.6: Commit
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/adminMerchants.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): Create Merchant User flow issues merchantPasswordResetTokens
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 12: Frontend API client โ apps/admin/src/lib/authApi.js โ
Files:
Modify:
apps/admin/src/lib/authApi.js[ ] Step 12.1: Find a good insertion point
Look at the bottom of authApi.js โ past attachUserToMerchant (added in the previous PR). Insert the merchant auth client methods after it.
- [ ] Step 12.2: Add the five client methods
Append to authApi.js:
// =============================================================================
// Merchant Auth (mirror of admin auth)
// =============================================================================
/**
* POST /auth/merchant/signin โ sign in with merchant portal password.
* Returns: { customToken, userId, email, displayName, merchantId }
*/
export async function signInMerchant({ email, password }) {
const response = await fetch(`${API_BASE_URL}/auth/merchant/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
return parseResponse(response)
}
/**
* POST /auth/merchant/password/reset โ request a reset email. Always safe-responds.
*/
export async function requestMerchantPasswordReset(email) {
const response = await fetch(`${API_BASE_URL}/auth/merchant/password/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return parseResponse(response)
}
/**
* GET /auth/merchant/password/reset/:token โ verify a reset/setup token.
* Returns: { valid, email, setupKind, firebaseOobCode }
*/
export async function verifyMerchantResetToken(token) {
const response = await fetch(
`${API_BASE_URL}/auth/merchant/password/reset/${encodeURIComponent(token)}`
)
return parseResponse(response)
}
/**
* POST /auth/merchant/password โ set merchant portal password using a reset token.
*/
export async function setMerchantPassword({ password, resetToken }) {
const response = await fetch(`${API_BASE_URL}/auth/merchant/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, resetToken }),
})
return parseResponse(response)
}
/**
* GET /auth/merchant/status โ check merchant + password-set state.
*/
export async function checkMerchantPasswordStatus(email) {
const response = await fetch(
`${API_BASE_URL}/auth/merchant/status?email=${encodeURIComponent(email)}`
)
return parseResponse(response)
}(If the existing admin auth helpers in this file use authRequest instead of plain fetch, match their style. The merchant auth endpoints are unauthenticated except password which uses resetToken in the body, so plain fetch is correct here. Cross-check by looking at how signInAdmin/requestAdminPasswordReset are written in the same file.)
- [ ] Step 12.3: Verify build
npm run build -w lantern-admin 2>&1 | tail -20Expected: build completes successfully.
- [ ] Step 12.4: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/lib/authApi.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): add merchant auth API client methods
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 13: Frontend firebase.js re-exports โ
Files:
Modify:
apps/admin/src/firebase.js[ ] Step 13.1: Find the admin auth re-exports
grep -n "signInWithAdminPassword\|requestAdminPasswordReset\|checkAdminPasswordStatus\|verifyAdminResetToken\|setAdminPassword" /home/mechelle/repos/lantern_app/apps/admin/src/firebase.jsThese functions are exported as wrappers around the auth API client; mirror them for merchant.
- [ ] Step 13.2: Add the merchant re-exports
Near the admin re-exports, add:
// =============================================================================
// Merchant Auth (mirror of admin auth โ Issue #245 pattern)
// =============================================================================
/**
* Sign in with separate merchant portal password.
* Mirrors signInWithAdminPassword.
*/
export async function signInWithMerchantPassword(email, password) {
try {
const { signInMerchant } = await import('./lib/authApi')
const result = await signInMerchant({ email, password })
const { customToken, userId, displayName, merchantId } = result
const { signInWithCustomToken } = await import('firebase/auth')
const userCredential = await signInWithCustomToken(auth, customToken)
return {
user: userCredential.user,
userId,
displayName,
merchantId,
authMethod: 'merchantPassword',
}
} catch (error) {
console.error('Merchant sign-in error:', error)
if (error.status === 428) {
if (error.details?.error === 'MERCHANT_PASSWORD_RESET_REQUIRED') {
throw new Error('MERCHANT_PASSWORD_RESET_REQUIRED')
}
throw new Error('MERCHANT_PASSWORD_NOT_SET')
}
if (error.status === 401) {
throw new Error('Incorrect email or password.')
}
throw error
}
}
export async function requestMerchantPasswordReset(email) {
const api = await import('./lib/authApi')
return api.requestMerchantPasswordReset(email)
}
export async function verifyMerchantResetToken(token) {
const api = await import('./lib/authApi')
return api.verifyMerchantResetToken(token)
}
export async function setMerchantPassword({ password, resetToken }) {
const api = await import('./lib/authApi')
return api.setMerchantPassword({ password, resetToken })
}
export async function checkMerchantPasswordStatus(email) {
const api = await import('./lib/authApi')
return api.checkMerchantPasswordStatus(email)
}(auth is the Firebase Auth instance already exported/imported in firebase.js โ match the existing pattern from signInWithAdminPassword.)
- [ ] Step 13.3: Verify build
npm run build -w lantern-admin 2>&1 | tail -10Expected: build succeeds.
- [ ] Step 13.4: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/firebase.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): re-export merchant auth helpers from firebase.js
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 14: Build the <SetMerchantPassword> component โ
Files:
Create:
apps/admin/src/components/SetMerchantPassword.jsx[ ] Step 14.1: Inspect
SetAdminPassword.jsxto mirror its structure
cat /home/mechelle/repos/lantern_app/apps/admin/src/components/SetAdminPassword.jsxRead the file end-to-end. The merchant version copies the same shell (input styles, validation, error handling) but (a) calls verifyMerchantResetToken / setMerchantPassword instead of admin equivalents, (b) handles setupKind to render either one or two steps.
- [ ] Step 14.2: Write the component
Create apps/admin/src/components/SetMerchantPassword.jsx:
import React, { useState, useEffect } from 'react'
import { confirmPasswordReset } from 'firebase/auth'
import {
auth,
verifyMerchantResetToken,
setMerchantPassword,
signInWithMerchantPassword,
} from '../firebase'
import LanternLogo from './LanternLogo'
const inputStyle = {
width: '100%',
padding: '14px 16px',
borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
fontSize: '1rem',
color: 'var(--text)',
outline: 'none',
transition: 'border-color 0.2s, background-color 0.2s',
}
const inputFocusStyle = {
borderColor: 'var(--accent-500)',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
}
/**
* Set Merchant Password Screen
*
* Mirrors SetAdminPassword. Handles three setupKind values:
* - 'fresh': two steps โ Lantern passphrase + merchant portal password
* - 'promotion': one step โ only merchant portal password
* - 'reset': one step โ only merchant portal password (forgot-password flow)
*
* URL parameters expected:
* - mode: 'merchantReset'
* - token: the merchant-specific reset token
*/
export default function SetMerchantPassword({ resetToken, onComplete, onBackToLogin }) {
const [tokenData, setTokenData] = useState(null) // { email, setupKind, firebaseOobCode }
const [loading, setLoading] = useState(true)
const [tokenError, setTokenError] = useState(null)
// Step 1 state (Lantern passphrase, only for 'fresh')
const [step, setStep] = useState(1) // starts at 1; promotion/reset jump to 2 on mount
const [lanternPassphrase, setLanternPassphrase] = useState('')
const [lanternPassphraseConfirm, setLanternPassphraseConfirm] = useState('')
// Step 2 state (merchant portal password)
const [portalPassword, setPortalPassword] = useState('')
const [portalPasswordConfirm, setPortalPasswordConfirm] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [focusedInput, setFocusedInput] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
if (!resetToken) {
setTokenError('Invalid or missing setup link. Please request a new one.')
setLoading(false)
return
}
try {
const result = await verifyMerchantResetToken(resetToken)
if (cancelled) return
setTokenData(result)
// For promotion or reset, jump straight to step 2.
if (result.setupKind !== 'fresh' || !result.firebaseOobCode) {
setStep(2)
}
} catch (err) {
if (cancelled) return
if (err.status === 410) setTokenError('This link has expired or already been used.')
else if (err.status === 404) setTokenError('This link is invalid.')
else setTokenError('Unable to verify this link. Please request a new one.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [resetToken])
const getInputStyle = (name) => ({
...inputStyle,
...(focusedInput === name ? inputFocusStyle : {}),
})
const validateLantern = () => {
if (lanternPassphrase.length < 8) return 'Passphrase must be at least 8 characters'
if (lanternPassphrase !== lanternPassphraseConfirm) return 'Passphrases do not match'
return null
}
const validatePortal = () => {
if (portalPassword.length < 8) return 'Password must be at least 8 characters'
if (portalPassword !== portalPasswordConfirm) return 'Passwords do not match'
return null
}
const handleStep1 = async (e) => {
e.preventDefault()
const v = validateLantern()
if (v) { setError(v); return }
setSubmitting(true)
setError(null)
try {
await confirmPasswordReset(auth, tokenData.firebaseOobCode, lanternPassphrase)
setStep(2)
} catch (err) {
// If the oobCode was already consumed (resumed setup), skip step 1 silently.
if (err.code === 'auth/expired-action-code' || err.code === 'auth/invalid-action-code') {
setStep(2)
} else {
setError(err.message || 'Failed to set Lantern passphrase')
}
} finally {
setSubmitting(false)
}
}
const handleStep2 = async (e) => {
e.preventDefault()
const v = validatePortal()
if (v) { setError(v); return }
setSubmitting(true)
setError(null)
try {
await setMerchantPassword({ password: portalPassword, resetToken })
// Auto sign-in
await signInWithMerchantPassword(tokenData.email, portalPassword)
onComplete?.()
} catch (err) {
setError(err.message || 'Failed to set merchant portal password')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="screen">
<div className="screen-content">
<LanternLogo size={64} />
<p>Verifying your setup linkโฆ</p>
</div>
</div>
)
}
if (tokenError) {
return (
<div className="screen">
<div className="screen-content">
<LanternLogo size={64} />
<h1>Setup link error</h1>
<p className="subtitle">{tokenError}</p>
<button className="btn btn-primary" onClick={onBackToLogin}>Back to sign in</button>
</div>
</div>
)
}
return (
<div className="screen">
<div className="screen-content">
<LanternLogo size={64} />
<h1>{step === 1 ? 'Set your Lantern passphrase' : 'Set your merchant portal password'}</h1>
<p className="subtitle">
{step === 1 && tokenData?.setupKind === 'fresh' &&
'Step 1 of 2 โ this passphrase encrypts your personal data in the Lantern app.'}
{step === 2 && tokenData?.setupKind === 'fresh' &&
'Step 2 of 2 โ this password is for signing into the merchant portal.'}
{step === 2 && tokenData?.setupKind === 'promotion' &&
'Set the password you\'ll use to sign into the merchant portal. Your Lantern app passphrase is unchanged.'}
{step === 2 && tokenData?.setupKind === 'reset' &&
'Choose a new password for signing into the merchant portal.'}
</p>
{error && <div className="form-error">{error}</div>}
{step === 1 && (
<form onSubmit={handleStep1} style={{ width: '100%', maxWidth: '320px', margin: '0 auto' }}>
<div style={{ marginBottom: 'var(--space-3)' }}>
<input
type="password" id="lp" placeholder="Lantern passphrase"
value={lanternPassphrase}
onChange={(e) => setLanternPassphrase(e.target.value)}
style={getInputStyle('lp')}
onFocus={() => setFocusedInput('lp')}
onBlur={() => setFocusedInput(null)}
required autoFocus
/>
</div>
<div style={{ marginBottom: 'var(--space-4)' }}>
<input
type="password" id="lpc" placeholder="Confirm passphrase"
value={lanternPassphraseConfirm}
onChange={(e) => setLanternPassphraseConfirm(e.target.value)}
style={getInputStyle('lpc')}
onFocus={() => setFocusedInput('lpc')}
onBlur={() => setFocusedInput(null)}
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Savingโฆ' : 'Continue'}
</button>
</form>
)}
{step === 2 && (
<form onSubmit={handleStep2} style={{ width: '100%', maxWidth: '320px', margin: '0 auto' }}>
<div style={{ marginBottom: 'var(--space-3)' }}>
<input
type="password" id="pp" placeholder="Merchant portal password"
value={portalPassword}
onChange={(e) => setPortalPassword(e.target.value)}
style={getInputStyle('pp')}
onFocus={() => setFocusedInput('pp')}
onBlur={() => setFocusedInput(null)}
required autoFocus
/>
</div>
<div style={{ marginBottom: 'var(--space-4)' }}>
<input
type="password" id="ppc" placeholder="Confirm password"
value={portalPasswordConfirm}
onChange={(e) => setPortalPasswordConfirm(e.target.value)}
style={getInputStyle('ppc')}
onFocus={() => setFocusedInput('ppc')}
onBlur={() => setFocusedInput(null)}
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Savingโฆ' : 'Finish setup'}
</button>
</form>
)}
</div>
</div>
)
}- [ ] Step 14.3: Verify the imports + build
firebase.js must export auth, verifyMerchantResetToken, setMerchantPassword, signInWithMerchantPassword. Tasks 12 + 13 added the latter three; auth is already exported from the existing initialization.
npm run build -w lantern-admin 2>&1 | tail -10Expected: build succeeds.
- [ ] Step 14.4: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/components/SetMerchantPassword.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): add SetMerchantPassword component
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 15: Component tests for SetMerchantPassword โ
Files:
Create:
apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx[ ] Step 15.1: Write the test file
Create apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import SetMerchantPassword from '../SetMerchantPassword'
vi.mock('../../firebase', () => ({
auth: {},
verifyMerchantResetToken: vi.fn(),
setMerchantPassword: vi.fn(),
signInWithMerchantPassword: vi.fn(),
}))
vi.mock('firebase/auth', () => ({
confirmPasswordReset: vi.fn(),
}))
import {
verifyMerchantResetToken,
setMerchantPassword,
signInWithMerchantPassword,
} from '../../firebase'
import { confirmPasswordReset } from 'firebase/auth'
describe('SetMerchantPassword', () => {
beforeEach(() => vi.clearAllMocks())
it('shows step 1 (passphrase) for fresh setup', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'fresh', firebaseOobCode: 'OOB',
})
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
expect(await screen.findByPlaceholderText(/Lantern passphrase/i)).toBeInTheDocument()
expect(screen.getByText(/Step 1 of 2/i)).toBeInTheDocument()
})
it('skips step 1 for promotion (only step 2 shown)', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'promotion', firebaseOobCode: null,
})
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
expect(await screen.findByPlaceholderText(/Merchant portal password/i)).toBeInTheDocument()
expect(screen.queryByPlaceholderText(/Lantern passphrase/i)).not.toBeInTheDocument()
})
it('skips step 1 for reset (only step 2 shown)', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'reset', firebaseOobCode: null,
})
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
expect(await screen.findByPlaceholderText(/Merchant portal password/i)).toBeInTheDocument()
})
it('shows token error for invalid token (404)', async () => {
verifyMerchantResetToken.mockRejectedValue({ status: 404 })
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
expect(await screen.findByText(/This link is invalid/i)).toBeInTheDocument()
})
it('shows token error for expired token (410)', async () => {
verifyMerchantResetToken.mockRejectedValue({ status: 410 })
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
expect(await screen.findByText(/expired or already been used/i)).toBeInTheDocument()
})
it('runs confirmPasswordReset on step 1 then advances to step 2', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'fresh', firebaseOobCode: 'OOB',
})
confirmPasswordReset.mockResolvedValue(undefined)
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
const lp = await screen.findByPlaceholderText(/Lantern passphrase/i)
const lpc = screen.getByPlaceholderText(/Confirm passphrase/i)
fireEvent.change(lp, { target: { value: 'lanternpass1' } })
fireEvent.change(lpc, { target: { value: 'lanternpass1' } })
fireEvent.click(screen.getByRole('button', { name: /Continue/i }))
await waitFor(() => {
expect(confirmPasswordReset).toHaveBeenCalledWith({}, 'OOB', 'lanternpass1')
})
expect(await screen.findByPlaceholderText(/Merchant portal password/i)).toBeInTheDocument()
})
it('advances past step 1 silently if oobCode is already consumed', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'fresh', firebaseOobCode: 'OOB',
})
confirmPasswordReset.mockRejectedValue({ code: 'auth/expired-action-code' })
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
fireEvent.change(await screen.findByPlaceholderText(/Lantern passphrase/i), { target: { value: 'pass1234' } })
fireEvent.change(screen.getByPlaceholderText(/Confirm passphrase/i), { target: { value: 'pass1234' } })
fireEvent.click(screen.getByRole('button', { name: /Continue/i }))
expect(await screen.findByPlaceholderText(/Merchant portal password/i)).toBeInTheDocument()
})
it('submits step 2 and calls onComplete', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'reset', firebaseOobCode: null,
})
setMerchantPassword.mockResolvedValue({ success: true })
signInWithMerchantPassword.mockResolvedValue({ userId: 'u1' })
const onComplete = vi.fn()
render(<SetMerchantPassword resetToken="t" onComplete={onComplete} onBackToLogin={() => {}} />)
const pp = await screen.findByPlaceholderText(/Merchant portal password/i)
const ppc = screen.getByPlaceholderText(/Confirm password/i)
fireEvent.change(pp, { target: { value: 'portalpass1' } })
fireEvent.change(ppc, { target: { value: 'portalpass1' } })
fireEvent.click(screen.getByRole('button', { name: /Finish setup/i }))
await waitFor(() => {
expect(setMerchantPassword).toHaveBeenCalledWith({ password: 'portalpass1', resetToken: 't' })
})
expect(signInWithMerchantPassword).toHaveBeenCalledWith('m@x.com', 'portalpass1')
expect(onComplete).toHaveBeenCalled()
})
it('shows error when step 2 backend fails', async () => {
verifyMerchantResetToken.mockResolvedValue({
valid: true, email: 'm@x.com', setupKind: 'reset', firebaseOobCode: null,
})
setMerchantPassword.mockRejectedValue(new Error('TOKEN_USED'))
render(<SetMerchantPassword resetToken="t" onComplete={() => {}} onBackToLogin={() => {}} />)
fireEvent.change(await screen.findByPlaceholderText(/Merchant portal password/i), { target: { value: 'portalpass1' } })
fireEvent.change(screen.getByPlaceholderText(/Confirm password/i), { target: { value: 'portalpass1' } })
fireEvent.click(screen.getByRole('button', { name: /Finish setup/i }))
expect(await screen.findByText(/TOKEN_USED/)).toBeInTheDocument()
})
})- [ ] Step 15.2: Run the tests
npm test -w lantern-admin -- --run SetMerchantPasswordExpected: all 9 tests pass.
- [ ] Step 15.3: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx
git -C /home/mechelle/repos/lantern_app commit -m "test(admin): SetMerchantPassword component tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 16: Wire mode=merchantReset into App.jsx + three-tier sign-in fall-through โ
Files:
Modify:
apps/admin/src/App.jsx[ ] Step 16.1: Add the
merchantResetmode handler
Find the existing adminReset handler (around App.jsx:170). Below it, add:
import SetMerchantPassword from './components/SetMerchantPassword'
// ... inside the component, parallel to the existing emailActionParams pulls
// e.g., extract merchantToken alongside adminResetToken if not already present:
const merchantToken = searchParams.get('token')
// ...later, in the URL-mode dispatch block:
if (emailActionParams.mode === 'merchantReset' && merchantToken) {
return (
<SetMerchantPassword
resetToken={merchantToken}
onComplete={handlePasswordSetComplete}
onBackToLogin={handleBackToLogin}
/>
)
}(Match the existing adminReset block exactly โ the only differences are the mode value ('merchantReset' vs 'adminReset'), the token variable name, and the rendered component.)
- [ ] Step 16.2: Update
handleSignInWithEmailfor three-tier fall-through
Find handleSignInWithEmail around App.jsx:105. Replace the current admin-only logic with a three-tier chain. The existing structure:
try { await signInWithAdminPassword(...); return }
catch (adminErr) {
if (adminErr.message === 'ADMIN_PASSWORD_NOT_SET') { /* fall through to legacy */ }
else if (...incorrect-password guard...) { setError(...); throw }
else { ...; throw }
}
const signInResult = await signInWithEmail(...)Becomes:
const handleSignInWithEmail = async (email, passphrase) => {
try {
setError(null)
// Tier 1: admin password (uses adminPasswordHash via /auth/admin/signin)
try {
await signInWithAdminPassword(email, passphrase)
return
} catch (adminErr) {
if (adminErr.message === 'ADMIN_PASSWORD_NOT_SET') {
// Fall through to merchant tier
} else if (adminErr.message === 'ADMIN_PASSWORD_RESET_REQUIRED') {
setError('Your admin password needs to be reset. Use "Forgot password?" to reset it.')
throw adminErr
} else if (adminErr.message === 'Incorrect email or password.') {
// Could be: admin with wrong password, OR a merchant user (admin endpoint
// returns INVALID_CREDENTIALS for non-admin role). Try merchant next.
} else {
setError(adminErr.message || 'Sign-in failed. Please try again.')
throw adminErr
}
}
// Tier 2: merchant password (uses merchantPasswordHash via /auth/merchant/signin)
try {
await signInWithMerchantPassword(email, passphrase)
return
} catch (merchantErr) {
if (merchantErr.message === 'MERCHANT_PASSWORD_NOT_SET') {
// Fall through to legacy Firebase
} else if (merchantErr.message === 'MERCHANT_PASSWORD_RESET_REQUIRED') {
setError('Your merchant portal password needs to be reset. Use "Forgot password?" to reset it.')
throw merchantErr
} else if (merchantErr.message === 'Incorrect email or password.') {
// Final tier: legacy Firebase. If that also fails, surface "Incorrect".
} else {
setError(merchantErr.message || 'Sign-in failed. Please try again.')
throw merchantErr
}
}
// Tier 3: legacy Firebase Auth
const signInResult = await signInWithEmail(email, passphrase)
if (signInResult.detectedNow) {
setCorruptionDetectedAt(Date.now())
}
} catch (err) {
if (!err._handled) setError(err.message || 'Incorrect email or password.')
throw err
}
}Also add signInWithMerchantPassword to the imports near the top of App.jsx:
import {
signInWithEmail,
signInWithAdminPassword,
signInWithMerchantPassword,
// ...
} from './firebase'- [ ] Step 16.3: Verify build
npm run build -w lantern-admin 2>&1 | tail -10Expected: build succeeds.
- [ ] Step 16.4: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/App.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): merchantReset URL handler + three-tier sign-in fall-through
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 17: LoginScreen.handlePasswordReset โ call both reset endpoints in parallel โ
Files:
Modify:
apps/admin/src/components/LoginScreen.jsx[ ] Step 17.1: Add the merchant reset import
Top of file, alongside requestAdminPasswordReset:
import { requestAdminPasswordReset, requestMerchantPasswordReset } from '../firebase'(requestMerchantPasswordReset was added in Task 13. Match the import style.)
- [ ] Step 17.2: Update
handlePasswordResetto fan out
Replace the body of the existing handlePasswordReset:
const handlePasswordReset = async (e) => {
e.preventDefault()
if (!flowEmail) {
setResetError('Please enter your email address')
return
}
setResetError(null)
setResetSent(false)
try {
// Fire both โ each endpoint independently safe-responds. The user receives
// one email (or zero) depending on which role their account has.
const results = await Promise.allSettled([
requestAdminPasswordReset(flowEmail),
requestMerchantPasswordReset(flowEmail),
])
// If ALL settled rejected with errors that look like real failures
// (network etc.), surface a generic error. Otherwise show success.
const allRejected = results.every((r) => r.status === 'rejected')
if (allRejected) {
setResetError('Failed to send reset email. Please try again.')
} else {
setResetSent(true)
}
} catch (err) {
setResetError('Failed to send reset email. Please try again.')
}
}- [ ] Step 17.3: Verify build
npm run build -w lantern-admin 2>&1 | tail -10Expected: build succeeds.
- [ ] Step 17.4: Commit
git -C /home/mechelle/repos/lantern_app add apps/admin/src/components/LoginScreen.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): LoginScreen forgot-password fans out to admin + merchant reset
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"Task 18: Final validation โ
- [ ] Step 18.1: Run admin workspace validation
npm run validate -- --workspace apps/admin 2>&1 | tail -20Expected: build green. The pre-existing security audit failure (firebase-admin / resend / etc.) is unrelated.
- [ ] Step 18.2: Run auth-API tests
cd services/api/auth && npm run test:runExpected: all existing unit tests pass.
- [ ] Step 18.3: Run admin tests
npm test -w lantern-admin -- --runExpected: all tests pass (existing + new SetMerchantPassword tests).
- [ ] Step 18.4: Manual end-to-end smoke โ fresh merchant
- Start dev servers:
npm run dev -w services/api/auth+npm run dev -w lantern-admin. - As an admin, create a brand-new merchant user (
/usersโ "Create Merchant User"). Note: use a fresh email never seen before. - Confirm Firestore:
merchantPasswordResetTokens/{token}withsetupKind: 'fresh', non-nullfirebaseOobCode. - Open the link from the dev
resetUrlin the response (or from the Resend email). - Step 1: type a Lantern passphrase, confirm. Verify Firebase Auth shows the user has a password now.
- Step 2: type a merchant portal password, confirm. Verify
merchantProfiles/{uid}.merchantPasswordHashis populated,merchantPasswordSaltset,failedLoginAttempts: 0. - Auto sign-in lands you on
/merchants/{merchantId}(viamerchantOnlyredirect).
- [ ] Step 18.5: Manual end-to-end smoke โ promoted user
- Pick an existing Lantern user (or sign up a fresh one in the consumer app first).
- As an admin, create a merchant user with that email.
- Confirm token has
setupKind: 'promotion',firebaseOobCode: null. - Open the link โ setup screen shows ONLY step 2 ("Set your merchant portal password"). Step 1 is skipped.
- Set portal password, confirm. Verify in Lantern app: signing in there with the original Lantern passphrase still works (Firebase password unchanged โ encryption preserved).
- [ ] Step 18.6: Manual end-to-end smoke โ sign-in fall-through
| Password | Expected outcome | |
|---|---|---|
| Admin | admin password | success (admin tier) |
| Admin | wrong | "Incorrect email or password" (after all tiers fail) |
| Merchant | merchant portal password | success (merchant tier) |
| Merchant | wrong | "Incorrect email or password" |
| Lantern user only | passphrase | success (legacy tier) |
| Unknown email | anything | "Incorrect email or password" |
- [ ] Step 18.7: Manual end-to-end smoke โ forgot password isolation
- Sign in to Lantern app as a merchant. Note their encrypted data (e.g., a private profile field).
- Sign out. Click "Forgot password?" on admin portal LoginScreen. Submit merchant email.
- Receive merchant reset email (NOT admin). Click link โ setup screen step 2 only โ set new portal password.
- Sign in to merchant portal โ works.
- Sign in to Lantern app with original passphrase โ encrypted data still readable.
- [ ] Step 18.8: Open a PR
git -C /home/mechelle/repos/lantern_app push -u origin claude/merchant-integration
gh pr create --title "feat: merchant integration โ create/attach flows + auth decoupling" \
--body "$(cat <<'EOF'
## Summary
- Adds the merchant-user create/attach flow on `/users` (replaces the misplaced "Create Merchant" tab)
- Adds attach-to-merchant action in the user detail panel
- Decouples merchant portal sign-in from Firebase Auth (mirrors admin Issue #245 pattern), so merchants who are also Lantern users can reset their portal password without destroying their Lantern encryption keys
- Updates both create-merchant flows (`/merchants/new` and `Create Merchant User`) to issue the new merchant-token invite
## Specs
- `docs/superpowers/specs/2026-04-26-merchant-user-attach-flow-design.md`
- `docs/superpowers/specs/2026-04-27-merchant-auth-decoupling-design.md`
## Test plan
- [ ] Fresh merchant onboarding end-to-end (set passphrase + portal password, sign in)
- [ ] Promoted Lantern-user merchant onboarding (only portal password step; passphrase preserved)
- [ ] Sign-in matrix (admin / merchant / Lantern-only / wrong-password each surface correct outcome)
- [ ] Forgot-password isolation (reset portal password โ Lantern encryption canary still readable)
- [ ] Existing admin auth flows unaffected
EOF
)"Known Gap: Backend Integration Tests โ
The auth-API workspace lacks route-level test infrastructure (Express + Firebase emulator + supertest). This plan covers each new endpoint with manual smoke tests via curl (Tasks 5โ11) plus frontend component tests (Task 15). Setting up integration tests is its own project.
If full integration coverage is needed:
- Vitest config to spin up the Express app with
firebase-adminpointed at the emulator - A
services/api/auth/src/routes/__tests__/directory with test helpers for auth header injection - Per-endpoint tests covering the cases listed in Tasks 5โ11 + the matrix in Task 18.6
File a follow-up issue ("Add route-level integration tests for auth-api admin + merchant endpoints") and reference both specs in the description.
Out of Scope (per spec โ do NOT do in this plan) โ
apps/merchant/separate app split/auth/merchant/*namespace beyond auth (offers, etc.)- Multi-role users (a single uid being admin AND merchant)
- Lockout enforcement on
failedLoginAttempts - Multi-factor auth on merchant portal
- Email change flow for merchants
- Detach-from-merchant action
If the implementer thinks any of these are needed for v1, stop and re-confirm with the user.