Skip to content

Merchant User Attach Flow 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: Replace the misplaced "Create Merchant" tab on /users with a "Create Merchant User" flow that links to an existing merchant, and add an "Attach to merchant" action in the user detail panel.

Architecture: Two new auth-API endpoints (one for create-and-attach, one for attach-existing). Frontend gets a new tab + form on /users and a new action in UserDetailPanel. Existing /merchants/new atomic create flow is left untouched. No data-model changes โ€” uses existing users.merchantId, customClaims.role, and merchants.ownerUserIds.

Tech Stack: React 19, Vite, Vitest + Testing Library (admin), Express 5 + Firebase Admin + zod (auth API).

Spec: docs/superpowers/specs/2026-04-26-merchant-user-attach-flow-design.md


File Structure โ€‹

New files โ€‹

  • apps/admin/src/components/CreateMerchantUserForm.jsx โ€” form for creating a user attached to an existing merchant
  • apps/admin/src/components/__tests__/CreateMerchantUserForm.test.jsx โ€” component tests
  • apps/admin/src/components/AttachMerchantDialog.jsx โ€” modal for the attach action in the user detail panel
  • services/api/auth/src/lib/__tests__/merchantAttach.test.js โ€” unit tests for any extracted validation helpers (if needed)

Modified files โ€‹

  • services/api/auth/src/routes/adminMerchants.js โ€” add POST /auth/admin/merchants/:merchantId/users
  • services/api/auth/src/routes/adminUsers.js โ€” add POST /auth/admin/users/:userId/attach-merchant
  • apps/admin/src/lib/authApi.js โ€” add createMerchantUserForMerchant, attachUserToMerchant
  • apps/admin/src/firebase.js โ€” re-export the two new client functions
  • apps/admin/src/components/UserManagement.jsx โ€” remove 'create-merchant' tab + button; add new 'create-merchant-user' tab + button
  • apps/admin/src/components/UserDetailPanel.jsx โ€” add attach/re-assign action

Untouched โ€‹

  • apps/admin/src/components/CreateMerchantForm.jsx โ€” used by /merchants/new; stays as-is
  • apps/admin/src/components/merchants/MerchantsCreate.jsx โ€” unaffected

Conventions to Follow โ€‹

  • Admin UI patterns: docs/engineering/guides/ADMIN_PAGE_PATTERNS.md. No emojis in UI chrome โ€” Lucide icons only. Reuse form-card, form-section, form-input, form-label, form-hint, form-error classes from apps/admin/src/styles.css.
  • Validation: zod for request bodies in the auth API (existing pattern, see CreateMerchantBody in adminUsers.js:31-38).
  • Audit logging: every admin mutation appends a record to adminActions collection (see adminUsers.js:401-408 for the existing pattern).
  • Commits: small, frequent. After each task, commit with a feat: / fix: / test: prefix.

Task 1: Branch + spec audit โ€‹

Files:

  • Read: docs/superpowers/specs/2026-04-26-merchant-user-attach-flow-design.md

  • [ ] Step 1.1: Confirm we are on a clean branch

Run:

bash
git status
git log --oneline -5

Expected: clean working tree (or only the spec/plan committed). If still on claude/merchant-ad-placeholders-y2pxu with unrelated changes, ask the user before proceeding.

  • [ ] Step 1.2: Re-read the spec

Make sure the spec's v1 scope matches the plan tasks. The plan only implements the v1 sections โ€” everything in "Out of Scope" is intentionally untouched.


Task 2: New endpoint โ€” POST /auth/admin/merchants/:merchantId/users โ€‹

This creates a user that is attached to an existing merchant. Mirrors the dual create-or-promote behavior of POST /auth/admin/users/merchant but requires the merchantId in the URL instead of minting a new one.

Files:

  • Modify: services/api/auth/src/routes/adminMerchants.js

  • [ ] Step 2.1: Add the zod schema for the request body

In adminMerchants.js, near the top (after the existing imports), add:

js
import { z } from 'zod'

const CreateMerchantUserBody = z.object({
  email: z.string().email(),
  contactName: z.string().min(1),
  phone: z.string().optional(),
  notes: z.string().optional(),
  sendInvite: z.boolean().optional(),
})

(If zod is already imported in this file, just add the schema object.)

  • [ ] Step 2.2: Implement the endpoint

Add this route handler before export default router in adminMerchants.js:

