Merchant User Attach Flow โ Design Spec โ
Date: 2026-04-26 Status: Approved (pending user review of this written spec) Scope: v1 cleanup of admin's user/merchant flows, and a parking lot for the deferred apps/merchant/ hub split.
Problem โ
Two concrete issues on the admin app today:
- The "Create Merchant" tab on
/users(apps/admin/src/components/UserManagement.jsx:102) is misplaced. It atomically creates a merchant business entity and a user account, conflating "manage users" with "create merchant." Creating a brand-new merchant business already lives at/merchants/new. - There is no admin UI for attaching a user to a merchant โ neither for creating a user already linked to a merchant, nor for linking an existing user to one.
A broader concern was also raised: role-based UI conditionals are accumulating in admin (e.g. merchantOnly flag in AdminDashboard.jsx:152-156, MerchantsIndexRedirect.jsx). The eventual answer is a separate apps/merchant/ hub. After weighing scope, the v1 fix here stays small and targeted; the hub split is captured below as deferred work.
v1 Scope โ
Frontend changes (apps/admin/) โ
/users page โ UserManagement.jsx โ
- Remove the
'create-merchant'tab from theTABSarray (UserManagement.jsx:102) and the corresponding "Create Merchant" header button (UserManagement.jsx:822). - Add a new tab
'create-attach-merchant-user', label "Create Merchant User", rendering a new componentCreateMerchantUserForm.jsx. Form fields:- Merchant (required) โ searchable dropdown of existing merchants
- Email (required)
- Contact name (required)
- Phone (optional)
- Notes (optional)
- Send invite (checkbox; default true)
- Submit creates a Firebase Auth user with
customClaims.role = 'merchant'andusers/{uid}.merchantId = <chosen merchantId>. - Rename the surviving header button "Create Merchant" โ "Create Merchant User" to match.
UserDetailPanel.jsx โ
- Add an "Attach to merchant" action.
- Visibility: users where
role !== 'admin'(admins are not re-rolled to merchants from this UI). - For users with no current
merchantId, the action label is "Attach to merchant". - For users already linked to a merchant, the panel shows the current linkage and offers "Re-assign merchant" โ opens the same picker.
- Visibility: users where
- Clicking opens a merchant picker (searchable dropdown). Confirm sets
users.merchantIdand ensurescustomClaims.role === 'merchant'.
/merchants/new โ MerchantsCreate.jsx โ
- No change. Continues to atomically create a merchant business + first owner user via the existing
createMerchantUserendpoint. The convenient one-stop onboarding flow stays. The new/userstab is the path for adding additional users to an existing merchant.
Backend changes (services/api/auth/) โ
Both new endpoints live in services/api/auth/src/routes/adminUsers.js and are admin-role gated.
POST /auth/admin/merchant-users โ
Creates a new merchant user attached to an existing merchant.
- Body:
{ merchantId, email, contactName, phone?, notes?, sendInvite } - Validation:
merchantIdexists inmerchants/{}emailnot already a Firebase Auth user with roleadmin
- Effects:
- Creates Firebase Auth user
- Sets
customClaims.role = 'merchant' - Writes
users/{uid}withmerchantId,contactName, etc. - Sends password-reset email if
sendInvite === true
- Returns:
{ userId, merchantId, resetLink, emailSent }
POST /auth/admin/users/:userId/attach-merchant โ
Attaches an existing (non-admin) user to an existing merchant. Also handles re-assignment.
- Body:
{ merchantId } - Validation:
- User exists; user's role is not
admin merchantIdexists
- User exists; user's role is not
- Effects:
- Sets
users.merchantId = merchantId - Sets
customClaims.role = 'merchant'(if not already)
- Sets
- Returns:
{ success: true, userId, merchantId, wasReassignment: boolean }
Data model โ
No schema changes. Already-modeled fields used:
users/{uid}.merchantIdโ the linkusers/{uid}.role+customClaims.roleโ gatemerchants/{merchantId}โ target entity
Permissions / filtering โ
No change. Existing scoping continues to apply:
AdminDashboard.jsx:152-156(merchantOnlyflag) confinesrole='merchant'users to/merchants/${user.merchantId}/*MerchantsIndexRedirect.jsxredirects merchants away from any landing page that isn't their own merchant
Error handling โ
- Client-side: form validates required fields, email shape, merchant selection.
- Server-side: structured error codes returned as 400/404:
MERCHANT_NOT_FOUNDโ picked merchantId does not existEMAIL_IN_USE_AS_ADMINโ create endpoint, conflictUSER_NOT_FOUNDโ attach endpoint, missing userUSER_IS_ADMINโ attach endpoint, refusal to re-role admins
- UI: form / detail panel surface errors with retry guidance.
Testing โ
- Component tests (
apps/admin/src/components/__tests__/):CreateMerchantUserFormโ required-field validation, merchant picker behavior, success and error states.UserDetailPanelโ "Attach to merchant" visibility, picker flow, confirm, re-assignment label switch.
- Integration tests (
services/api/auth/):POST /auth/admin/merchant-usersโ happy path, missing merchant, email-conflict, role-gate enforcement.POST /auth/admin/users/:userId/attach-merchantโ happy path, re-assignment path, admin rejection, role-gate enforcement.
- Manual smoke: full create-and-attach flow ends with the new merchant user able to log in and land on their merchant's dashboard via the existing
MerchantsIndexRedirect.
Files Touched โ
New โ
apps/admin/src/components/CreateMerchantUserForm.jsxapps/admin/src/components/__tests__/CreateMerchantUserForm.test.jsx- Test files alongside the new endpoints in
services/api/auth/.
Modified โ
apps/admin/src/components/UserManagement.jsxโ tab swap, header-button rename.apps/admin/src/components/UserDetailPanel.jsxโ attach action.apps/admin/src/firebase.jsโ new API client functions.apps/admin/src/lib/authApi.jsโ new auth API methods.services/api/auth/src/routes/adminUsers.jsโ two new endpoints.
Intentionally untouched โ
apps/admin/src/components/CreateMerchantForm.jsxโ used by/merchants/new, stays as-is.apps/admin/src/components/merchants/*โ unaffected.
Out of Scope โ Deferred to Future "Merchant Hub" Plan โ
Considered during brainstorming and explicitly deferred to keep v1 scoped:
apps/merchant/separate app split โ own Cloudflare Pages target (e.g.merchant.ourlantern.app), independent build, mirroringapps/admin/'s shape. The architectural fork is right long-term; v1 stays in the existing admin app to build stability first./auth/merchant/*API namespace + service-layer extraction โ endpoints derived from the auth claim rather than a URL param, with shared business logic in a service module. Defer until there is a second consumer feeling the duplication.@lantern/uipackage extraction โ pullPageHeader,PageTabs,StyledSelect, role-card styles, etc. into a shared package. Defer until the hub exists and duplication pain is real.- "View as merchant" admin impersonation โ useful for QA and support once the hub is its own app. Defer until the hub exists.
- Multi-role users โ today a Firebase user has exactly one role at a time. Adequate for v1.
- Detach user from merchant โ explicit demotion (clear
merchantId, setrole='user'). Adjacent to attach but not in scope here. Add when product need arises.