Skip to content

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, status
  • src/services/passwordHash.service.js โ€” renamed from adminAuth.service.js (rename, no content change)

Frontend (apps/admin/):

  • src/components/SetMerchantPassword.jsx โ€” mirror of SetAdminPassword.jsx; handles setupKind: 'fresh' | 'promotion' | 'reset'
  • src/components/__tests__/SetMerchantPassword.test.jsx โ€” branch behavior + error states

Modified files โ€‹

Backend:

  • src/index.js โ€” mount merchantAuthRouter at /auth/merchant
  • src/routes/adminAuth.js โ€” update import path for renamed service file
  • src/routes/adminMerchants.js โ€” POST /:merchantId/users switches to new merchant-token flow
  • src/routes/adminUsers.js โ€” POST /merchant switches to new merchant-token flow
  • src/lib/email.js โ€” add sendMerchantPasswordResetEmail; revise sendMerchantInviteEmail URL pattern + setupKind argument

Frontend:

  • src/App.jsx โ€” add mode === 'merchantReset' URL handler; three-tier sign-in fall-through
  • src/components/LoginScreen.jsx โ€” handlePasswordReset calls both admin + merchant reset in parallel
  • src/firebase.js โ€” add merchant auth re-exports
  • src/lib/authApi.js โ€” add merchant auth API client methods

Untouched โ€‹

  • apps/web/ โ€” Lantern consumer app uses Firebase Auth as today
  • firestore.rules โ€” collections live under existing admin paths
  • apps/admin/src/components/SetAdminPassword.jsx, CreateAdminForm.jsx, etc. โ€” admin flow unchanged

