Admin / Merchant Shell Split โ Design Spec โ
Date: 2026-04-27 Status: Approved (pending implementation plan) Scope: Restructure apps/admin/ into two role-scoped shells (AdminShell, MerchantShell) with a shared library zone, enforce the boundary at lint + build time, and align the auth API so both shells have well-defined surfaces. Merchants who are also Lantern users get a working self-service experience that doesn't pierce admin gates.
Problem โ
Iterating on the merchant integration surfaced a recurring pattern: every time a merchant-role user touches the admin app, something breaks (admin-only API gates, admin-shaped sidebar, "Merchant not found" landing failures, role-conditional UI cluttering shared components). The root cause is that apps/admin/ was built admin-first with merchants retrofitted via a merchantOnly flag and a MerchantsIndexRedirect. Every page assumes admin context.
Splitting into two shells with hard boundaries:
- Eliminates role conditionals across every page
- Gives merchants a clean, purpose-built experience
- Lets admins enter "merchant mode" deliberately (with switcher privileges) when they need to see what merchants see
- Sets up the eventual
apps/merchant/separate-app split as a mechanical lift, not a rewrite
Constraints โ
- Single PR, multi-commit. Each commit leaves the working tree green. No "halfway-migrated" intermediate state.
- No regression in admin functionality. Every existing admin URL keeps working (via back-compat redirects for one release cycle).
- Strict validation, not convention. Cross-shell imports fail lint with
errorseverity. Build-time chunk-graph validation catches dynamic imports that lint can't see. - No new shared package. Inside one Vite project,
@shared/*path aliases are sufficient. Package extraction (@lantern/ui) only when a second app needs the chrome. - Symmetric URL space. Both shells get a top-level prefix (
/admin/*,/merchant/*). No "admin-at-root, merchant-prefixed" asymmetry that leaks into every routing conversation forever.
v1 Scope โ
Architecture โ
Two role-scoped shells. The top-level fork happens in App.jsx after Firebase auth state is resolved.
| AdminShell | MerchantShell | |
|---|---|---|
| URL prefix | /admin/* | /merchant/:merchantId/* |
| Audience | customClaims.role === 'admin' | role === 'merchant' (own merchantId) OR role === 'admin' (any merchantId, via switcher) |
| Default landing | /admin/ (DashboardHome) | /merchant/:id/overview |
| Sidebar | Dashboard, Users, Merchants (list), Docs, Storybook, API, Config, Analytics, System, Billing, Profile, Feature Tracker | Overview, Offers, Venues, Notes, Photos, Address, Profile, โ Admin (admin-only), Sign out |
| Entry from the other | n/a | Admin sidebar "Merchants" โ /admin/merchants/all โ row click โ window.open('/merchant/:id/overview', '_blank') |
Three guarantees:
- A merchant can never reach admin URLs. Real merchants accessing
/admin/*are redirected to/merchant/<theirId>/overview. Enforced at the route guard, validated again at the API byrequireAdmin. - An admin can reach either shell. Admins entering merchant mode use elevated switcher privileges (any merchantId).
- No file in
src/admin/orsrc/merchant/ever imports from the other. Enforced by ESLintimport/no-restricted-paths(error severity) + a build-time chunk validator.
Directory layout โ
apps/admin/src/
โโโ App.jsx โ role fork; URL-mode handlers
โโโ main.jsx โ Vite entry
โโโ firebase.js โ stays at root (re-exports; will progressively shrink)
โ
โโโ shared/
โ โโโ components/ โ LanternLogo, PageHeader, PageTabs, StyledSelect,
โ โ SetAdminPassword, SetMerchantPassword, SetPassword,
โ โ LoadingScreen, AccessDenied, LoginScreen,
โ โ AdminMigrationBanner, ReinitializeLanternModal
โ โโโ lib/
โ โ โโโ apiClient.js
โ โ โโโ authApi.js โ admin auth + merchant auth endpoint clients
โ โ โโโ merchantApi.js โ role-aware: picks /auth/admin/merchants/:id vs /auth/merchant/me
โ โ โโโ currentMerchant.js
โ โ โโโ env.js
โ โโโ hooks/
โ โโโ styles/ โ styles.css, design tokens
โ
โโโ admin/
โ โโโ AdminShell.jsx โ sidebar + Routes for /admin/*
โ โโโ DashboardHome.jsx
โ โโโ users/ โ UserManagement, UserDetailPanel, CreateAdminForm,
โ โ CreateMerchantUserForm, AttachMerchantDialog
โ โโโ merchants/ โ MerchantsAll (list), MerchantsCreate, CreateMerchantForm
โ โโโ docs/ โ DocsEditor, MarkdownEditor, DocsEmbed, DocPage,
โ โ SelfHostedDocsEditor
โ โโโ api/ โ ApiOverview, ApiReferencePage, ClientSdkPage, ClientSdkEmbed
โ โโโ storybook/ โ StorybookEmbed
โ โโโ config/ โ ConfigOverview, ConfigServicesCors, ConfigRateLimits, ConfigVenue
โ โโโ analytics/ โ (existing analytics subtree)
โ โโโ system/ โ SystemHealth
โ โโโ billing/ โ Billing
โ โโโ feature-tracker/ โ FeatureTracker
โ โโโ profile/ โ MyAdminProfile
โ โโโ lib/
โ โโโ adminApi.js โ admin-only API clients
โ
โโโ merchant/
โโโ MerchantShell.jsx โ sidebar + Routes for /merchant/:merchantId/*
โโโ MerchantSwitcher.jsx โ dropdown; admin-only
โโโ tabs/ โ Overview, Offers, Venues, Notes, Photos, Address
โโโ offers/ โ OfferDetail, OfferForm, OffersList
โโโ profile/ โ MyMerchantProfile (new)
โโโ lib/
โโโ merchantSelfApi.js โ merchant-only thin clientComponent fates:
MerchantDetail.jsxโ deleted. Its responsibility (route shell + tab switching) is replaced byMerchantShell.jsx.MerchantsIndexRedirect.jsxโ deleted. The role fork inApp.jsxmakes it obsolete.MerchantSwitcher.jsxโ moves into the merchant shell (it's a merchant-shell affordance).- Existing
merchants/tabs/Merchant*Tab.jsxfiles โ renamed (drop the "Tab" suffix) and moved tosrc/merchant/tabs/since each is now a top-level page, not a tab strip entry.
Path aliases โ
apps/admin/vite.config.mjs and apps/admin/vitest.config.mjs:
resolve: {
alias: {
'@admin': path.resolve(__dirname, 'src/admin'),
'@merchant': path.resolve(__dirname, 'src/merchant'),
'@shared': path.resolve(__dirname, 'src/shared'),
},
}Lint enforcement (apps/admin/.eslintrc.json) โ
{
"rules": {
"import/no-restricted-paths": ["error", {
"zones": [
{
"target": "./src/admin",
"from": "./src/merchant",
"message": "Admin code may not import from src/merchant. Move shared code to src/shared/."
},
{
"target": "./src/merchant",
"from": "./src/admin",
"message": "Merchant code may not import from src/admin. Move shared code to src/shared/."
},
{
"target": "./src/shared",
"from": ["./src/admin", "./src/merchant"],
"message": "src/shared may not depend on shell-specific code. Inline or invert the dependency."
}
]
}]
}
}App.jsx, main.jsx, and firebase.js at the root are the bootstrap layer. They are not in src/admin/ or src/merchant/ and are allowed to import from any of the three zones.
Build-time bundle validator โ
tooling/scripts/check-shell-isolation.mjs runs after npm run build (added to npm run validate). Reads Vite's dist/.vite/manifest.json and asserts:
- The chunk graph reachable from
MerchantShell.jsx's entry includes no module undersrc/admin/. - The chunk graph reachable from
AdminShell.jsx's entry includes no module undersrc/merchant/.
Catches dynamic import() paths that ESLint can't statically analyze.
URL space โ
Admin shell route tree (/admin/*) โ
/admin/ โ DashboardHome
/admin/users โ UserManagement
/admin/merchants โ redirects to /admin/merchants/all
/admin/merchants/all โ MerchantsAll (list โ entry to merchant mode)
/admin/merchants/new โ MerchantsCreate
/admin/docs/* โ DocsEditor subtree
/admin/storybook โ StorybookEmbed
/admin/api/* โ ApiOverview / ApiReferencePage / ClientSdkPage
/admin/config/* โ ConfigOverview / ConfigServicesCors / ConfigRateLimits / ConfigVenue
/admin/analytics/* โ analytics subtree
/admin/system โ SystemHealth
/admin/billing โ Billing
/admin/profile โ MyAdminProfile
/admin/feature-tracker โ FeatureTrackerNote: there is no admin route that renders the merchant tabs. Admins view those only through the merchant shell. MerchantsAll rows include an "Open in merchant mode" affordance that calls window.open('/merchant/:id/overview', '_blank').
Merchant shell route tree (/merchant/:merchantId/*) โ
/merchant/:id/ โ redirects to overview
/merchant/:id/overview โ Overview
/merchant/:id/offers โ OffersList
/merchant/:id/offers/new โ OfferForm (mode=create)
/merchant/:id/offers/:offerId โ OfferDetail
/merchant/:id/offers/:offerId/edit โ OfferForm (mode=edit)
/merchant/:id/venues โ Venues
/merchant/:id/notes โ Notes
/merchant/:id/photos โ Photos
/merchant/:id/address โ Address
/merchant/:id/profile โ MyMerchantProfileRoute guards โ
At the App.jsx fork (before any shell renders):
- Real merchants accessing
/admin/*โ redirected to/merchant/<theirId>/overview - Real merchants accessing
/or any non-admin/non-merchant path โ redirected to/merchant/<theirId>/overview - Admins accessing
/or any non-admin/non-merchant path โ redirected to/admin/
Inside MerchantShell (additional self-merchant guard):
- Real merchants whose URL
:merchantIddoes not match their claimedmerchantIdโ redirected to their own merchant - Admins are free to navigate to any
:merchantId
Backwards-compatibility redirects โ
For one release cycle, redirect old admin URLs to the new /admin/* namespace. The shipping list:
| Old URL | New URL |
|---|---|
/users/* | /admin/users/* |
/docs/* | /admin/docs/* |
/storybook | /admin/storybook |
/client-sdk/* | /admin/api/client-sdk/* (note: was top-level, now nested under api) |
/system | /admin/system |
/billing | /admin/billing |
/profile | /admin/profile |
/feature-tracker | /admin/feature-tracker |
/api/* | /admin/api/* |
/config/* | /admin/config/* |
/analytics/* | /admin/analytics/* |
/merchants | /admin/merchants/all |
/merchants/all | /admin/merchants/all |
/merchants/new | /admin/merchants/new |
/merchants/:merchantId/* | /merchant/:merchantId/* (note: pluralโsingular) |
Track removal via a TODO comment + a /schedule follow-up agent (~2 weeks after merge).
Backend โ shared handlers + dual route mounts โ
The merchant data API needs to serve both:
- Admins via
/auth/admin/merchants/:merchantId/*(any merchantId) - Merchants via
/auth/merchant/me/*(merchantId resolves from token claim)
Refactor:
- Extract route handlers from
services/api/auth/src/routes/adminMerchants.jsinto pure functions in a newservices/api/auth/src/handlers/merchantHandlers.js. adminMerchants.jsbecomes a thin router that wires handlers under admin paths withrequireAdminmiddleware (unchanged behavior from today).- New
services/api/auth/src/routes/merchantSelf.jsmounts a subset of those handlers under/me. A small middleware injectsreq.params.merchantId = req.user.merchantIdbefore the handlers run.
New middleware: requireMerchant in services/api/auth/src/middleware/auth.js (mirror of requireAdmin).
What's mounted on each route:
| Handler | /auth/admin/merchants/:id/... | /auth/merchant/me/... |
|---|---|---|
getMerchantDetail | โ (any merchant) | โ (own merchant) |
listMerchants | โ | โ โ admin only |
createMerchantUserForMerchant | โ | โ โ admin onboards |
detachUserFromMerchant | โ | โ โ admin manages |
associateVenueWithMerchant | โ | โ โ admin claims |
disassociateVenueFromMerchant | โ | โ โ admin un-claims |
For v1, only getMerchantDetail is mounted on /me. Adding more handlers later is a single-line change in merchantSelf.js.
Client-side routing: in src/shared/lib/merchantApi.js, getMerchantDetail(merchantId) checks the current Firebase token's role claim and dispatches to the admin URL or the /me URL accordingly. Each shell calls the same function.
Merchant write paths:
- Offers (
services/api/merchants/, port 8085) โ already merchant-accessible via Firestore rules. No change. - Photos (Firebase Storage) โ existing merchant-writable rules. No change unless gaps surface.
- Business name / contact / notes / address โ currently admin-only via
PATCH /auth/admin/users/merchant/:userId(and merchant business updates). Add merchant-self versions in the migration phase if/when needed; otherwise the merchant tabs initially treat those as read-only and admin handles edits.
Migration sequencing (phased) โ
Each commit leaves the tree green.
Phase A โ Foundation (4 commits, no behavior change)
- Add path aliases to
vite.config.mjs+vitest.config.mjs. - Create empty
src/shared/,src/admin/,src/merchant/(with.gitkeep). - Add ESLint
import/no-restricted-pathsrule (zones empty initially). - Add
tooling/scripts/check-shell-isolation.mjswired intonpm run validate.
Phase B โ Move shared infrastructure (5 commits) 5. Move design tokens / styles.css to src/shared/styles/. 6. Move LanternLogo.jsx to src/shared/components/. 7. Move PageHeader, PageTabs, StyledSelect, LoadingScreen, AccessDenied to src/shared/components/. 8. Move LoginScreen, SetAdminPassword, SetMerchantPassword, SetPassword, AdminMigrationBanner, ReinitializeLanternModal to src/shared/components/. 9. Move lib/apiClient.js, lib/authApi.js, lib/currentMerchant.js to src/shared/lib/.
Phase C โ Build the shell skeletons (4 commits) 10. Create src/admin/AdminShell.jsx (initially imports admin pages from old paths). 11. Create src/merchant/MerchantShell.jsx (with placeholder pages). 12. Update App.jsx with the role fork + back-compat redirects. 13. Add requireMerchant middleware to the auth API.
Phase D โ Migrate admin pages (7 commits) 14. UserManagement subtree โ src/admin/users/ 15. Docs subtree โ src/admin/docs/ 16. API/SDK subtree โ src/admin/api/ 17. Analytics subtree โ src/admin/analytics/ 18. Misc admin pages (Billing, SystemHealth, MyAdminProfile, FeatureTracker, StorybookEmbed) โ src/admin/{billing,system,profile,feature-tracker,storybook}/ 19. Admin-side merchants management (MerchantsAll, MerchantsCreate, CreateMerchantForm + "Open in merchant mode" affordance) โ src/admin/merchants/ 20. Update AdminShell.jsx imports to @admin/* paths.
Phase E โ Migrate merchant pages (6 commits) 21. MerchantOverviewTab โ src/merchant/tabs/Overview.jsx (rename, drop "Tab" suffix). 22. MerchantOffersTab/VenuesTab/NotesTab/PhotosTab/AddressTab โ corresponding pages in src/merchant/tabs/. 23. OfferDetail, OfferForm, OffersList โ src/merchant/offers/. 24. MerchantSwitcher โ src/merchant/MerchantSwitcher.jsx. 25. Delete MerchantDetail.jsx and MerchantsIndexRedirect.jsx. 26. Replace placeholders in MerchantShell.jsx with real imports.
Phase F โ Backend dual-mount (3 commits) 27. Extract handlers from routes/adminMerchants.js to handlers/merchantHandlers.js. Old route file becomes thin wiring. 28. Add merchantSelfRoutes at routes/merchantSelf.js. Mount under /auth/merchant/me. 29. Update src/shared/lib/merchantApi.js to do role-aware URL selection.
Phase G โ Wire merchant write paths (2 commits, may grow) 30. Audit each merchant-shell page that writes data; document which endpoints are already merchant-accessible vs need /me mounting. The audit's findings drive how many follow-on commits are needed in this phase โ if every write path turns out to use services/api/merchants/ (already merchant-accessible) or read-only fields, no further commits. If gaps exist, each gap = one additional commit (one per /me mount or new endpoint). Estimate 2-6 total commits including the audit. 31. Add /me mounts and/or new endpoints for any gaps surfaced by step 30.
Phase H โ Profile + tests (4 commits) 32. Create src/merchant/profile/MyMerchantProfile.jsx. 33. Component tests for MerchantShell (renders, redirects on role mismatch, switcher visibility). 34. Component tests for AdminShell (renders, "Merchants" opens new tab โ mock window.open). 35. Route-guard test: real merchant accessing /admin/users redirects to /merchant/<theirId>/overview.
Phase I โ Final validation + PR (1 commit + close-out) 36. Run npm run validate end-to-end. Manual smoke matrix. Open PR.
Estimated total: ~36-40 commits (Phase G's audit may add a few), each small and self-contained.
Testing โ
Component tests (vitest + Testing Library) โ
src/admin/__tests__/AdminShell.test.jsx:- Renders the admin sidebar with all expected items
- "Merchants" sidebar item navigates to
/admin/merchants/all MerchantsAllrow click callswindow.open('/merchant/<id>/overview', '_blank')- Routes correctly resolve admin sub-pages
src/merchant/__tests__/MerchantShell.test.jsx:- Real merchant whose
merchantId === url:merchantIdrenders the shell - Real merchant whose
merchantId !== url:merchantIdredirects to their own - Admin viewing any merchant renders the shell with the switcher visible
- Real merchant does NOT see the switcher
- Real merchant does NOT see the "โ Admin" sidebar item
- Admin DOES see the "โ Admin" sidebar item
- Real merchant whose
src/__tests__/App.test.jsx(or update existing):- Real merchant lands on
/admin/Xโ redirects to/merchant/<theirId>/overview - Admin lands on
/โ redirects to/admin/ - Old
/usersURL โ redirects to/admin/users - Old
/merchants/<id>URL โ redirects to/merchant/<id>/overview - Real merchant lands on
/merchant/<other-id>/...โ redirects to their own
- Real merchant lands on
src/shared/lib/__tests__/merchantApi.test.js:- When current user is admin โ
getMerchantDetail(id)calls/auth/admin/merchants/:id - When current user is merchant โ
getMerchantDetail(id)calls/auth/merchant/me
- When current user is admin โ
Existing tests updated:
apps/admin/src/components/__tests__/AdminDashboard.test.jsxsupersedes / merges intoAdminShell.test.jsx. LegacyAdminDashboardcomponent goes away.CreateMerchantUserForm.test.jsx,SetMerchantPassword.test.jsxโ import paths shift; logic unchanged.
Backend tests โ
services/api/auth/src/handlers/__tests__/merchantHandlers.test.js: confirms each handler readsreq.params.merchantIdcorrectly given different mock requests.services/api/auth/src/routes/__tests__/merchantSelf.test.js: confirms the/memiddleware injectsreq.params.merchantId = req.user.merchantId.
The auth-API still has minimal route-level integration coverage. This PR does not add the integration scaffolding (deferred per existing "Known Gap"); the unit-level tests above cover the structural change.
Lint enforcement validation โ
A one-shot test fixture (tooling/scripts/lint-fixture-cross-zone.mjs) creates a temporary file with a deliberately-bad import, runs ESLint on it, asserts it fails, then deletes the file. Guards against the rule being silently disabled or misconfigured.
Build-time bundle validation โ
tooling/scripts/check-shell-isolation.mjs runs after npm run build as part of npm run validate. Reads Vite's dist/.vite/manifest.json and asserts no admin module is reachable from MerchantShell and vice versa.
Manual smoke matrix โ
| Action | Real merchant | Admin |
|---|---|---|
| Sign in | Lands on /merchant/<theirId>/overview | Lands on /admin/ |
Visit /admin/users | Redirected to own merchant overview | Renders user management |
Visit /merchant/<otherId>/overview | Redirected to own merchant | Renders other merchant's overview |
Visit old /users | Redirects, then merchant guard fires โ own merchant | Redirects to /admin/users |
Open /merchant/<id>/offers | Loads offers for own merchant only | Loads offers for the picked merchant via switcher |
| Click "Merchants" in admin sidebar | n/a | Navigates to /admin/merchants/all |
| Click row in admin merchants list | n/a | Opens /merchant/<id>/overview in a new browser tab |
| Click "โ Admin" in merchant sidebar | Doesn't appear | Returns to /admin/ |
| Sign out | Returns to LoginScreen | Returns to LoginScreen |
| Forgot password | Same parallel-fan-out as today | Same |
Files Touched โ
New โ
apps/admin/src/admin/AdminShell.jsxapps/admin/src/admin/__tests__/AdminShell.test.jsxapps/admin/src/merchant/MerchantShell.jsxapps/admin/src/merchant/MerchantSwitcher.jsx(moved + lightly adapted)apps/admin/src/merchant/__tests__/MerchantShell.test.jsxapps/admin/src/merchant/profile/MyMerchantProfile.jsxapps/admin/src/shared/lib/merchantApi.jsapps/admin/src/shared/lib/__tests__/merchantApi.test.jsservices/api/auth/src/handlers/merchantHandlers.jsservices/api/auth/src/handlers/__tests__/merchantHandlers.test.jsservices/api/auth/src/routes/merchantSelf.jsservices/api/auth/src/routes/__tests__/merchantSelf.test.jstooling/scripts/check-shell-isolation.mjstooling/scripts/lint-fixture-cross-zone.mjs
Renamed/moved (no logic change beyond import-path updates) โ
- All admin-only pages from
apps/admin/src/components/โapps/admin/src/admin/<sub>/ - All shared chrome from
apps/admin/src/components/โapps/admin/src/shared/components/ - Merchant tabs from
apps/admin/src/components/merchants/tabs/โapps/admin/src/merchant/tabs/(drop "Tab" suffix) - Merchant offer flows from
apps/admin/src/components/merchants/tabs/Offer*.jsxandOffersList.jsxโapps/admin/src/merchant/offers/ apps/admin/src/lib/*.jsโapps/admin/src/shared/lib/*.js
Modified โ
apps/admin/src/App.jsxโ role fork, back-compat redirects, URL-mode handlersapps/admin/vite.config.mjsโ path aliasesapps/admin/vitest.config.mjsโ path aliases (mirror)apps/admin/.eslintrc.jsonโimport/no-restricted-pathsruleapps/admin/package.jsonโeslint-plugin-importdep if not already presentservices/api/auth/src/routes/adminMerchants.jsโ becomes thin wiring; handlers extractedservices/api/auth/src/middleware/auth.jsโ addsrequireMerchantservices/api/auth/src/index.jsโ mountsmerchantSelfRoutesunder/auth/merchant/metooling/scripts/validate.jsโ wires the newcheck-shell-isolation+lint-fixture-cross-zonescripts
Deleted โ
apps/admin/src/components/AdminDashboard.jsx(responsibility split intoAdminShell+App.jsx)apps/admin/src/components/merchants/MerchantDetail.jsxapps/admin/src/components/merchants/MerchantsIndexRedirect.jsxapps/admin/src/components/__tests__/AdminDashboard.test.jsx(superseded byAdminShell.test.jsx)
Intentionally untouched โ
apps/web/โ Lantern consumer app unchangedfirestore.rules,storage.rulesโ no rule changes- All Cloud Run / Cloudflare deployment workflows
services/api/merchants/โ already merchant-accessible
Out of Scope (deferred) โ
apps/merchant/separate-app split โ this PR sets up the prerequisite. Separate Vite project + Cloudflare target is a future move when independent deploys justify the cost.@lantern/uishared package extraction โ not needed inside one Vite project.- Greenfield merchant dashboard / KPIs / activity feed โ Q1's option (c). Merchant landing stays on
Overview(today's read-only summary). Track via "Merchant home redesign" issue. - Merchant self-onboarding (signup, billing, plan selection) โ product decision; orthogonal to architecture.
- "View as merchant" admin impersonation โ distinct from "admin enters merchant mode." Add when QA / support workflows need it.
- Multi-role users (single uid both admin AND merchant) โ existing guards reject this state; spec doesn't change that.
- Merchant-side mutation endpoints beyond what's already wired โ additive. Drop handlers into
merchantHandlers.js, mount on/me, done. No architectural change required. - Removing the back-compat redirects โ ships in this PR; follow-up agent removes them ~2 weeks after merge.
- Visual differentiation between admin and merchant shells โ both use the same design tokens. Visual distinction is a separate
frontend-designpass. - Per-page test coverage parity โ out of scope; admin-side coverage isn't great today either.
- Non-Vite bundlers / deployment changes โ Cloudflare Pages auto-deploy and Cloud Run deploys unchanged.
If the implementer thinks any of these are needed for the PR to ship, stop and re-confirm.