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 merchantapps/admin/src/components/__tests__/CreateMerchantUserForm.test.jsxโ component testsapps/admin/src/components/AttachMerchantDialog.jsxโ modal for the attach action in the user detail panelservices/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โ addPOST /auth/admin/merchants/:merchantId/usersservices/api/auth/src/routes/adminUsers.jsโ addPOST /auth/admin/users/:userId/attach-merchantapps/admin/src/lib/authApi.jsโ addcreateMerchantUserForMerchant,attachUserToMerchantapps/admin/src/firebase.jsโ re-export the two new client functionsapps/admin/src/components/UserManagement.jsxโ remove'create-merchant'tab + button; add new'create-merchant-user'tab + buttonapps/admin/src/components/UserDetailPanel.jsxโ add attach/re-assign action
Untouched โ
apps/admin/src/components/CreateMerchantForm.jsxโ used by/merchants/new; stays as-isapps/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. Reuseform-card,form-section,form-input,form-label,form-hint,form-errorclasses fromapps/admin/src/styles.css. - Validation: zod for request bodies in the auth API (existing pattern, see
CreateMerchantBodyinadminUsers.js:31-38). - Audit logging: every admin mutation appends a record to
adminActionscollection (seeadminUsers.js:401-408for 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:
git status
git log --oneline -5Expected: 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:
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:
// โโ 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:
import { sendMerchantInviteEmail } from '../lib/email.js'- [ ] Step 2.3: Verify the auth-api boots without syntax errors
Run:
cd services/api/auth && node --check src/routes/adminMerchants.jsExpected: no output (means syntax is OK).
- [ ] Step 2.4: Smoke test the endpoint locally
Start the auth API:
npm run dev -w services/api/authIn another terminal, get an admin ID token (from the admin app session) and call the endpoint:
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
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
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:
const AttachMerchantBody = z.object({
merchantId: z.string().min(1),
})- [ ] Step 3.2: Implement the endpoint
Add the route handler before export default router:
// โโ 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
cd services/api/auth && node --check src/routes/adminUsers.jsExpected: no output.
- [ ] Step 3.4: Smoke test the happy path
With the dev server running:
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
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
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.jsModify:
apps/admin/src/firebase.js[ ] Step 4.1: Add the two client functions to
authApi.js
After the existing disassociateVenueFromMerchant (around line 240), add:
/**
* 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:
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
npm run lint -w apps/adminExpected: no new lint errors.
- [ ] Step 4.4: Commit
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:
// 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:
if (activeTab === 'create-merchant') {
return <CreateMerchantForm onSuccess={handleFormSuccess} onCancel={handleFormCancel} />
}- [ ] Step 5.3: Remove the unused import
At the top of the file, delete:
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
handleFormSuccessandhandleFormCancel
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):
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:
// After:
const pageTitle =
activeTab === 'create-admin' ? 'Create Admin' : 'User Management'- [ ] Step 5.7: Verify
npm run lint -w apps/admin
npm test -w apps/admin -- --run --reporter=verbose UserManagementExpected: 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
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:
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 → 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
npm run lint -w apps/admin -- --max-warnings 0 apps/admin/src/components/CreateMerchantUserForm.jsxExpected: no lint errors. (Specifically, listMerchants and createMerchantUserForMerchant must be re-exported from firebase.js per Task 4.)
- [ ] Step 6.3: Commit
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:
import CreateMerchantUserForm from './CreateMerchantUserForm'- [ ] Step 7.2: Add the tab to
TABS
Add an entry after 'create-admin':
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:
if (activeTab === 'create-merchant-user') {
return <CreateMerchantUserForm onSuccess={handleFormSuccess} onCancel={handleFormCancel} />
}- [ ] Step 7.4: Add page-title branch
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:
<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
npm run dev -w apps/adminOpen /users. Verify:
- The new "Create Merchant User" tab is visible.
- Clicking it shows the form with the merchant dropdown populated.
- Submitting with a valid merchant + email creates the user (check Firestore).
- The original "Create Merchant" tab is gone.
- [ ] Step 7.7: Commit
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:
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
grep -n "dialog-overlay\|confirm-overlay\|dialog\b\|confirm-dialog" apps/admin/src/styles.css | head -10If 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
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:
import AttachMerchantDialog from './AttachMerchantDialog'In the component's state declarations (after the existing useState block around lines 26-65), add:
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:
{!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:
{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
npm run dev -w apps/admin- Open
/users. Click on a regular user (no merchantId). - In the detail panel, click "Attach to merchant" โ select a merchant โ confirm.
- Verify: panel shows success, user's role updated, user row updates in the list.
- Open the same user again. Button now reads "Re-assign merchant". Pick a different merchant โ confirm.
- Verify Firestore: old merchant's
ownerUserIdsshrinks, new merchant's grows. - Open an admin user. The attach button should NOT appear.
- [ ] Step 9.5: Commit
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
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
npm test -w apps/admin -- --run CreateMerchantUserFormExpected: all four tests pass.
- [ ] Step 10.3: Commit
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
npm run validate -- --workspace apps/adminExpected: all checks pass (lint, format, test, audit).
- [ ] Step 11.2: Run auth API checks
npm run validate -- --workspace services/api/authExpected: all checks pass.
- [ ] Step 11.3: Manual end-to-end smoke
With both auth-api and admin dev servers running:
- Create flow:
/usersโ "Create Merchant User" tab โ pick merchant, enter brand-new email โ submit. Verify success message + Firestore (users/{uid}.merchantId,merchants.ownerUserIds). - Promotion flow: Same flow, but use an email of an existing non-merchant, non-admin user. Verify
wasPromotion: truein response and that the existing user's role + merchantId are updated. - Attach existing:
/usersโ click a regular user โ "Attach to merchant" โ pick merchant โ confirm. Verify state updates. - Re-assign: Same user โ "Re-assign merchant" โ pick a different merchant โ confirm. Verify old merchant's
ownerUserIdsno longer contains user; new one does. - Admin guard: Open an admin user. Confirm "Attach to merchant" button is not shown.
- Untouched:
/merchants/newstill creates a brand-new merchant + first owner atomically (regression check).
- [ ] Step 11.4: Open PR
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-adminpointed 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/uipackage 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.