Conventions to Follow โ€‹

  • Mirror admin patterns: every block in merchantAuth.js should have a counterpart in adminAuth.js. When in doubt, copy the admin version verbatim and substitute admin โ†’ merchant.
  • scrypt password hashing: services/passwordHash.service.js exports hashPassword(password, existingSalt = null) and verifyPassword(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 adminActions collection (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

bash
git status
git log --oneline -5
git branch --show-current

Expected: 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.js

  • Modify: services/api/auth/src/routes/adminAuth.js (update import)

  • [ ] Step 2.1: Rename the service file with git mv

bash
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:

js
import { hashPassword, verifyPassword } from '../services/adminAuth.service.js'

Replace with:

js
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:

js
/**
 * Admin auth helper โ€” password hashing with scrypt (same params as the CF).
 */

With:

js
/**
 * 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
bash
cd services/api/auth && npm run test:run

Expected: all existing tests pass (4/4 from merchantIds.test.js).

  • [ ] Step 2.5: Verify syntax
bash
cd services/api/auth && node --check src/routes/adminAuth.js

Expected: no output.

  • [ ] Step 2.6: Commit
bash
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 sendAdminPasswordResetEmail to mirror its structure

bash
grep -n "sendAdminPasswordResetEmail\b" /home/mechelle/repos/lantern_app/services/api/auth/src/lib/email.js | head -3

Open the file at the matched line and read the function's body. The new function will mirror it.

  • [ ] Step 3.2: Add sendMerchantPasswordResetEmail next to sendMerchantInviteEmail

After the existing sendMerchantInviteEmail export, add:

js
/**
 * 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 sendMerchantInviteEmail to accept setupKind

Find the existing export async function sendMerchantInviteEmail(...). Update the JSDoc + signature to accept a new setupKind argument and adjust the subject/copy:

js
/**
 * 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
bash
cd services/api/auth && node --check src/lib/email.js

Expected: no output.

  • [ ] Step 3.5: Commit
bash
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.js

  • Modify: services/api/auth/src/index.js

  • [ ] Step 4.1: Create the route file with stubs

Write services/api/auth/src/routes/merchantAuth.js:

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:

js
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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js
cd services/api/auth && node --check src/index.js

Expected: no output for both.

  • [ ] Step 4.4: Boot the server and hit each stub

In one terminal:

bash
npm run dev -w services/api/auth

In another:

bash
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
bash
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:

js
// โ”€โ”€ 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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js

Expected: 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):

bash
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
bash
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)
bash
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 merchantPasswordHash returns 428

Pick a merchant whose merchantProfiles doc has no merchantPasswordHash. Call signin:

bash
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
bash
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:

js
// โ”€โ”€ 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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js

Expected: 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:

js
{
  userId: '<existing merchant uid>',
  email: '<merchant email>',
  setupKind: 'reset',
  firebaseOobCode: null,
  createdAt: <recent serverTimestamp>,
  used: false,
}

Call:

bash
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
bash
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
bash
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)
bash
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
bash
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:

js
// โ”€โ”€ 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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js

Smoke โ€” unknown email returns SAFE_RESPONSE (with no resetUrl since email doesn't exist):

bash
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)
bash
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)
bash
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
bash
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:

js
// โ”€โ”€ 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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js

Smoke โ€” valid token (use one from Task 7.4 output):

bash
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
bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/password/reset/does-not-exist

Expected: 404.

  • [ ] Step 8.4: Smoke โ€” used token returns 410

Use a token already redeemed in Task 6 (foo123 if you didn't delete it):

bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8084/auth/merchant/password/reset/foo123

Expected: 410.

  • [ ] Step 8.5: Commit
bash
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:

js
// โ”€โ”€ 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
bash
cd services/api/auth && node --check src/routes/merchantAuth.js

Smoke โ€” five branches:

bash
# 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

bash
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 generatePasswordResetLink call

bash
grep -n "generatePasswordResetLink" /home/mechelle/repos/lantern_app/services/api/auth/src/routes/adminUsers.js

The 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:

js
const resetLink = await auth.generatePasswordResetLink(email, { url: ... })
// ... sendMerchantInviteEmail(... resetLink ...)

Replace that block with the new flow:

js
// โ”€โ”€ 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 randomBytes import already exists in adminUsers.js? If not, add import { randomBytes } from 'crypto' at the top of the file.

  • isPromotion, targetUser, email, contactName, callerUid are local variables in the existing handler. Match the existing names exactly.

  • The response body should still include resetLink and emailSent for the admin UI; nothing else needs to change.)

  • [ ] Step 10.3: Verify syntax

bash
cd services/api/auth && node --check src/routes/adminUsers.js

Expected: 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 with setupKind: 'fresh' and a non-null firebaseOobCode

  • The Resend email log shows the new merchant-token URL (?mode=merchantReset&token=...)

  • [ ] Step 10.5: Commit

bash
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

bash
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:

js
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
bash
cd services/api/auth && node --check src/routes/adminMerchants.js

Expected: 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} with setupKind: 'fresh', non-null firebaseOobCode

  • Email 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} with setupKind: 'promotion', null firebaseOobCode

  • Email subject reflects the "added to" copy

  • [ ] Step 11.6: Commit

bash
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:

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
bash
npm run build -w lantern-admin 2>&1 | tail -20

Expected: build completes successfully.

  • [ ] Step 12.4: Commit
bash
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

bash
grep -n "signInWithAdminPassword\|requestAdminPasswordReset\|checkAdminPasswordStatus\|verifyAdminResetToken\|setAdminPassword" /home/mechelle/repos/lantern_app/apps/admin/src/firebase.js

These 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:

js
// =============================================================================
// 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
bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds.

  • [ ] Step 13.4: Commit
bash
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.jsx to mirror its structure

bash
cat /home/mechelle/repos/lantern_app/apps/admin/src/components/SetAdminPassword.jsx

Read 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:

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.

bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds.

  • [ ] Step 14.4: Commit
bash
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:

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
bash
npm test -w lantern-admin -- --run SetMerchantPassword

Expected: all 9 tests pass.

  • [ ] Step 15.3: Commit
bash
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 merchantReset mode handler

Find the existing adminReset handler (around App.jsx:170). Below it, add:

jsx
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 handleSignInWithEmail for three-tier fall-through

Find handleSignInWithEmail around App.jsx:105. Replace the current admin-only logic with a three-tier chain. The existing structure:

js
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:

js
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:

js
import {
  signInWithEmail,
  signInWithAdminPassword,
  signInWithMerchantPassword,
  // ...
} from './firebase'
  • [ ] Step 16.3: Verify build
bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds.

  • [ ] Step 16.4: Commit
bash
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:

js
import { requestAdminPasswordReset, requestMerchantPasswordReset } from '../firebase'

(requestMerchantPasswordReset was added in Task 13. Match the import style.)

  • [ ] Step 17.2: Update handlePasswordReset to fan out

Replace the body of the existing handlePasswordReset:

js
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
bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds.

  • [ ] Step 17.4: Commit
bash
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
bash
npm run validate -- --workspace apps/admin 2>&1 | tail -20

Expected: build green. The pre-existing security audit failure (firebase-admin / resend / etc.) is unrelated.

  • [ ] Step 18.2: Run auth-API tests
bash
cd services/api/auth && npm run test:run

Expected: all existing unit tests pass.

  • [ ] Step 18.3: Run admin tests
bash
npm test -w lantern-admin -- --run

Expected: all tests pass (existing + new SetMerchantPassword tests).

  • [ ] Step 18.4: Manual end-to-end smoke โ€” fresh merchant
  1. Start dev servers: npm run dev -w services/api/auth + npm run dev -w lantern-admin.
  2. As an admin, create a brand-new merchant user (/users โ†’ "Create Merchant User"). Note: use a fresh email never seen before.
  3. Confirm Firestore: merchantPasswordResetTokens/{token} with setupKind: 'fresh', non-null firebaseOobCode.
  4. Open the link from the dev resetUrl in the response (or from the Resend email).
  5. Step 1: type a Lantern passphrase, confirm. Verify Firebase Auth shows the user has a password now.
  6. Step 2: type a merchant portal password, confirm. Verify merchantProfiles/{uid}.merchantPasswordHash is populated, merchantPasswordSalt set, failedLoginAttempts: 0.
  7. Auto sign-in lands you on /merchants/{merchantId} (via merchantOnly redirect).
  • [ ] Step 18.5: Manual end-to-end smoke โ€” promoted user
  1. Pick an existing Lantern user (or sign up a fresh one in the consumer app first).
  2. As an admin, create a merchant user with that email.
  3. Confirm token has setupKind: 'promotion', firebaseOobCode: null.
  4. Open the link โ†’ setup screen shows ONLY step 2 ("Set your merchant portal password"). Step 1 is skipped.
  5. 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
EmailPasswordExpected outcome
Adminadmin passwordsuccess (admin tier)
Adminwrong"Incorrect email or password" (after all tiers fail)
Merchantmerchant portal passwordsuccess (merchant tier)
Merchantwrong"Incorrect email or password"
Lantern user onlypassphrasesuccess (legacy tier)
Unknown emailanything"Incorrect email or password"
  • [ ] Step 18.7: Manual end-to-end smoke โ€” forgot password isolation
  1. Sign in to Lantern app as a merchant. Note their encrypted data (e.g., a private profile field).
  2. Sign out. Click "Forgot password?" on admin portal LoginScreen. Submit merchant email.
  3. Receive merchant reset email (NOT admin). Click link โ†’ setup screen step 2 only โ†’ set new portal password.
  4. Sign in to merchant portal โ€” works.
  5. Sign in to Lantern app with original passphrase โ€” encrypted data still readable.
  • [ ] Step 18.8: Open a PR
bash
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-admin pointed 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.

Built with VitePress