Skip to content

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:

  1. 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.
  2. 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 the TABS array (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 component CreateMerchantUserForm.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' and users/{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.
  • Clicking opens a merchant picker (searchable dropdown). Confirm sets users.merchantId and ensures customClaims.role === 'merchant'.

/merchants/new โ€” MerchantsCreate.jsx โ€‹

  • No change. Continues to atomically create a merchant business + first owner user via the existing createMerchantUser endpoint. The convenient one-stop onboarding flow stays. The new /users tab 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:
    • merchantId exists in merchants/{}
    • email not already a Firebase Auth user with role admin
  • Effects:
    • Creates Firebase Auth user
    • Sets customClaims.role = 'merchant'
    • Writes users/{uid} with merchantId, 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
    • merchantId exists
  • Effects:
    • Sets users.merchantId = merchantId
    • Sets customClaims.role = 'merchant' (if not already)
  • Returns: { success: true, userId, merchantId, wasReassignment: boolean }

Data model โ€‹

No schema changes. Already-modeled fields used:

  • users/{uid}.merchantId โ€” the link
  • users/{uid}.role + customClaims.role โ€” gate
  • merchants/{merchantId} โ€” target entity

Permissions / filtering โ€‹

No change. Existing scoping continues to apply:

  • AdminDashboard.jsx:152-156 (merchantOnly flag) confines role='merchant' users to /merchants/${user.merchantId}/*
  • MerchantsIndexRedirect.jsx redirects 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 exist
    • EMAIL_IN_USE_AS_ADMIN โ€” create endpoint, conflict
    • USER_NOT_FOUND โ€” attach endpoint, missing user
    • USER_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.jsx
  • apps/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, mirroring apps/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/ui package extraction โ€” pull PageHeader, 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, set role='user'). Adjacent to attach but not in scope here. Add when product need arises.

Built with VitePress