js
// โ”€โ”€ POST /auth/admin/merchants/:merchantId/users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Create a user attached to an existing merchant. Atomic with rollback:
// if the Firestore batch fails, the new Auth user is deleted (or, on the
// promotion path, prior custom claims are restored).
router.post('/:merchantId/users', async (req, res, next) => {
  let createdAuthUid = null
  let isPromotion = false
  let targetUser
  let previousClaims = null

  try {
    const { merchantId } = req.params
    const body = CreateMerchantUserBody.parse(req.body)
    const callerUid = req.user.uid
    const auth = getAuth()
    const db = getFirestore()
    const email = body.email.trim()
    const contactName = body.contactName.trim()
    const phone = body.phone?.trim() || ''
    const notes = body.notes?.trim() || ''

    // โ”€โ”€ 1. Validate merchant exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const merchantSnap = await db.collection('merchants').doc(merchantId).get()
    if (!merchantSnap.exists) {
      return res.status(404).json({
        error: 'MERCHANT_NOT_FOUND',
        message: `No merchant with id ${merchantId}`,
      })
    }

    // โ”€โ”€ 2. Resolve target user (promotion vs fresh create) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    try {
      targetUser = await auth.getUserByEmail(email)
      isPromotion = true
    } catch (err) {
      if (err.code !== 'auth/user-not-found') throw err
      targetUser = await auth.createUser({ email, displayName: contactName })
      createdAuthUid = targetUser.uid
    }

    // โ”€โ”€ 3. Guard against role conflicts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if (isPromotion) {
      const existingClaims = targetUser.customClaims || {}
      if (existingClaims.role === 'admin') {
        return res.status(409).json({
          error: 'EMAIL_IN_USE_AS_ADMIN',
          message: 'User is already an admin and cannot be re-roled here',
        })
      }
      const existingSnap = await db.collection('users').doc(targetUser.uid).get()
      if (existingSnap.exists && existingSnap.data().merchantId) {
        return res.status(409).json({
          error: 'ALREADY_MERCHANT',
          message: 'User is already associated with a merchant',
        })
      }
    }

    // โ”€โ”€ 4. Apply role claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    previousClaims = isPromotion ? (targetUser.customClaims || null) : null
    await auth.setCustomUserClaims(targetUser.uid, { role: 'merchant' })

    // โ”€โ”€ 5. Firestore batch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const batch = db.batch()
    const now = FieldValue.serverTimestamp()

    if (isPromotion) {
      batch.update(db.collection('users').doc(targetUser.uid), {
        role: 'merchant',
        merchantId,
        promotedToMerchantAt: now,
        promotedBy: callerUid,
      })
    } else {
      batch.set(db.collection('users').doc(targetUser.uid), {
        email,
        role: 'merchant',
        displayName: contactName,
        merchantId,
        createdAt: now,
        createdBy: callerUid,
      })
    }

    batch.set(
      db.collection('merchantProfiles').doc(targetUser.uid),
      {
        contactName,
        phone,
        notes,
        status: 'pending_setup',
        createdAt: now,
        createdBy: callerUid,
      },
      { merge: true }
    )

    batch.update(db.collection('merchants').doc(merchantId), {
      ownerUserIds: FieldValue.arrayUnion(targetUser.uid),
      updatedAt: now,
    })

    await batch.commit()
    createdAuthUid = null

    if (targetUser.displayName !== contactName) {
      await auth.updateUser(targetUser.uid, { displayName: contactName })
    }

    // โ”€โ”€ 6. Invite email (new accounts only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let resetLink = null
    let emailSent = false
    const isProd = process.env.GOOGLE_CLOUD_PROJECT === 'lantern-app-prod'

    if (!isPromotion) {
      resetLink = await auth.generatePasswordResetLink(email, {
        url: isProd ? 'https://admin.ourlantern.app' : 'https://admin.dev.ourlantern.app',
      })

      if (body.sendInvite !== false && resetLink) {
        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,
        })
        emailSent = emailResult.success
      }
    }

    // โ”€โ”€ 7. Audit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    await db.collection('adminActions').add({
      action: isPromotion ? 'attachUserToMerchant' : 'createMerchantUserForMerchant',
      targetUserId: targetUser.uid,
      targetEmail: email,
      merchantId,
      performedBy: callerUid,
      performedAt: FieldValue.serverTimestamp(),
    })

    return res.status(201).json({
      userId: targetUser.uid,
      merchantId,
      email,
      wasPromotion: isPromotion,
      emailSent,
      resetLink,
    })
  } catch (err) {
    if (createdAuthUid) {
      try {
        await getAuth().deleteUser(createdAuthUid)
      } catch (rollbackErr) {
        req.log?.error({ err: rollbackErr, uid: createdAuthUid }, 'rollback failed')
      }
    } else if (isPromotion && typeof targetUser !== 'undefined') {
      try {
        await getAuth().setCustomUserClaims(targetUser.uid, previousClaims ?? {})
      } catch (rollbackErr) {
        req.log?.error({ err: rollbackErr, uid: targetUser.uid }, 'claims rollback failed')
      }
    }
    next(err)
  }
})

Make sure sendMerchantInviteEmail is imported at the top of the file. If it's not already imported in adminMerchants.js, add:

js
import { sendMerchantInviteEmail } from '../lib/email.js'
  • [ ] Step 2.3: Verify the auth-api boots without syntax errors

Run:

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

Expected: no output (means syntax is OK).

  • [ ] Step 2.4: Smoke test the endpoint locally

Start the auth API:

bash
npm run dev -w services/api/auth

In another terminal, get an admin ID token (from the admin app session) and call the endpoint:

bash
TOKEN="<paste admin Firebase ID token>"
MERCHANT_ID="<existing merchantId from Firestore>"

curl -X POST http://localhost:8084/auth/admin/merchants/$MERCHANT_ID/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"smoke-test+1@example.com","contactName":"Smoke Test","sendInvite":false}'

Expected: 201 with { userId, merchantId, wasPromotion: false, ... }. Verify in Firestore that merchants/$MERCHANT_ID.ownerUserIds now contains the new uid, and users/$uid has role: 'merchant' and merchantId.

  • [ ] Step 2.5: Smoke test the merchant-not-found path
bash
curl -X POST http://localhost:8084/auth/admin/merchants/does-not-exist/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"smoke-test+2@example.com","contactName":"Smoke Test"}'

Expected: 404 with { error: 'MERCHANT_NOT_FOUND' }.

  • [ ] Step 2.6: Commit
bash
git add services/api/auth/src/routes/adminMerchants.js
git commit -m "feat(auth-api): add POST /auth/admin/merchants/:merchantId/users"

Task 3: New endpoint โ€” POST /auth/admin/users/:userId/attach-merchant โ€‹

This attaches an existing (non-admin) user to a merchant, including re-assignment from another merchant.

Files:

  • Modify: services/api/auth/src/routes/adminUsers.js

  • [ ] Step 3.1: Add the zod schema

In adminUsers.js, near the existing schemas (around line 30-46), add:

js
const AttachMerchantBody = z.object({
  merchantId: z.string().min(1),
})
  • [ ] Step 3.2: Implement the endpoint

Add the route handler before export default router:

js
// โ”€โ”€ POST /auth/admin/users/:userId/attach-merchant โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Attach an existing (non-admin) user to an existing merchant. Handles
// re-assignment: if the user already has a merchantId, they are removed
// from the old merchant's ownerUserIds and added to the new one.
router.post('/:userId/attach-merchant', async (req, res, next) => {
  try {
    const { userId } = req.params
    const body = AttachMerchantBody.parse(req.body)
    const { merchantId } = body
    const callerUid = req.user.uid
    const auth = getAuth()
    const db = getFirestore()

    // โ”€โ”€ 1. Validate target user โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let authUser
    try {
      authUser = await auth.getUser(userId)
    } catch (err) {
      if (err.code === 'auth/user-not-found') {
        return res.status(404).json({ error: 'USER_NOT_FOUND', message: 'User does not exist' })
      }
      throw err
    }

    if ((authUser.customClaims || {}).role === 'admin') {
      return res.status(409).json({
        error: 'USER_IS_ADMIN',
        message: 'Admin users cannot be attached to a merchant from this UI',
      })
    }

    // โ”€โ”€ 2. Validate target merchant exists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const merchantSnap = await db.collection('merchants').doc(merchantId).get()
    if (!merchantSnap.exists) {
      return res.status(404).json({
        error: 'MERCHANT_NOT_FOUND',
        message: `No merchant with id ${merchantId}`,
      })
    }

    // โ”€โ”€ 3. Detect re-assignment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const userSnap = await db.collection('users').doc(userId).get()
    const previousMerchantId = userSnap.exists ? userSnap.data().merchantId || null : null
    const wasReassignment = !!previousMerchantId && previousMerchantId !== merchantId

    if (previousMerchantId === merchantId) {
      return res.status(200).json({
        success: true,
        userId,
        merchantId,
        wasReassignment: false,
        alreadyAttached: true,
      })
    }

    // โ”€โ”€ 4. Apply role claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if ((authUser.customClaims || {}).role !== 'merchant') {
      await auth.setCustomUserClaims(userId, { role: 'merchant' })
    }

    // โ”€โ”€ 5. Firestore batch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const batch = db.batch()
    const now = FieldValue.serverTimestamp()

    if (userSnap.exists) {
      batch.update(db.collection('users').doc(userId), {
        role: 'merchant',
        merchantId,
        attachedToMerchantAt: now,
        attachedBy: callerUid,
      })
    } else {
      batch.set(db.collection('users').doc(userId), {
        email: authUser.email || '',
        role: 'merchant',
        displayName: authUser.displayName || '',
        merchantId,
        createdAt: now,
        createdBy: callerUid,
      })
    }

    batch.update(db.collection('merchants').doc(merchantId), {
      ownerUserIds: FieldValue.arrayUnion(userId),
      updatedAt: now,
    })

    if (wasReassignment) {
      batch.update(db.collection('merchants').doc(previousMerchantId), {
        ownerUserIds: FieldValue.arrayRemove(userId),
        updatedAt: now,
      })
    }

    await batch.commit()

    // โ”€โ”€ 6. Audit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    await db.collection('adminActions').add({
      action: wasReassignment ? 'reassignUserToMerchant' : 'attachUserToMerchant',
      targetUserId: userId,
      merchantId,
      previousMerchantId,
      performedBy: callerUid,
      performedAt: FieldValue.serverTimestamp(),
    })

    return res.status(200).json({
      success: true,
      userId,
      merchantId,
      wasReassignment,
    })
  } catch (err) {
    next(err)
  }
})
  • [ ] Step 3.3: Verify syntax
bash
cd services/api/auth && node --check src/routes/adminUsers.js

Expected: no output.

  • [ ] Step 3.4: Smoke test the happy path

With the dev server running:

bash
USER_ID="<existing non-admin uid>"
MERCHANT_ID="<existing merchantId>"

curl -X POST http://localhost:8084/auth/admin/users/$USER_ID/attach-merchant \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"merchantId\":\"$MERCHANT_ID\"}"

Expected: 200 with { success: true, userId, merchantId, wasReassignment: false }. Verify Firestore: users/$USER_ID.merchantId is set, merchants/$MERCHANT_ID.ownerUserIds includes the user.

  • [ ] Step 3.5: Smoke test re-assignment

Repeat the call with a different merchantId for the same user. Expected: 200 with wasReassignment: true. Verify the old merchant's ownerUserIds no longer contains the user, and the new merchant's does.

  • [ ] Step 3.6: Smoke test admin rejection
bash
ADMIN_USER_ID="<existing admin uid>"

curl -X POST http://localhost:8084/auth/admin/users/$ADMIN_USER_ID/attach-merchant \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"merchantId\":\"$MERCHANT_ID\"}"

Expected: 409 with { error: 'USER_IS_ADMIN' }.

  • [ ] Step 3.7: Commit
bash
git add services/api/auth/src/routes/adminUsers.js
git commit -m "feat(auth-api): add POST /auth/admin/users/:userId/attach-merchant"

Task 4: Add API client functions โ€‹

Files:

  • Modify: apps/admin/src/lib/authApi.js

  • Modify: apps/admin/src/firebase.js

  • [ ] Step 4.1: Add the two client functions to authApi.js

After the existing disassociateVenueFromMerchant (around line 240), add:

js
/**
 * POST /auth/admin/merchants/:merchantId/users โ€” create a user attached to a merchant
 * Body: { email, contactName, phone?, notes?, sendInvite? }
 * Returns: { userId, merchantId, email, wasPromotion, emailSent, resetLink }
 */
export async function createMerchantUserForMerchant(merchantId, body) {
  const response = await authRequest(
    `${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}/users`,
    { method: 'POST', body: JSON.stringify(body) }
  )
  return parseResponse(response)
}

/**
 * POST /auth/admin/users/:userId/attach-merchant โ€” attach an existing user to a merchant
 * Body: { merchantId }
 * Returns: { success, userId, merchantId, wasReassignment }
 */
export async function attachUserToMerchant(userId, merchantId) {
  const response = await authRequest(
    `${API_BASE_URL}/auth/admin/users/${encodeURIComponent(userId)}/attach-merchant`,
    { method: 'POST', body: JSON.stringify({ merchantId }) }
  )
  return parseResponse(response)
}
  • [ ] Step 4.2: Re-export from firebase.js

apps/admin/src/firebase.js re-exports auth API client functions for components to import. Find a section that re-exports merchant-related functions (search for associateVenueWithMerchant) and add the two new ones nearby:

js
export async function createMerchantUserForMerchant(merchantId, body) {
  const api = await import('./lib/authApi.js')
  return api.createMerchantUserForMerchant(merchantId, body)
}

export async function attachUserToMerchant(userId, merchantId) {
  const api = await import('./lib/authApi.js')
  return api.attachUserToMerchant(userId, merchantId)
}

(Match the dynamic-import pattern used by surrounding re-exports โ€” see getMerchantData around line 909 of firebase.js.)

  • [ ] Step 4.3: Verify build
bash
npm run lint -w apps/admin

Expected: no new lint errors.

  • [ ] Step 4.4: Commit
bash
git add apps/admin/src/lib/authApi.js apps/admin/src/firebase.js
git commit -m "feat(admin): add API client for merchant-user create/attach"

Task 5: Remove "Create Merchant" tab from /users โ€‹

Files:

  • Modify: apps/admin/src/components/UserManagement.jsx

  • [ ] Step 5.1: Remove the tab definition

In the TABS array (around line 97-103), remove the 'create-merchant' entry:

js
// Before:
const TABS = [
  { id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={16} />, isDashboard: true },
  { id: 'users', label: 'Users', icon: <UsersIcon size={16} /> },
  { id: 'activity', label: 'Activity', icon: <Activity size={16} />, isActivity: true },
  { id: 'create-admin', label: 'Create Admin', icon: <Plus size={16} />, isForm: true },
  { id: 'create-merchant', label: 'Create Merchant', icon: <Plus size={16} />, isForm: true },
]

// After:
const TABS = [
  { id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={16} />, isDashboard: true },
  { id: 'users', label: 'Users', icon: <UsersIcon size={16} /> },
  { id: 'activity', label: 'Activity', icon: <Activity size={16} />, isActivity: true },
  { id: 'create-admin', label: 'Create Admin', icon: <Plus size={16} />, isForm: true },
]
  • [ ] Step 5.2: Remove the tab's render branch

In renderTabContent(), delete the block:

js
if (activeTab === 'create-merchant') {
  return <CreateMerchantForm onSuccess={handleFormSuccess} onCancel={handleFormCancel} />
}
  • [ ] Step 5.3: Remove the unused import

At the top of the file, delete:

js
import CreateMerchantForm from './CreateMerchantForm'
  • [ ] Step 5.4: Remove the header button

Find the header <button> "Create Merchant" (around line 822) and delete that whole <button> element. Keep the "Create Admin" button.

  • [ ] Step 5.5: Update handleFormSuccess and handleFormCancel

These currently branch on activeTab === 'create-admin' vs default. Simplify them to just handle the admin path (the default-else for merchant is no longer reachable):

js
const handleFormSuccess = () => {
  setActiveTab('users')
  loadUsers(true)
}

const handleFormCancel = () => {
  setActiveTab('users')
}
  • [ ] Step 5.6: Update page-title logic

The pageTitle ternary currently includes 'create-merchant'. Remove that branch:

js
// After:
const pageTitle =
  activeTab === 'create-admin' ? 'Create Admin' : 'User Management'
  • [ ] Step 5.7: Verify
bash
npm run lint -w apps/admin
npm test -w apps/admin -- --run --reporter=verbose UserManagement

Expected: no lint errors. Existing UserManagement tests (if any) still pass โ€” the AdminDashboard.test.jsx is unrelated. Manually navigate to /users and confirm the "Create Merchant" tab/button are gone.

  • [ ] Step 5.8: Commit
bash
git add apps/admin/src/components/UserManagement.jsx
git commit -m "refactor(admin): remove misplaced Create Merchant tab from /users"

Task 6: Build the new CreateMerchantUserForm component โ€‹

Files:

  • Create: apps/admin/src/components/CreateMerchantUserForm.jsx

  • [ ] Step 6.1: Create the component file

Write apps/admin/src/components/CreateMerchantUserForm.jsx:

jsx
import React, { useState, useEffect } from 'react'
import { Mail, Store, FileText } from 'lucide-react'
import { listMerchants, createMerchantUserForMerchant } from '../firebase'

/**
 * Create Merchant User Form
 *
 * Creates a new user attached to an existing merchant. Distinct from
 * `/merchants/new`, which creates a brand-new merchant business.
 */
export default function CreateMerchantUserForm({ onSuccess, onCancel }) {
  const [merchants, setMerchants] = useState([])
  const [merchantsLoading, setMerchantsLoading] = useState(true)
  const [merchantsError, setMerchantsError] = useState(null)

  const [formData, setFormData] = useState({
    merchantId: '',
    email: '',
    contactName: '',
    phone: '',
    notes: '',
    sendInvite: true,
  })
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState(null)
  const [success, setSuccess] = useState(null)

  useEffect(() => {
    let cancelled = false
    ;(async () => {
      try {
        setMerchantsLoading(true)
        const result = await listMerchants({ pageSize: 100 })
        if (!cancelled) setMerchants(result.items || [])
      } catch (err) {
        if (!cancelled) setMerchantsError(err.message || 'Failed to load merchants')
      } finally {
        if (!cancelled) setMerchantsLoading(false)
      }
    })()
    return () => {
      cancelled = true
    }
  }, [])

  const handleChange = (field, value) => {
    setFormData((prev) => ({ ...prev, [field]: value }))
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    if (!formData.merchantId) {
      setError('Please select a merchant')
      return
    }
    if (!formData.email.trim()) {
      setError('Email is required')
      return
    }
    if (!formData.contactName.trim()) {
      setError('Contact name is required')
      return
    }

    try {
      setSubmitting(true)
      setError(null)
      const response = await createMerchantUserForMerchant(formData.merchantId, {
        email: formData.email.trim(),
        contactName: formData.contactName.trim(),
        phone: formData.phone.trim() || undefined,
        notes: formData.notes.trim() || undefined,
        sendInvite: formData.sendInvite,
      })
      setSuccess({
        userId: response.userId,
        merchantId: response.merchantId,
        wasPromotion: response.wasPromotion,
        resetLink: response.resetLink,
        emailSent: response.emailSent,
      })
      onSuccess?.(response)
    } catch (err) {
      setError(err.message || 'Failed to create merchant user')
    } finally {
      setSubmitting(false)
    }
  }

  if (success) {
    return (
      <div className="create-form-container">
        <div className="form-card">
          <h3 className="form-section-title">User attached to merchant</h3>
          <p>
            {success.wasPromotion
              ? 'Existing user promoted and attached.'
              : 'New user created and attached.'}{' '}
            {success.emailSent && 'Invite email sent.'}
          </p>
          {success.resetLink && (
            <p className="form-hint">
              Setup link: <code>{success.resetLink}</code>
            </p>
          )}
          <button className="btn btn-secondary" onClick={onCancel}>
            Done
          </button>
        </div>
      </div>
    )
  }

  return (
    <div className="create-form-container">
      <div className="create-form-header">
        <h2>Create Merchant User</h2>
        <p className="text-muted">
          Create a user account and attach them to an existing merchant. To create a brand-new
          merchant business, go to Merchants &rarr; Create.
        </p>
      </div>

      {error && <div className="form-error">{error}</div>}

      <form onSubmit={handleSubmit} className="create-form">
        <div className="form-card">
          <div className="form-section">
            <h3 className="form-section-title">
              <Store size={16} />
              Merchant
            </h3>
            <div className="form-group">
              <label className="form-label">
                Attach to <span className="required">*</span>
              </label>
              {merchantsLoading ? (
                <div className="form-hint">Loading merchants...</div>
              ) : merchantsError ? (
                <div className="form-error">{merchantsError}</div>
              ) : (
                <select
                  className="form-input"
                  value={formData.merchantId}
                  onChange={(e) => handleChange('merchantId', e.target.value)}
                  required
                  disabled={submitting}
                >
                  <option value="">Select a merchantโ€ฆ</option>
                  {merchants.map((m) => (
                    <option key={m.merchantId} value={m.merchantId}>
                      {m.businessName} ({m.merchantId})
                    </option>
                  ))}
                </select>
              )}
            </div>
          </div>

          <div className="form-section">
            <h3 className="form-section-title">
              <Mail size={16} />
              Account Details
            </h3>
            <div className="form-group">
              <label className="form-label">
                Email <span className="required">*</span>
              </label>
              <input
                type="email"
                className="form-input"
                value={formData.email}
                onChange={(e) => handleChange('email', e.target.value)}
                required
                disabled={submitting}
              />
              <span className="form-hint">
                If this email matches an existing user, they will be promoted to merchant.
              </span>
            </div>
            <div className="form-group">
              <label className="form-label">
                Contact Name <span className="required">*</span>
              </label>
              <input
                type="text"
                className="form-input"
                value={formData.contactName}
                onChange={(e) => handleChange('contactName', e.target.value)}
                required
                disabled={submitting}
              />
            </div>
            <div className="form-group">
              <label className="form-label">Phone</label>
              <input
                type="tel"
                className="form-input"
                value={formData.phone}
                onChange={(e) => handleChange('phone', e.target.value)}
                disabled={submitting}
              />
            </div>
            <div className="form-group">
              <label className="form-label">
                <input
                  type="checkbox"
                  checked={formData.sendInvite}
                  onChange={(e) => handleChange('sendInvite', e.target.checked)}
                  disabled={submitting}
                  style={{ marginRight: 'var(--space-2)' }}
                />
                Send invite email
              </label>
              <span className="form-hint">
                Sends a setup link only when creating a brand-new account.
              </span>
            </div>
          </div>

          <div className="form-section">
            <h3 className="form-section-title">
              <FileText size={16} />
              Notes
            </h3>
            <div className="form-group">
              <textarea
                className="form-textarea"
                value={formData.notes}
                onChange={(e) => handleChange('notes', e.target.value)}
                disabled={submitting}
                rows={3}
              />
            </div>
          </div>
        </div>

        <div className="form-actions">
          <button
            type="submit"
            className="btn btn-primary"
            style={{ marginRight: 'var(--space-2)' }}
            disabled={submitting}
          >
            {submitting ? 'Creatingโ€ฆ' : 'Create & Attach'}
          </button>
          <button
            type="button"
            className="btn btn-secondary"
            onClick={onCancel}
            disabled={submitting}
          >
            Cancel
          </button>
        </div>
      </form>
    </div>
  )
}
  • [ ] Step 6.2: Verify imports resolve
bash
npm run lint -w apps/admin -- --max-warnings 0 apps/admin/src/components/CreateMerchantUserForm.jsx

Expected: no lint errors. (Specifically, listMerchants and createMerchantUserForMerchant must be re-exported from firebase.js per Task 4.)

  • [ ] Step 6.3: Commit
bash
git add apps/admin/src/components/CreateMerchantUserForm.jsx
git commit -m "feat(admin): add CreateMerchantUserForm component"

Task 7: Wire the new tab into UserManagement โ€‹

Files:

  • Modify: apps/admin/src/components/UserManagement.jsx

  • [ ] Step 7.1: Add the import

At the top of UserManagement.jsx, near other component imports, add:

js
import CreateMerchantUserForm from './CreateMerchantUserForm'
  • [ ] Step 7.2: Add the tab to TABS

Add an entry after 'create-admin':

js
const TABS = [
  { id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={16} />, isDashboard: true },
  { id: 'users', label: 'Users', icon: <UsersIcon size={16} /> },
  { id: 'activity', label: 'Activity', icon: <Activity size={16} />, isActivity: true },
  { id: 'create-admin', label: 'Create Admin', icon: <Plus size={16} />, isForm: true },
  { id: 'create-merchant-user', label: 'Create Merchant User', icon: <Plus size={16} />, isForm: true },
]
  • [ ] Step 7.3: Add render branch

In renderTabContent(), after the 'create-admin' branch, add:

js
if (activeTab === 'create-merchant-user') {
  return <CreateMerchantUserForm onSuccess={handleFormSuccess} onCancel={handleFormCancel} />
}
  • [ ] Step 7.4: Add page-title branch
js
const pageTitle =
  activeTab === 'create-admin'
    ? 'Create Admin'
    : activeTab === 'create-merchant-user'
      ? 'Create Merchant User'
      : 'User Management'
  • [ ] Step 7.5: Add header button

Where the "Create Admin" header button lives (around the previous line 818-820), add a sibling button:

jsx
<button
  className="btn btn-primary btn-sm"
  onClick={() => setActiveTab('create-merchant-user')}
>
  <Plus size={14} />
  Create Merchant User
</button>

(Note: ADMIN_PAGE_PATTERNS.md says "at most one or two .btn-primary per screen." Two header CTAs โ€” Create Admin + Create Merchant User โ€” is the upper bound; do not add a third.)

  • [ ] Step 7.6: Manual smoke test
bash
npm run dev -w apps/admin

Open /users. Verify:

  1. The new "Create Merchant User" tab is visible.
  2. Clicking it shows the form with the merchant dropdown populated.
  3. Submitting with a valid merchant + email creates the user (check Firestore).
  4. The original "Create Merchant" tab is gone.
  • [ ] Step 7.7: Commit
bash
git add apps/admin/src/components/UserManagement.jsx
git commit -m "feat(admin): wire Create Merchant User tab into /users"

Task 8: Add AttachMerchantDialog (modal) โ€‹

Files:

  • Create: apps/admin/src/components/AttachMerchantDialog.jsx

  • [ ] Step 8.1: Create the dialog

Write apps/admin/src/components/AttachMerchantDialog.jsx:

jsx
import React, { useState, useEffect } from 'react'
import { listMerchants, attachUserToMerchant } from '../firebase'

/**
 * Modal dialog for attaching/re-assigning a user to a merchant.
 * Renders inline (no portal) inside UserDetailPanel.
 */
export default function AttachMerchantDialog({
  userId,
  currentMerchantId,
  onAttached,
  onCancel,
}) {
  const [merchants, setMerchants] = useState([])
  const [merchantsLoading, setMerchantsLoading] = useState(true)
  const [merchantsError, setMerchantsError] = useState(null)
  const [selectedMerchantId, setSelectedMerchantId] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    ;(async () => {
      try {
        setMerchantsLoading(true)
        const result = await listMerchants({ pageSize: 100 })
        if (!cancelled) setMerchants(result.items || [])
      } catch (err) {
        if (!cancelled) setMerchantsError(err.message || 'Failed to load merchants')
      } finally {
        if (!cancelled) setMerchantsLoading(false)
      }
    })()
    return () => {
      cancelled = true
    }
  }, [])

  const handleConfirm = async () => {
    if (!selectedMerchantId) {
      setError('Please select a merchant')
      return
    }
    if (selectedMerchantId === currentMerchantId) {
      setError('User is already attached to this merchant')
      return
    }
    try {
      setSubmitting(true)
      setError(null)
      const response = await attachUserToMerchant(userId, selectedMerchantId)
      onAttached?.(response)
    } catch (err) {
      setError(err.message || 'Failed to attach user to merchant')
    } finally {
      setSubmitting(false)
    }
  }

  const isReassignment = !!currentMerchantId

  return (
    <div className="dialog-overlay">
      <div className="dialog">
        <h3>{isReassignment ? 'Re-assign merchant' : 'Attach to merchant'}</h3>
        {isReassignment && (
          <p className="form-hint">
            Currently attached to <code>{currentMerchantId}</code>. Selecting a different merchant
            will move the user.
          </p>
        )}
        {error && <div className="form-error">{error}</div>}
        <div className="form-group">
          <label className="form-label">Merchant</label>
          {merchantsLoading ? (
            <div className="form-hint">Loading merchants...</div>
          ) : merchantsError ? (
            <div className="form-error">{merchantsError}</div>
          ) : (
            <select
              className="form-input"
              value={selectedMerchantId}
              onChange={(e) => setSelectedMerchantId(e.target.value)}
              disabled={submitting}
            >
              <option value="">Select a merchantโ€ฆ</option>
              {merchants.map((m) => (
                <option key={m.merchantId} value={m.merchantId}>
                  {m.businessName} ({m.merchantId})
                </option>
              ))}
            </select>
          )}
        </div>
        <div className="form-actions">
          <button
            className="btn btn-primary"
            onClick={handleConfirm}
            disabled={submitting || !selectedMerchantId}
            style={{ marginRight: 'var(--space-2)' }}
          >
            {submitting ? 'Attachingโ€ฆ' : isReassignment ? 'Re-assign' : 'Attach'}
          </button>
          <button className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
            Cancel
          </button>
        </div>
      </div>
    </div>
  )
}

The component uses dialog-overlay and dialog classes โ€” confirm these exist in apps/admin/src/styles.css (search for them). If not, the existing pattern in UserDetailPanel for ban/delete dialogs uses confirm-overlay / confirm-dialog โ€” switch to whichever the codebase actually uses.

  • [ ] Step 8.2: Confirm the dialog CSS classes exist
bash
grep -n "dialog-overlay\|confirm-overlay\|dialog\b\|confirm-dialog" apps/admin/src/styles.css | head -10

If dialog-overlay/dialog are not present but confirm-overlay/confirm-dialog are, replace the class names in the JSX accordingly. If neither exists, look at the inline ban/delete dialog markup in UserDetailPanel.jsx (around showBanDialog / showDeleteDialog) and reuse its exact structure and class names.

  • [ ] Step 8.3: Commit
bash
git add apps/admin/src/components/AttachMerchantDialog.jsx
git commit -m "feat(admin): add AttachMerchantDialog"

Task 9: Wire the attach action into UserDetailPanel โ€‹

Files:

  • Modify: apps/admin/src/components/UserDetailPanel.jsx

  • [ ] Step 9.1: Add the import + state

At the top of the imports, add:

js
import AttachMerchantDialog from './AttachMerchantDialog'

In the component's state declarations (after the existing useState block around lines 26-65), add:

js
const [showAttachDialog, setShowAttachDialog] = useState(false)
  • [ ] Step 9.2: Add the action button

Find the section that renders user actions for non-self, non-admin users. Look for the existing block around !isSelf && user.role !== 'admin' (search for that condition). Add an "Attach to merchant" button (or "Re-assign merchant" if user.merchantId) inside that block:

jsx
{!isSelf && user.role !== 'admin' && (
  <div className="action-section">
    <button
      className="btn btn-secondary"
      onClick={() => setShowAttachDialog(true)}
      disabled={saving}
    >
      {user.merchantId ? 'Re-assign merchant' : 'Attach to merchant'}
    </button>
    {user.merchantId && (
      <span className="form-hint">
        Currently attached to merchant <code>{user.merchantId}</code>
      </span>
    )}
  </div>
)}

(If no action-section class exists, wrap with whatever container class is used by adjacent action buttons โ€” e.g., the demote/ban buttons.)

  • [ ] Step 9.3: Render the dialog

Near the bottom of the panel JSX, alongside the existing ban/delete dialogs, add:

jsx
{showAttachDialog && (
  <AttachMerchantDialog
    userId={user.id}
    currentMerchantId={user.merchantId || null}
    onAttached={(response) => {
      setShowAttachDialog(false)
      setSuccess(
        response.wasReassignment
          ? 'User re-assigned to new merchant'
          : 'User attached to merchant'
      )
      setUser((prev) => ({
        ...prev,
        role: 'merchant',
        merchantId: response.merchantId,
      }))
      onUserUpdated?.(user.id, { role: 'merchant', merchantId: response.merchantId })
    }}
    onCancel={() => setShowAttachDialog(false)}
  />
)}

(onUserUpdated is the prop the panel already receives โ€” see existing usages elsewhere in the file. Match the call shape.)

  • [ ] Step 9.4: Manual smoke test
bash
npm run dev -w apps/admin
  1. Open /users. Click on a regular user (no merchantId).
  2. In the detail panel, click "Attach to merchant" โ†’ select a merchant โ†’ confirm.
  3. Verify: panel shows success, user's role updated, user row updates in the list.
  4. Open the same user again. Button now reads "Re-assign merchant". Pick a different merchant โ†’ confirm.
  5. Verify Firestore: old merchant's ownerUserIds shrinks, new merchant's grows.
  6. Open an admin user. The attach button should NOT appear.
  • [ ] Step 9.5: Commit
bash
git add apps/admin/src/components/UserDetailPanel.jsx
git commit -m "feat(admin): add Attach to merchant action in user detail panel"

Task 10: Component test for CreateMerchantUserForm โ€‹

Files:

  • Create: apps/admin/src/components/__tests__/CreateMerchantUserForm.test.jsx

  • [ ] Step 10.1: Write the test

jsx
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import CreateMerchantUserForm from '../CreateMerchantUserForm'

vi.mock('../../firebase', () => ({
  listMerchants: vi.fn(),
  createMerchantUserForMerchant: vi.fn(),
}))

import { listMerchants, createMerchantUserForMerchant } from '../../firebase'

describe('CreateMerchantUserForm', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    listMerchants.mockResolvedValue({
      items: [
        { merchantId: 'm-1', businessName: 'Acme Cafe' },
        { merchantId: 'm-2', businessName: 'Beta Bar' },
      ],
      nextCursor: null,
    })
  })

  it('loads merchants into the dropdown on mount', async () => {
    render(<CreateMerchantUserForm onSuccess={() => {}} onCancel={() => {}} />)
    await waitFor(() => {
      expect(screen.getByRole('option', { name: /Acme Cafe/ })).toBeInTheDocument()
    })
  })

  it('shows an error when submitting without a merchant', async () => {
    render(<CreateMerchantUserForm onSuccess={() => {}} onCancel={() => {}} />)
    await waitFor(() => screen.getByRole('option', { name: /Acme Cafe/ }))
    fireEvent.change(screen.getByLabelText(/Email/i), {
      target: { value: 'test@example.com' },
    })
    fireEvent.change(screen.getByLabelText(/Contact Name/i), {
      target: { value: 'Test User' },
    })
    fireEvent.click(screen.getByRole('button', { name: /Create & Attach/i }))
    expect(await screen.findByText(/Please select a merchant/)).toBeInTheDocument()
  })

  it('submits with the chosen merchant and shows success', async () => {
    createMerchantUserForMerchant.mockResolvedValue({
      userId: 'u-1',
      merchantId: 'm-1',
      wasPromotion: false,
      emailSent: true,
      resetLink: null,
    })
    const onSuccess = vi.fn()
    render(<CreateMerchantUserForm onSuccess={onSuccess} onCancel={() => {}} />)
    await waitFor(() => screen.getByRole('option', { name: /Acme Cafe/ }))

    fireEvent.change(screen.getByLabelText(/Attach to/i), { target: { value: 'm-1' } })
    fireEvent.change(screen.getByLabelText(/Email/i), {
      target: { value: 'test@example.com' },
    })
    fireEvent.change(screen.getByLabelText(/Contact Name/i), {
      target: { value: 'Test User' },
    })
    fireEvent.click(screen.getByRole('button', { name: /Create & Attach/i }))

    await waitFor(() => {
      expect(createMerchantUserForMerchant).toHaveBeenCalledWith(
        'm-1',
        expect.objectContaining({
          email: 'test@example.com',
          contactName: 'Test User',
          sendInvite: true,
        })
      )
    })
    expect(onSuccess).toHaveBeenCalled()
    expect(await screen.findByText(/User attached to merchant/i)).toBeInTheDocument()
  })

  it('surfaces backend errors', async () => {
    createMerchantUserForMerchant.mockRejectedValue(new Error('MERCHANT_NOT_FOUND'))
    render(<CreateMerchantUserForm onSuccess={() => {}} onCancel={() => {}} />)
    await waitFor(() => screen.getByRole('option', { name: /Acme Cafe/ }))

    fireEvent.change(screen.getByLabelText(/Attach to/i), { target: { value: 'm-1' } })
    fireEvent.change(screen.getByLabelText(/Email/i), {
      target: { value: 'test@example.com' },
    })
    fireEvent.change(screen.getByLabelText(/Contact Name/i), {
      target: { value: 'Test User' },
    })
    fireEvent.click(screen.getByRole('button', { name: /Create & Attach/i }))

    expect(await screen.findByText(/MERCHANT_NOT_FOUND/)).toBeInTheDocument()
  })
})
  • [ ] Step 10.2: Run the test and verify it passes
bash
npm test -w apps/admin -- --run CreateMerchantUserForm

Expected: all four tests pass.

  • [ ] Step 10.3: Commit
bash
git add apps/admin/src/components/__tests__/CreateMerchantUserForm.test.jsx
git commit -m "test(admin): add CreateMerchantUserForm component tests"

Task 11: Final validation โ€‹

  • [ ] Step 11.1: Run the workspace validation
bash
npm run validate -- --workspace apps/admin

Expected: all checks pass (lint, format, test, audit).

  • [ ] Step 11.2: Run auth API checks
bash
npm run validate -- --workspace services/api/auth

Expected: all checks pass.

  • [ ] Step 11.3: Manual end-to-end smoke

With both auth-api and admin dev servers running:

  1. Create flow: /users โ†’ "Create Merchant User" tab โ†’ pick merchant, enter brand-new email โ†’ submit. Verify success message + Firestore (users/{uid}.merchantId, merchants.ownerUserIds).
  2. Promotion flow: Same flow, but use an email of an existing non-merchant, non-admin user. Verify wasPromotion: true in response and that the existing user's role + merchantId are updated.
  3. Attach existing: /users โ†’ click a regular user โ†’ "Attach to merchant" โ†’ pick merchant โ†’ confirm. Verify state updates.
  4. Re-assign: Same user โ†’ "Re-assign merchant" โ†’ pick a different merchant โ†’ confirm. Verify old merchant's ownerUserIds no longer contains user; new one does.
  5. Admin guard: Open an admin user. Confirm "Attach to merchant" button is not shown.
  6. Untouched: /merchants/new still creates a brand-new merchant + first owner atomically (regression check).
  • [ ] Step 11.4: Open PR
bash
git push -u origin <branch-name>
gh pr create --title "feat(admin): merchant-user attach flow on /users" --body "..."

PR body should reference the spec and plan paths, summarize the v1 scope, and include a Test plan checklist.


Known Gap: Backend Integration Tests โ€‹

The spec calls for integration tests on both new endpoints, but the auth API (services/api/auth/) currently has no route-level test infrastructure โ€” only one unit-test file in src/lib/__tests__/. Setting up Express + Firebase emulator + supertest is its own project. This plan covers smoke tests via curl (Tasks 2 and 3) plus frontend component tests (Task 10), but defers automated backend integration tests.

If the user wants integration tests in this PR, expand the plan with a precursor task to scaffold:

  • 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 validation cases listed in Tasks 2 and 3

Otherwise, file a follow-up issue โ€” "Add route-level integration tests for auth-api admin endpoints" โ€” and reference this plan in the description.


Out of Scope (per spec โ€” do NOT do in this plan) โ€‹

  • apps/merchant/ separate app split
  • /auth/merchant/* namespace
  • @lantern/ui package extraction
  • "View as merchant" impersonation
  • Multi-role user support
  • Detach (without re-assign) action

If the implementer thinks any of these are needed for v1, stop and re-confirm with the user.

Built with VitePress