Skip to content

Admin / Merchant Shell Split 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: 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.

Architecture: Top-level role fork in App.jsx after Firebase auth resolves. /admin/* renders AdminShell, /merchant/:merchantId/* renders MerchantShell. Three directories โ€” src/admin/, src/merchant/, src/shared/ โ€” with hard cross-import boundaries enforced by ESLint import/no-restricted-paths (error severity) and a build-time chunk-graph validator. Backend route handlers are extracted to pure functions and dual-mounted under /auth/admin/merchants/:id/* (admin) and /auth/merchant/me/* (merchant-self).

Tech Stack: React 19 + Vite + Vitest + Testing Library + react-router-dom (admin); Express 5 + Firebase Admin (auth API). No new top-level dependencies expected.

Spec: docs/superpowers/specs/2026-04-27-admin-merchant-shell-split-design.md


File Structure (terminal state) โ€‹

New files (admin app) โ€‹

  • apps/admin/src/admin/AdminShell.jsx โ€” sidebar + Routes for /admin/*
  • apps/admin/src/admin/__tests__/AdminShell.test.jsx
  • apps/admin/src/merchant/MerchantShell.jsx โ€” sidebar + Routes for /merchant/:merchantId/*
  • apps/admin/src/merchant/MerchantSwitcher.jsx (moved + adapted)
  • apps/admin/src/merchant/__tests__/MerchantShell.test.jsx
  • apps/admin/src/merchant/profile/MyMerchantProfile.jsx
  • apps/admin/src/shared/lib/merchantApi.js โ€” role-aware client
  • apps/admin/src/shared/lib/__tests__/merchantApi.test.js
  • apps/admin/src/__tests__/App.test.jsx (or update existing)

New files (auth API) โ€‹

  • services/api/auth/src/handlers/merchantHandlers.js โ€” extracted pure functions
  • services/api/auth/src/handlers/__tests__/merchantHandlers.test.js
  • services/api/auth/src/routes/merchantSelf.js โ€” /me mount
  • services/api/auth/src/routes/__tests__/merchantSelf.test.js

New files (tooling) โ€‹

  • tooling/scripts/check-shell-isolation.mjs โ€” bundle validator
  • tooling/scripts/lint-fixture-cross-zone.mjs โ€” lint-rule fixture test

Renamed/moved โ€‹

  • All of apps/admin/src/components/* is redistributed across src/shared/components/, src/admin/<sub>/, src/merchant/<sub>/.
  • apps/admin/src/lib/* โ†’ apps/admin/src/shared/lib/*
  • Merchant tabs lose the "Tab" suffix when they move (MerchantOverviewTab โ†’ Overview).

Modified โ€‹

  • apps/admin/vite.config.mjs โ€” path aliases
  • apps/admin/vitest.config.mjs โ€” path aliases mirror
  • apps/admin/.eslintrc.json โ€” import/no-restricted-paths rule
  • apps/admin/src/App.jsx โ€” role fork + URL-mode handlers + back-compat redirects
  • services/api/auth/src/middleware/auth.js โ€” adds requireMerchant
  • services/api/auth/src/routes/adminMerchants.js โ€” becomes thin wiring
  • services/api/auth/src/index.js โ€” mounts merchantSelfRoutes under /auth/merchant/me
  • tooling/scripts/validate.js โ€” wires the new shell + lint-fixture scripts

Deleted โ€‹

  • apps/admin/src/components/AdminDashboard.jsx
  • apps/admin/src/components/merchants/MerchantDetail.jsx
  • apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx
  • apps/admin/src/components/__tests__/AdminDashboard.test.jsx

Conventions to Follow โ€‹

  • Staging discipline (CRITICAL): the working tree on this branch contains unrelated in-flight files (packages/api-docs-renderer/*, sometimes others). Every commit MUST stage ONLY the files touched by that step. Use git add <exact paths>. NEVER use git add -A, git add ., or git commit -am. Run git -C /home/mechelle/repos/lantern_app status --short BEFORE every commit to verify only the intended files are staged. If anything else is staged, unstage it.
  • Migration pattern: every commit leaves the working tree green. After every step, npm run build -w lantern-admin and npm test -w lantern-admin --run must succeed. Auth API tests: cd services/api/auth && npm run test:run. If any of these fail, that's a blocker โ€” stop and fix or escalate.
  • Use git mv for renames so git tracks the file move with history preserved.
  • Path alias usage: once aliases are added in Phase A, any newly written import that crosses zones MUST use the alias (@admin/..., @merchant/..., @shared/...). Old imports updated in-place can use either relative or alias form, but prefer aliases when the import crosses a directory boundary.
  • Phase G is audit-driven. Step 30 surfaces the actual list of merchant write paths needing /me mounts. The exact follow-on commits depend on what the audit finds. Plan for 2-6 commits in that phase.
  • Commit messages: feat(scope):, refactor(scope):, test(scope):, chore(scope): etc. Keep them grep-able.

Task 1: Sanity check + branch verification โ€‹

Files:

  • Read: docs/superpowers/specs/2026-04-27-admin-merchant-shell-split-design.md

  • [ ] Step 1.1: Confirm branch state

bash
git -C /home/mechelle/repos/lantern_app branch --show-current
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app log --oneline -5

Expected: on claude/merchant-integration (or whatever the integration branch is named). Working tree may have unrelated in-flight files in packages/api-docs-renderer/ โ€” that's fine, leave them alone.

  • [ ] Step 1.2: Re-read the spec

Confirm v1 scope, the 9 phases, and the out-of-scope list. Everything in "Out of Scope" stays untouched.

  • [ ] Step 1.3: Capture the existing module count for sanity
bash
ls /home/mechelle/repos/lantern_app/apps/admin/src/components/ | wc -l
ls /home/mechelle/repos/lantern_app/apps/admin/src/lib/ | wc -l

Note the numbers. By Phase E end, apps/admin/src/components/ should be empty (or near-empty) and apps/admin/src/lib/ should be gone.


Phase A โ€” Foundation (Tasks 2-5) โ€‹

No behavior change. Adds infrastructure that's inert until later phases use it.

Task 2: Add path aliases to Vite + Vitest configs โ€‹

Files:

  • Modify: apps/admin/vite.config.mjs

  • Modify: apps/admin/vitest.config.mjs

  • [ ] Step 2.1: Inspect the current configs

bash
cat /home/mechelle/repos/lantern_app/apps/admin/vite.config.mjs | head -50
cat /home/mechelle/repos/lantern_app/apps/admin/vitest.config.mjs

Find the resolve.alias block in each (vitest.config.mjs already has '@' mapped to './src').

  • [ ] Step 2.2: Add the three new aliases to vite.config.mjs

In the resolve.alias block, add:

js
'@admin': path.resolve(__dirname, 'src/admin'),
'@merchant': path.resolve(__dirname, 'src/merchant'),
'@shared': path.resolve(__dirname, 'src/shared'),

Make sure path and __dirname are imported / available at the top of the file (the existing code already uses path.resolve(__dirname, ...) for the existing alias, so they should already be).

  • [ ] Step 2.3: Mirror the aliases in vitest.config.mjs

Same three lines in the same resolve.alias block.

  • [ ] Step 2.4: Verify build still succeeds
bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds. The aliases point to non-existent directories, but no code uses them yet so no error fires.

  • [ ] Step 2.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/vite.config.mjs apps/admin/vitest.config.mjs
git -C /home/mechelle/repos/lantern_app status --short
# Confirm only those two files are staged.
git -C /home/mechelle/repos/lantern_app commit -m "chore(admin): add @admin/@merchant/@shared path aliases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Create empty shell zone directories โ€‹

Files:

  • Create: apps/admin/src/shared/.gitkeep

  • Create: apps/admin/src/admin/.gitkeep

  • Create: apps/admin/src/merchant/.gitkeep

  • [ ] Step 3.1: Create the three directories with .gitkeep placeholder files

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/merchant
touch /home/mechelle/repos/lantern_app/apps/admin/src/shared/.gitkeep
touch /home/mechelle/repos/lantern_app/apps/admin/src/admin/.gitkeep
touch /home/mechelle/repos/lantern_app/apps/admin/src/merchant/.gitkeep
  • [ ] Step 3.2: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/shared/.gitkeep apps/admin/src/admin/.gitkeep apps/admin/src/merchant/.gitkeep
git -C /home/mechelle/repos/lantern_app commit -m "chore(admin): scaffold shared/admin/merchant shell zones

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Add ESLint import/no-restricted-paths rule โ€‹

Files:

  • Modify: apps/admin/.eslintrc.json (or whichever ESLint config the workspace uses)

  • [ ] Step 4.1: Inspect the current ESLint setup

bash
ls /home/mechelle/repos/lantern_app/apps/admin/.eslint*
ls /home/mechelle/repos/lantern_app/.eslint*
cat /home/mechelle/repos/lantern_app/apps/admin/.eslintrc.json 2>/dev/null || cat /home/mechelle/repos/lantern_app/.eslintrc.json | head -40

Confirm eslint-plugin-import is in the resolved plugin list (search package.json files).

  • [ ] Step 4.2: Confirm eslint-plugin-import is available
bash
grep -l "eslint-plugin-import" /home/mechelle/repos/lantern_app/apps/admin/package.json /home/mechelle/repos/lantern_app/package.json 2>/dev/null

If it's not in apps/admin/package.json or root package.json, add it:

bash
npm i -D -w lantern-admin eslint-plugin-import

(Only run the install if missing โ€” most React + ESLint setups already have it via a preset.)

  • [ ] Step 4.3: Add the zone rule

If apps/admin/.eslintrc.json exists, add the rule. Otherwise create it.

json
{
  "plugins": ["import"],
  "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."
        }
      ]
    }]
  }
}

If a config already exists, merge into its existing structure (preserve other plugins/rules). The key constraint: the rule must apply to files inside apps/admin/src/. If the workspace inherits from a root config that doesn't have eslint-plugin-import, add the plugin import + the rule scoped to this workspace.

  • [ ] Step 4.4: Verify lint passes
bash
npm run lint -w lantern-admin 2>&1 | tail -10

Expected: no new errors. The zones are still empty (only .gitkeep files), so the rule is trivially satisfied.

If npm run lint doesn't exist on the workspace, this is a known gap โ€” flag it as DONE_WITH_CONCERNS and note that the rule won't be enforced until a lint script is added. (Currently the workspace runs lint as part of npm run validate, so check there first.)

  • [ ] Step 4.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/.eslintrc.json
git -C /home/mechelle/repos/lantern_app commit -m "chore(admin): enforce shell zone boundaries with import/no-restricted-paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

(If you also touched root package.json or apps/admin/package.json to add eslint-plugin-import, include those โ€” verify with git status --short.)


Task 5: Add check-shell-isolation.mjs bundle validator โ€‹

Files:

  • Create: tooling/scripts/check-shell-isolation.mjs

  • Modify: tooling/scripts/validate.js (wire it into the validate pipeline)

  • [ ] Step 5.1: Inspect tooling/scripts/validate.js to understand the existing pipeline

bash
grep -n "scope\|workspace\|step\|register" /home/mechelle/repos/lantern_app/tooling/scripts/validate.js | head -20
cat /home/mechelle/repos/lantern_app/tooling/scripts/validate.js | head -80

Note how existing checks are registered (likely a list of named steps with shell commands). The new check is a Node script that reads Vite's manifest.

  • [ ] Step 5.2: Create tooling/scripts/check-shell-isolation.mjs
js
#!/usr/bin/env node
/**
 * Verifies that the admin and merchant Vite chunks don't bleed into each other.
 *
 * Reads dist/.vite/manifest.json (produced by the admin app build) and walks the
 * import graph from each shell entry. If a chunk reachable from MerchantShell.jsx
 * imports anything under src/admin/ (or vice versa), exit non-zero.
 *
 * Run after `npm run build -w lantern-admin`.
 */

import fs from 'node:fs/promises'
import path from 'node:path'

const REPO_ROOT = path.resolve(new URL('../..', import.meta.url).pathname)
const MANIFEST = path.join(REPO_ROOT, 'apps/admin/dist/.vite/manifest.json')

const ADMIN_PREFIX = 'src/admin/'
const MERCHANT_PREFIX = 'src/merchant/'

async function main() {
  let manifest
  try {
    manifest = JSON.parse(await fs.readFile(MANIFEST, 'utf8'))
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.log('check-shell-isolation: skipping โ€” admin app not built yet')
      process.exit(0)
    }
    throw err
  }

  const violations = []

  // Walk every entry. For each chunk, list its source modules. Flag if a
  // module under src/merchant/ ends up in a chunk reachable from src/admin/
  // entries (or vice versa).
  for (const [key, entry] of Object.entries(manifest)) {
    if (!entry.isEntry) continue
    if (key.includes('AdminShell') || key.includes('admin/')) {
      const merchantSources = (entry.css || [])
        .concat(entry.assets || [])
        .concat([entry.file])
        .concat(entry.imports || [])
        .filter((s) => s && s.includes(MERCHANT_PREFIX))
      if (merchantSources.length > 0) {
        violations.push(`AdminShell entry pulls in merchant code: ${merchantSources.join(', ')}`)
      }
    }
    if (key.includes('MerchantShell') || key.includes('merchant/')) {
      const adminSources = (entry.css || [])
        .concat(entry.assets || [])
        .concat([entry.file])
        .concat(entry.imports || [])
        .filter((s) => s && s.includes(ADMIN_PREFIX))
      if (adminSources.length > 0) {
        violations.push(`MerchantShell entry pulls in admin code: ${adminSources.join(', ')}`)
      }
    }
  }

  if (violations.length > 0) {
    console.error('check-shell-isolation: cross-shell module imports detected:')
    for (const v of violations) console.error(`  โ€ข ${v}`)
    process.exit(1)
  }
  console.log('check-shell-isolation: OK โ€” shells are isolated in the bundle')
}

main().catch((err) => {
  console.error('check-shell-isolation: error', err)
  process.exit(2)
})

NOTE: Vite's manifest format walks differently in practice. The above is a starting heuristic. After Phase D + E lands (when there are real shells to validate against), refine the script if needed. For now, the script being present and runnable is enough โ€” it gracefully no-ops when there's no built output.

  • [ ] Step 5.3: Wire it into tooling/scripts/validate.js

Locate the validate.js scope/step registry (the structure varies โ€” read the file to figure out where new steps go). Add a new step that runs node tooling/scripts/check-shell-isolation.mjs. Tag it under the same scope as test or build so it runs as part of npm run validate.

If you can't determine where to plug it in safely, add a top-level npm script in package.json:

json
{
  "scripts": {
    "check:shell-isolation": "node tooling/scripts/check-shell-isolation.mjs"
  }
}

โ€ฆand document in the spec follow-up that it should be wired into validate.js as a follow-up. For now, ensure it's at minimum invokable.

  • [ ] Step 5.4: Verify it runs
bash
node /home/mechelle/repos/lantern_app/tooling/scripts/check-shell-isolation.mjs

Expected (no admin build yet): check-shell-isolation: skipping โ€” admin app not built yet. Exit code 0.

bash
npm run build -w lantern-admin 2>&1 | tail -5
node /home/mechelle/repos/lantern_app/tooling/scripts/check-shell-isolation.mjs

Expected: check-shell-isolation: OK โ€” shells are isolated in the bundle (or similar). Exit code 0. (If it errors with a manifest parsing issue, that's a real bug in the script โ€” fix it.)

  • [ ] Step 5.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add tooling/scripts/check-shell-isolation.mjs
# Plus tooling/scripts/validate.js or package.json depending on what you wired
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "chore(tooling): add check-shell-isolation bundle validator

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase B โ€” Move shared infrastructure (Tasks 6-10) โ€‹

Move global chrome and lib code into src/shared/. Each move is a single commit. Update consumers' import paths in the same commit (otherwise the build breaks).

Task 6: Move design tokens / styles.css to src/shared/styles/ โ€‹

Files:

  • Move: apps/admin/src/styles.css (or wherever the global stylesheet lives) โ†’ apps/admin/src/shared/styles/styles.css

  • Modify: any file that imports the stylesheet (typically src/main.jsx)

  • [ ] Step 6.1: Locate the global stylesheet and its consumers

bash
ls /home/mechelle/repos/lantern_app/apps/admin/src/*.css 2>/dev/null
grep -rn "styles.css\|index.css\|main.css" /home/mechelle/repos/lantern_app/apps/admin/src/ | head -10

Identify the global stylesheet file and every place it's imported.

  • [ ] Step 6.2: git mv it into src/shared/styles/
bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared/styles
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/styles.css apps/admin/src/shared/styles/styles.css

Adjust path if the actual file lives elsewhere.

  • [ ] Step 6.3: Update consumers' import paths

For each file that imported the old path, update to import from './shared/styles/styles.css' or use the alias @shared/styles/styles.css. Most likely just apps/admin/src/main.jsx:

js
// before
import './styles.css'
// after
import './shared/styles/styles.css'
  • [ ] Step 6.4: Verify build
bash
npm run build -w lantern-admin 2>&1 | tail -10

Expected: build succeeds. Visual smoke should be unchanged because the stylesheet contents didn't change.

  • [ ] Step 6.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/main.jsx apps/admin/src/shared/styles/styles.css
# Ensure git status --short shows only the rename + import update
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move global styles to src/shared/styles/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Move LanternLogo, LoadingScreen, AccessDenied, AdminMigrationBanner to src/shared/components/ โ€‹

Files:

  • Move: apps/admin/src/components/LanternLogo.jsx โ†’ apps/admin/src/shared/components/LanternLogo.jsx

  • Move: apps/admin/src/components/LoadingScreen.jsx โ†’ apps/admin/src/shared/components/LoadingScreen.jsx

  • Move: apps/admin/src/components/AccessDenied.jsx โ†’ apps/admin/src/shared/components/AccessDenied.jsx

  • Move: apps/admin/src/components/AdminMigrationBanner.jsx โ†’ apps/admin/src/shared/components/AdminMigrationBanner.jsx

  • Modify: every consumer's import paths

  • [ ] Step 7.1: Find consumers

bash
grep -rn "from '.*LanternLogo'" /home/mechelle/repos/lantern_app/apps/admin/src/ | head -20
grep -rn "from '.*LoadingScreen'" /home/mechelle/repos/lantern_app/apps/admin/src/ | head -20
grep -rn "from '.*AccessDenied'" /home/mechelle/repos/lantern_app/apps/admin/src/ | head -20
grep -rn "from '.*AdminMigrationBanner'" /home/mechelle/repos/lantern_app/apps/admin/src/ | head -20
  • [ ] Step 7.2: Move each file with git mv
bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared/components
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/LanternLogo.jsx apps/admin/src/shared/components/LanternLogo.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/LoadingScreen.jsx apps/admin/src/shared/components/LoadingScreen.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/AccessDenied.jsx apps/admin/src/shared/components/AccessDenied.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/AdminMigrationBanner.jsx apps/admin/src/shared/components/AdminMigrationBanner.jsx
  • [ ] Step 7.3: Update import paths in every consumer

For each consumer file found in 7.1, update the import. Use the @shared/ alias for cross-zone imports; for files that are still in src/components/ (i.e., not yet moved), use a relative path that works.

Example transform:

js
// before
import LanternLogo from './LanternLogo'
// after (when consumer is in src/components/)
import LanternLogo from '../shared/components/LanternLogo'
// or
import LanternLogo from '@shared/components/LanternLogo'
  • [ ] Step 7.4: Verify build
bash
npm run build -w lantern-admin 2>&1 | tail -10
  • [ ] Step 7.5: Verify tests still pass
bash
npm test -w lantern-admin -- --run 2>&1 | tail -10

Expected: tests pass. If any test imports the moved component, update its path too.

  • [ ] Step 7.6: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
# Should show only the four moved files + any consumer updates
git -C /home/mechelle/repos/lantern_app add -u  # picks up modifications
# (or add specific files if you're worried about other in-flight files)
git -C /home/mechelle/repos/lantern_app status --short
# Verify only the intended files are staged

git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move chrome utilities to src/shared/components/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

NOTE on git add -u: this stages modifications to tracked files only (not new files). It can pick up unrelated in-flight edits to tracked files. If status --short shows surprises, use specific file paths instead.


Task 8: Move PageHeader, PageTabs, StyledSelect, Toolbar to src/shared/components/ โ€‹

Files:

  • Move: each of the four files โ†’ apps/admin/src/shared/components/

  • Modify: every consumer

  • [ ] Step 8.1: Move with git mv (mirror Task 7's pattern)

bash
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/PageHeader.jsx apps/admin/src/shared/components/PageHeader.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/PageTabs.jsx apps/admin/src/shared/components/PageTabs.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/StyledSelect.jsx apps/admin/src/shared/components/StyledSelect.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/Toolbar.jsx apps/admin/src/shared/components/Toolbar.jsx
  • [ ] Step 8.2: Update consumers
bash
grep -rln "from '.*PageHeader'\|from '.*PageTabs'\|from '.*StyledSelect'\|from '.*Toolbar'" /home/mechelle/repos/lantern_app/apps/admin/src/

For each match, update import paths. Prefer @shared/components/X.

  • [ ] Step 8.3: Build + test verification
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 8.4: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u
# Or add specific files if status --short surfaces unrelated changes.
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move PageHeader/PageTabs/StyledSelect/Toolbar to src/shared/components/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 9: Move auth-flow screens (LoginScreen, Set*Password, ReinitializeLanternModal) to src/shared/components/ โ€‹

Files:

  • Move:

    • apps/admin/src/components/LoginScreen.jsx
    • apps/admin/src/components/SetAdminPassword.jsx
    • apps/admin/src/components/SetMerchantPassword.jsx
    • apps/admin/src/components/SetPassword.jsx
    • apps/admin/src/components/SetupAdminPasswordModal.jsx
    • apps/admin/src/components/ReinitializeLanternModal.jsx
  • Move associated tests (apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx โ†’ apps/admin/src/shared/components/__tests__/SetMerchantPassword.test.jsx)

  • Modify: every consumer

  • [ ] Step 9.1: Move files with git mv

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared/components/__tests__
for f in LoginScreen SetAdminPassword SetMerchantPassword SetPassword SetupAdminPasswordModal ReinitializeLanternModal; do
  git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/${f}.jsx apps/admin/src/shared/components/${f}.jsx
done

If a test file exists for SetMerchantPassword, move it too:

bash
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx apps/admin/src/shared/components/__tests__/SetMerchantPassword.test.jsx
  • [ ] Step 9.2: Update consumers + test import paths
bash
grep -rln "from '.*LoginScreen'\|from '.*SetAdminPassword'\|from '.*SetMerchantPassword'\|from '.*SetPassword'\|from '.*SetupAdminPasswordModal'\|from '.*ReinitializeLanternModal'" /home/mechelle/repos/lantern_app/apps/admin/src/

Update each consumer to @shared/components/X.

The SetMerchantPassword.test.jsx mock paths also shift โ€” vi.mock('../../firebase', ...) becomes vi.mock('../../../firebase', ...) (or use the absolute alias @/firebase). Verify the test still passes.

  • [ ] Step 9.3: Build + test verification
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 9.4: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move auth-flow screens to src/shared/components/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: Move apps/admin/src/lib/* to apps/admin/src/shared/lib/ โ€‹

Files:

  • Move all files in apps/admin/src/lib/ โ†’ apps/admin/src/shared/lib/

  • Move apps/admin/src/lib/__tests__/ โ†’ apps/admin/src/shared/lib/__tests__/

  • Modify: every consumer

  • [ ] Step 10.1: Inspect what's there

bash
ls /home/mechelle/repos/lantern_app/apps/admin/src/lib/
ls /home/mechelle/repos/lantern_app/apps/admin/src/lib/__tests__/ 2>/dev/null

You should see at least: apiClient.js, authApi.js, currentMerchant.js, analyticsApi.js, assistantApi.js, docsApi.js, merchantsApi.js, offerNormalizer.js, plus any tests.

NOTE: analyticsApi.js, assistantApi.js, docsApi.js, merchantsApi.js are arguably admin-side or merchant-side rather than truly shared. Move all of them to src/shared/lib/ for now (preserves the current "everything is in lib/" mental model). Future cleanup can re-zone individual files when their callers are unambiguously one shell.

  • [ ] Step 10.2: Move with git mv
bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared/lib
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/shared/lib/__tests__
for f in $(ls /home/mechelle/repos/lantern_app/apps/admin/src/lib/ | grep -v __tests__); do
  git -C /home/mechelle/repos/lantern_app mv "apps/admin/src/lib/$f" "apps/admin/src/shared/lib/$f"
done
for f in $(ls /home/mechelle/repos/lantern_app/apps/admin/src/lib/__tests__/ 2>/dev/null); do
  git -C /home/mechelle/repos/lantern_app mv "apps/admin/src/lib/__tests__/$f" "apps/admin/src/shared/lib/__tests__/$f"
done
rmdir /home/mechelle/repos/lantern_app/apps/admin/src/lib/__tests__ 2>/dev/null || true
rmdir /home/mechelle/repos/lantern_app/apps/admin/src/lib 2>/dev/null || true
  • [ ] Step 10.3: Update consumers
bash
grep -rln "from '.*src/lib/\|from '\(\.\./\)*lib/" /home/mechelle/repos/lantern_app/apps/admin/src/

For each consumer, update the import to point at @shared/lib/X. Be careful: dynamic imports like import('./lib/authApi') (used inside firebase.js for re-exports) need updating too. Search:

bash
grep -rn "import('./lib" /home/mechelle/repos/lantern_app/apps/admin/src/
grep -rn "import('../lib" /home/mechelle/repos/lantern_app/apps/admin/src/

Update each to import('./shared/lib/...') or the alias form.

  • [ ] Step 10.4: Build + test verification
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5

If any test mocks '../../firebase' or similar relative paths and that mocked module's path shifted, the mock will silently miss โ€” verify firebase.js itself didn't move (it didn't; it stays at the root) and that mocks targeting '../../firebase' from __tests__ still resolve.

  • [ ] Step 10.5: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move src/lib/ to src/shared/lib/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase C โ€” Build the shell skeletons (Tasks 11-14) โ€‹

Task 11: Create src/admin/AdminShell.jsx (mirroring AdminDashboard's chrome) โ€‹

Files:

  • Create: apps/admin/src/admin/AdminShell.jsx

  • [ ] Step 11.1: Read AdminDashboard.jsx to understand the chrome layout

bash
cat /home/mechelle/repos/lantern_app/apps/admin/src/components/AdminDashboard.jsx | head -120

Note:

  • Sidebar structure (search for expandedSections, the sidebar nav array, the <aside> or sidebar <div> block)

  • Top bar / page header layout

  • The main <Routes> block with all the admin route definitions (~lines 555-630)

  • The useEffect redirect for merchantOnly users (we'll DELETE this in AdminShell โ€” that's the role fork's job now)

  • [ ] Step 11.2: Create apps/admin/src/admin/AdminShell.jsx

The new file is roughly a copy of AdminDashboard's chrome + Routes block, with these changes:

  • Drop the merchantOnly flag and the useEffect redirect (App.jsx handles role routing now).
  • All routes are RELATIVE to /admin โ€” the parent in App.jsx mounts AdminShell at /admin/*, so <Route path="/" element={<DashboardHome />} /> is now /admin/, etc.
  • Update import paths: DashboardHome, UserManagement, MerchantsAll, etc., still live in apps/admin/src/components/... for now (they get moved in Phase D). Use their current paths.
  • Sidebar: same items as AdminDashboard's, EXCEPT remove any merchant-only items (e.g., the merchantOnly branch). The sidebar is admin-only now.
  • Routes for the merchant detail tree (/merchants/:merchantId/*) are REMOVED from AdminShell โ€” admins access merchant content via the merchant shell only. Keep /admin/merchants (list) and /admin/merchants/new (create).

Key parts (paraphrased):

jsx
import React, { useState, useEffect } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation, Outlet } from 'react-router-dom'
// ... all the icon imports from AdminDashboard ...
import LanternLogo from '@shared/components/LanternLogo'
import LoadingScreen from '@shared/components/LoadingScreen'
import PageHeader from '@shared/components/PageHeader'
// ... more shared imports ...

// Admin-side pages (still in old locations until Phase D):
import DashboardHome from '../components/DashboardHome'  // or wherever
import UserManagement from '../components/UserManagement'
import DocsEditor from '../components/DocsEditor'
import StorybookEmbed from '../components/StorybookEmbed'
import ApiOverview from '../components/ApiOverview'
import ApiReferencePage from '../components/ApiReferencePage'
import ClientSdkPage from '../components/ClientSdkPage'
// ... etc ...
import MerchantsAll from '../components/merchants/MerchantsAll'
import MerchantsCreate from '../components/merchants/MerchantsCreate'

export default function AdminShell({ user, onSignOut, ... }) {
  // Sidebar markup (paraphrased โ€” copy from AdminDashboard)
  // No merchantOnly redirect.
  // Routes block (relative paths, since mounted at /admin/*):
  return (
    <div className="admin-dashboard-container">
      <aside className="sidebar">
        {/* sidebar markup */}
      </aside>
      <main>
        <Routes>
          <Route path="/" element={<DashboardHome onNavigate={...} />} />
          <Route path="users" element={<UserManagement currentUser={user} />} />
          <Route path="merchants" element={<Navigate to="/admin/merchants/all" replace />} />
          <Route path="merchants/all" element={<MerchantsAll />} />
          <Route path="merchants/new" element={<MerchantsCreate />} />
          {/* docs, storybook, api, config, analytics, system, billing, profile, feature-tracker */}
          <Route path="*" element={<Navigate to="/admin" replace />} />
        </Routes>
      </main>
    </div>
  )
}

The exact sidebar markup, navigation, and route entries should mirror what AdminDashboard does today. The key difference is no merchant routes, and paths are admin-relative.

  • [ ] Step 11.3: Verify build

AdminShell.jsx is created but not yet wired into App.jsx. Build should still succeed (file just isn't imported anywhere yet).

bash
npm run build -w lantern-admin 2>&1 | tail -5
  • [ ] Step 11.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/admin/AdminShell.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): scaffold AdminShell (admin-only routes + sidebar)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: Create src/merchant/MerchantShell.jsx skeleton (placeholder pages) โ€‹

Files:

  • Create: apps/admin/src/merchant/MerchantShell.jsx

  • [ ] Step 12.1: Read MerchantDetail.jsx for context

bash
cat /home/mechelle/repos/lantern_app/apps/admin/src/components/merchants/MerchantDetail.jsx | head -120

This shows how the merchant detail page composes its tabs today. We'll keep similar data-loading semantics but invert the layout (sidebar instead of horizontal tabs).

  • [ ] Step 12.2: Create apps/admin/src/merchant/MerchantShell.jsx with placeholder pages
jsx
import React, { useEffect, useState } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation, useParams, Link } from 'react-router-dom'
import {
  ClipboardList, Tag, MapPin, StickyNote, Image, Home, User as UserIcon, ChevronLeft, LogOut,
} from 'lucide-react'
import LanternLogo from '@shared/components/LanternLogo'
import LoadingScreen from '@shared/components/LoadingScreen'

const TABS = [
  { id: 'overview', label: 'Overview', icon: ClipboardList },
  { id: 'offers',   label: 'Offers',   icon: Tag },
  { id: 'venues',   label: 'Venues',   icon: MapPin },
  { id: 'notes',    label: 'Notes',    icon: StickyNote },
  { id: 'photos',   label: 'Photos',   icon: Image },
  { id: 'address',  label: 'Address',  icon: Home },
  { id: 'profile',  label: 'Profile',  icon: UserIcon },
]

function Placeholder({ title }) {
  return (
    <div style={{ padding: 'var(--space-6)' }}>
      <h2>{title}</h2>
      <p className="text-muted">Migrating from <code>apps/admin/src/components/merchants/tabs/</code> in Phase E.</p>
    </div>
  )
}

export default function MerchantShell({ user, isAdmin, onSignOut }) {
  const { merchantId: urlMerchantId } = useParams()
  const navigate = useNavigate()
  const location = useLocation()

  // Real merchants must be viewing their own merchant. Admins are free.
  const ownMerchantId = user?.merchantId || null
  if (!isAdmin && ownMerchantId && urlMerchantId !== ownMerchantId) {
    return <Navigate to={`/merchant/${ownMerchantId}/overview`} replace />
  }

  const activeTab = location.pathname.split('/').pop() || 'overview'

  return (
    <div className="merchant-shell">
      <aside className="sidebar">
        <div className="sidebar-header">
          <LanternLogo size={32} />
          <span>Merchant</span>
        </div>
        <nav>
          {TABS.map((t) => (
            <Link
              key={t.id}
              to={`/merchant/${urlMerchantId}/${t.id}`}
              className={`sidebar-item ${activeTab === t.id ? 'active' : ''}`}
            >
              <t.icon size={16} />
              <span>{t.label}</span>
            </Link>
          ))}
          {isAdmin && (
            <Link to="/admin" className="sidebar-item sidebar-item--secondary">
              <ChevronLeft size={16} />
              <span>Admin</span>
            </Link>
          )}
          <button onClick={onSignOut} className="sidebar-item sidebar-item--button">
            <LogOut size={16} />
            <span>Sign out</span>
          </button>
        </nav>
      </aside>
      <main>
        <Routes>
          <Route path="/" element={<Navigate to="overview" replace />} />
          <Route path="overview" element={<Placeholder title="Overview" />} />
          <Route path="offers/*" element={<Placeholder title="Offers" />} />
          <Route path="venues" element={<Placeholder title="Venues" />} />
          <Route path="notes" element={<Placeholder title="Notes" />} />
          <Route path="photos" element={<Placeholder title="Photos" />} />
          <Route path="address" element={<Placeholder title="Address" />} />
          <Route path="profile" element={<Placeholder title="Profile" />} />
          <Route path="*" element={<Navigate to="overview" replace />} />
        </Routes>
      </main>
    </div>
  )
}

This is a skeleton โ€” real pages get wired in Phase E. The shell is renderable + role-guarded as of this commit.

  • [ ] Step 12.3: Verify build
bash
npm run build -w lantern-admin 2>&1 | tail -5

The shell isn't reachable yet (App.jsx doesn't render it), but the file should compile.

  • [ ] Step 12.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/merchant/MerchantShell.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): scaffold MerchantShell with placeholder pages + role guard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: Update App.jsx with the role fork + back-compat redirects โ€‹

Files:

  • Modify: apps/admin/src/App.jsx

This is the load-bearing change. After this task, real merchants who sign in see MerchantShell (with placeholder pages); admins see AdminShell.

  • [ ] Step 13.1: Read the current App.jsx
bash
cat /home/mechelle/repos/lantern_app/apps/admin/src/App.jsx

Note:

  • Current rendering pattern (likely <AdminDashboard user={user} ... />)

  • Existing URL-mode handlers (adminReset, merchantReset, resetPassword)

  • The auth state machine (loading, !user, signed-in)

  • [ ] Step 13.2: Replace the post-auth render block with the role fork

Imports near the top:

js
import AdminShell from './admin/AdminShell'
import MerchantShell from './merchant/MerchantShell'

Drop the import AdminDashboard from './components/AdminDashboard' (or comment it out for now โ€” we'll delete the file in Task 25).

In the render section, after the URL-mode handlers and after the loading + !user guards, replace:

jsx
return <AdminDashboard user={user} isAdmin={isAdmin} isMerchant={isMerchant} ... />

with the role fork:

jsx
const role = user?.customClaims?.role || (isAdmin ? 'admin' : isMerchant ? 'merchant' : null)
const ownMerchantId = user?.merchantId || null

// Real merchants without a merchantId โ€” defensive, shouldn't happen in normal flow
if (role === 'merchant' && !ownMerchantId) {
  return <NoMerchantAttached onSignOut={handleSignOut} />  // (or just redirect to login)
}

// Path-based redirects (back-compat + role-based defaults)
const path = location.pathname

// Real merchants visiting /admin/* or root โ†’ bounce to their own merchant
if (role === 'merchant' && (path === '/' || path.startsWith('/admin'))) {
  return <Navigate to={`/merchant/${ownMerchantId}/overview`} replace />
}

// Admins visiting / โ†’ admin home
if (role === 'admin' && path === '/') {
  return <Navigate to="/admin" replace />
}

// Old admin URLs (back-compat for one release cycle)
const oldUrlMap = [
  ['/users', '/admin/users'],
  ['/docs', '/admin/docs'],
  ['/storybook', '/admin/storybook'],
  ['/client-sdk', '/admin/api/client-sdk'],
  ['/system', '/admin/system'],
  ['/billing', '/admin/billing'],
  ['/profile', '/admin/profile'],
  ['/feature-tracker', '/admin/feature-tracker'],
  ['/api', '/admin/api'],
  ['/config', '/admin/config'],
  ['/analytics', '/admin/analytics'],
  ['/merchants/all', '/admin/merchants/all'],
  ['/merchants/new', '/admin/merchants/new'],
  ['/merchants', '/admin/merchants/all'],
]
for (const [oldPrefix, newPrefix] of oldUrlMap) {
  if (path === oldPrefix || path.startsWith(oldPrefix + '/')) {
    const rest = path.slice(oldPrefix.length)
    return <Navigate to={newPrefix + rest} replace />
  }
}
// Old /merchants/:merchantId/<tab> โ†’ /merchant/:merchantId/<tab> (pluralโ†’singular)
const oldMerchantDetailMatch = path.match(/^\/merchants\/([^/]+)(\/.*)?$/)
if (oldMerchantDetailMatch) {
  const [, merchantId, rest] = oldMerchantDetailMatch
  return <Navigate to={`/merchant/${merchantId}${rest || '/overview'}`} replace />
}

// Render the appropriate shell
return (
  <Routes>
    <Route path="/admin/*" element={role === 'admin' ? <AdminShell user={user} isAdmin onSignOut={handleSignOut} /> : <Navigate to="/admin" replace />} />
    <Route path="/merchant/:merchantId/*" element={<MerchantShell user={user} isAdmin={role === 'admin'} onSignOut={handleSignOut} />} />
    <Route path="*" element={<Navigate to={role === 'admin' ? '/admin' : `/merchant/${ownMerchantId}/overview`} replace />} />
  </Routes>
)

You may need to lift Routes/useLocation imports โ€” react-router-dom should already be imported. Add useLocation if missing.

The exact code structure depends on the existing App.jsx โ€” some bits like handleSignInWithEmail, handleSignOut, handlePasswordSetComplete, setCorruptionDetectedAt etc. all stay. The change is replacing the single rendering of AdminDashboard with the role-based shell selection.

  • [ ] Step 13.3: Verify build
bash
npm run build -w lantern-admin 2>&1 | tail -5
  • [ ] Step 13.4: Manual smoke (if you have a running local dev server)

If you can spin up the admin dev server and use a known admin Firebase user:

  • Visit / โ†’ should redirect to /admin.
  • Visit /users โ†’ should redirect to /admin/users.
  • Sign out and sign back in โ†’ admins land on /admin/.

For a merchant user (use one of the test accounts created via earlier tasks):

  • Sign in โ†’ should land on /merchant/<theirId>/overview (showing the placeholder page).
  • Visit /admin/users โ†’ should redirect to /merchant/<theirId>/overview.

If you can't run a live server, run automated tests; we'll add a real test in Task 36 anyway.

  • [ ] Step 13.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/App.jsx
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): role-fork in App.jsx, mounting AdminShell vs MerchantShell

Adds back-compat redirects from old admin URLs to /admin/* and from old
/merchants/:id/* to /merchant/:id/*. Real merchants are bounced to their
own merchant overview if they hit any admin URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: Add requireMerchant middleware to the auth API โ€‹

Files:

  • Modify: services/api/auth/src/middleware/auth.js

  • [ ] Step 14.1: Inspect existing middleware

bash
cat /home/mechelle/repos/lantern_app/services/api/auth/src/middleware/auth.js

Find requireAdmin. The new requireMerchant is a direct mirror.

  • [ ] Step 14.2: Add the middleware

After requireAdmin, append:

js
export function requireMerchant(req, res, next) {
  if ((req.user?.role) !== 'merchant') {
    return res.status(403).json({ error: 'FORBIDDEN', message: 'Merchant role required' })
  }
  next()
}
  • [ ] Step 14.3: Verify syntax
bash
cd /home/mechelle/repos/lantern_app/services/api/auth && node --check src/middleware/auth.js
cd /home/mechelle/repos/lantern_app/services/api/auth && npm run test:run 2>&1 | tail -5
  • [ ] Step 14.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/middleware/auth.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): add requireMerchant middleware

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase D โ€” Migrate admin pages (Tasks 15-21) โ€‹

Each task moves one admin sub-tree from src/components/... to src/admin/<sub>/. After each, AdminShell's import paths get updated from '../components/...' to '@admin/<sub>/...'. Build + tests verify after each.

Task 15: Move UserManagement, UserDetailPanel, CreateAdminForm, CreateMerchantUserForm, AttachMerchantDialog to src/admin/users/ โ€‹

Files:

  • Move: 5 component files + their __tests__ to apps/admin/src/admin/users/

  • Modify: AdminShell import paths

  • [ ] Step 15.1: Move files

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin/users/__tests__
for f in UserManagement UserDetailPanel CreateAdminForm CreateMerchantUserForm AttachMerchantDialog; do
  git -C /home/mechelle/repos/lantern_app mv "apps/admin/src/components/${f}.jsx" "apps/admin/src/admin/users/${f}.jsx"
done

# Tests
for f in $(ls /home/mechelle/repos/lantern_app/apps/admin/src/components/__tests__/ | grep -E "UserManagement|UserDetailPanel|CreateAdminForm|CreateMerchantUserForm|AttachMerchantDialog"); do
  git -C /home/mechelle/repos/lantern_app mv "apps/admin/src/components/__tests__/${f}" "apps/admin/src/admin/users/__tests__/${f}"
done
  • [ ] Step 15.2: Update import paths in moved files

Each moved file imports from './X' (other components in old src/components/) and from '../firebase'. Update:

  • Imports of moved siblings within the new src/admin/users/ directory: stay relative (e.g., import UserDetailPanel from './UserDetailPanel').
  • Imports of ../firebase: become '../../firebase' (one extra level up).
  • Imports of ../shared/...: become '@shared/...'.
  • Imports of lucide-react, react, etc.: unchanged.

grep -n "from '" apps/admin/src/admin/users/*.jsx to surface every import.

  • [ ] Step 15.3: Update AdminShell import paths

In apps/admin/src/admin/AdminShell.jsx, change:

js
import UserManagement from '../components/UserManagement'

to:

js
import UserManagement from '@admin/users/UserManagement'

(or use relative './users/UserManagement').

  • [ ] Step 15.4: Update test mock paths

Test files like UserDetailPanel.test.jsx had vi.mock('../../firebase', ...). Now they're at apps/admin/src/admin/users/__tests__/, so the path to firebase.js is '../../../firebase' (three levels up).

  • [ ] Step 15.5: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -10

All tests pass. Build green.

  • [ ] Step 15.6: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/admin/ apps/admin/src/components/
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move user management into src/admin/users/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 16: Move docs subtree (DocsEditor, MarkdownEditor, DocsEmbed, DocPage, SelfHostedDocsEditor) to src/admin/docs/ โ€‹

Files:

  • Move: 5 files โ†’ apps/admin/src/admin/docs/

  • Modify: AdminShell import paths + any cross-references inside the moved files

  • [ ] Step 16.1: Move files

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin/docs
for f in DocsEditor MarkdownEditor DocsEmbed DocPage SelfHostedDocsEditor; do
  git -C /home/mechelle/repos/lantern_app mv "apps/admin/src/components/${f}.jsx" "apps/admin/src/admin/docs/${f}.jsx"
done
  • [ ] Step 16.2: Update import paths in moved files
bash
grep -n "from '" /home/mechelle/repos/lantern_app/apps/admin/src/admin/docs/*.jsx

For each from '...' import:

  • './<sibling>' (another moved file) โ†’ stays as './<sibling>'.

  • '../firebase' โ†’ '../../firebase' (one extra level up because we're now 2 deep from src/).

  • '../<shared component>' โ†’ '@shared/components/<X>' (cross-zone โ€” use the alias).

  • '../<lib>' โ†’ '@shared/lib/<X>'.

  • [ ] Step 16.3: Update AdminShell import paths

In apps/admin/src/admin/AdminShell.jsx, replace '../components/DocsEditor' etc. with '@admin/docs/DocsEditor' (and the four siblings).

  • [ ] Step 16.4: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 16.5: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/admin/docs/ apps/admin/src/admin/AdminShell.jsx
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move docs subtree into src/admin/docs/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 17: Move API subtree (ApiOverview, ApiReferencePage, ClientSdkPage, ClientSdkPage.css, ClientSdkEmbed) to src/admin/api/ โ€‹

Files:

  • Move: 4 jsx files + 1 css file โ†’ apps/admin/src/admin/api/

  • Modify: AdminShell import paths

  • [ ] Step 17.1: Move files

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin/api
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/ApiOverview.jsx apps/admin/src/admin/api/ApiOverview.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/ApiReferencePage.jsx apps/admin/src/admin/api/ApiReferencePage.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/ClientSdkPage.jsx apps/admin/src/admin/api/ClientSdkPage.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/ClientSdkPage.css apps/admin/src/admin/api/ClientSdkPage.css
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/ClientSdkEmbed.jsx apps/admin/src/admin/api/ClientSdkEmbed.jsx
  • [ ] Step 17.2: Update import paths in moved files

Same rules as Task 16.2: relative siblings stay relative, '../firebase' becomes '../../firebase', cross-zone uses @shared/.... Verify the CSS import path too โ€” likely import './ClientSdkPage.css' stays unchanged after the move.

  • [ ] Step 17.3: Update AdminShell import paths

Replace '../components/ApiOverview' etc. with '@admin/api/ApiOverview' (and siblings).

  • [ ] Step 17.4: Verify build + tests + commit
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/admin/api/ apps/admin/src/admin/AdminShell.jsx
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move API + Client SDK pages into src/admin/api/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 18: Move analytics subtree to src/admin/analytics/ โ€‹

Files:

  • Move: apps/admin/src/components/analytics/ โ†’ apps/admin/src/admin/analytics/

  • Modify: AdminShell import paths

  • [ ] Step 18.1: Move the directory

bash
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/analytics apps/admin/src/admin/analytics
  • [ ] Step 18.2: Update import paths

Inside files in apps/admin/src/admin/analytics/:

  • '../firebase' โ†’ '../../firebase'
  • '../<shared component>' โ†’ '@shared/components/<X>'
  • '../<lib file>' โ†’ '@shared/lib/<X>'
bash
grep -rn "from '" /home/mechelle/repos/lantern_app/apps/admin/src/admin/analytics/ | head -30

In AdminShell.jsx, replace '../components/analytics/X' with '@admin/analytics/X'.

  • [ ] Step 18.3: Verify + commit
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/admin/analytics/ apps/admin/src/admin/AdminShell.jsx
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move analytics subtree into src/admin/analytics/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 19: Move misc admin pages (Billing, SystemHealth, MyAdminProfile, FeatureTracker, StorybookEmbed) โ€‹

Files:

  • Move 5 components into 5 sibling subdirectories under src/admin/

  • Modify: AdminShell import paths

  • [ ] Step 19.1: Create directories + move files

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin/{billing,system,profile,feature-tracker,storybook}
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/Billing.jsx apps/admin/src/admin/billing/Billing.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/SystemHealth.jsx apps/admin/src/admin/system/SystemHealth.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/MyAdminProfile.jsx apps/admin/src/admin/profile/MyAdminProfile.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/FeatureTracker apps/admin/src/admin/feature-tracker
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/StorybookEmbed.jsx apps/admin/src/admin/storybook/StorybookEmbed.jsx
  • [ ] Step 19.2: Update import paths in moved files

For each file:

  • '../firebase' โ†’ '../../firebase'
  • '../<shared component>' โ†’ '@shared/components/<X>'
  • '../<lib file>' โ†’ '@shared/lib/<X>'

The FeatureTracker/ directory has internal sibling imports โ€” those stay relative within the directory (e.g., import NewIssueModal from './NewIssueModal' is unchanged).

  • [ ] Step 19.3: Update AdminShell
js
import Billing from '@admin/billing/Billing'
import SystemHealth from '@admin/system/SystemHealth'
import MyAdminProfile from '@admin/profile/MyAdminProfile'
import FeatureTracker from '@admin/feature-tracker/FeatureTracker'
import StorybookEmbed from '@admin/storybook/StorybookEmbed'

(Adjust FeatureTracker import to whichever name FeatureTracker's index/main file uses.)

  • [ ] Step 19.4: Verify + commit
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/admin/ apps/admin/src/components/
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move billing/system/profile/feature-tracker/storybook into src/admin/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 20: Move admin merchants management (MerchantsAll, MerchantsCreate, CreateMerchantForm, MerchantSwitcher) to src/admin/merchants/ โ€‹

Note: MerchantSwitcher is admin-only here (admin's switcher in the merchants list and admin chrome) โ€” actually in our spec the switcher lives in the merchant shell (src/merchant/MerchantSwitcher). Confirm the spec โ€” it says: "MerchantSwitcher.jsx โ€” moves into the merchant shell (it's a merchant-shell affordance)." So MerchantSwitcher is NOT moved here; it goes to src/merchant/ in Task 25.

For this task, move:

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/admin/merchants
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/MerchantsAll.jsx apps/admin/src/admin/merchants/MerchantsAll.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/MerchantsCreate.jsx apps/admin/src/admin/merchants/MerchantsCreate.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/CreateMerchantForm.jsx apps/admin/src/admin/merchants/CreateMerchantForm.jsx
  • [ ] Step 20.1: Add the "Open in merchant mode" affordance to MerchantsAll

Each row in MerchantsAll.jsx's table currently navigates to /merchants/:merchantId (the old admin merchant detail). Change the click behavior:

Instead of navigate(/merchants/${merchantId}), do:

jsx
onClick={() => window.open(`/merchant/${merchantId}/overview`, '_blank')}

Or add a dedicated action button in the row labeled "Open in merchant mode" with that behavior. The exact UX should match what feels right โ€” a button is more discoverable; a row-click is more efficient. Pick one and apply consistently.

  • [ ] Step 20.2: Update import paths + AdminShell + verify + commit
"refactor(admin): move merchant management views into src/admin/merchants/

Adds 'Open in merchant mode' affordance to MerchantsAll rows that opens
/merchant/:id/overview in a new browser tab."

Task 21: AdminShell paths cleanup pass โ€‹

After Phase D, AdminShell.jsx should import everything from @admin/* or @shared/*. There should be no '../components/...' imports left in AdminShell.

  • [ ] Step 21.1: Audit AdminShell imports
bash
grep -n "from '" /home/mechelle/repos/lantern_app/apps/admin/src/admin/AdminShell.jsx

Expected: every import resolves to @shared/, @admin/, react, react-router-dom, lucide-react. Any '../components/...' is a leftover that needs fixing.

  • [ ] Step 21.2: Fix any leftover paths

Update each leftover to its new location.

  • [ ] Step 21.3: Verify build + tests + lint
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
npm run lint -w lantern-admin 2>&1 | tail -10  # if lint script exists

Lint should now flag any cross-zone imports inside src/admin/. Fix any that surface.

  • [ ] Step 21.4: Commit
bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): finalize AdminShell imports to @admin/@shared aliases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase E โ€” Migrate merchant pages (Tasks 22-27) โ€‹

Task 22: Move + rename MerchantOverviewTab โ†’ src/merchant/tabs/Overview.jsx โ€‹

Files:

  • Move: apps/admin/src/components/merchants/tabs/MerchantOverviewTab.jsx โ†’ apps/admin/src/merchant/tabs/Overview.jsx

  • Modify: MerchantShell.jsx to render Overview (instead of placeholder)

  • [ ] Step 22.1: Move + rename

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/merchant/tabs
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantOverviewTab.jsx apps/admin/src/merchant/tabs/Overview.jsx
  • [ ] Step 22.2: Update the moved file's component name + imports

Inside Overview.jsx:

  • Rename the exported component from MerchantOverviewTab to Overview.
  • Update imports of '../firebase' โ†’ '../../firebase' (one extra level: merchant/tabs is 2 deep from src).
  • Update imports of shared components โ†’ @shared/components/X.
  • Imports of useOutletContext from react-router-dom: this component currently expects to be a child of MerchantDetail's <Outlet>. We're flattening that โ€” the page now consumes data via its own loader (or accepts merchantId from URL params). For now, refactor to:
jsx
import { useParams } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { getMerchantData } from '../../firebase'  // or @shared/lib/merchantApi (after Task 28)

export default function Overview() {
  const { merchantId } = useParams()
  const [data, setData] = useState({ merchant: null, owners: [], venues: [] })
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    ;(async () => {
      try {
        const result = await getMerchantData(merchantId)
        if (!cancelled) setData(result)
      } catch (err) {
        if (!cancelled) setError(err.message)
      } finally {
        if (!cancelled) setLoading(false)
      }
    })()
    return () => { cancelled = true }
  }, [merchantId])

  if (loading) return <p>Loading...</p>
  if (error) return <div className="form-error">{error}</div>
  // Render the existing JSX from MerchantOverviewTab using `data` instead of useOutletContext.
  return (...)
}
  • [ ] Step 22.3: Wire Overview into MerchantShell

Replace the placeholder for path="overview" in MerchantShell.jsx:

jsx
import Overview from './tabs/Overview'
// ...
<Route path="overview" element={<Overview />} />
  • [ ] Step 22.4: Build + verify
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 22.5: Commit
"refactor(admin): move MerchantOverviewTab to src/merchant/tabs/Overview.jsx

Component renamed and refactored to read merchantId from URL params
instead of useOutletContext, since MerchantShell flattens the tab tree."

Task 23: Move + rename remaining merchant tabs (Offers, Venues, Notes, Photos, Address) โ€‹

For each of MerchantOffersTab, MerchantVenuesTab, MerchantNotesTab, MerchantPhotosTab, MerchantAddressTab, repeat the Task 22 pattern. Prefer one commit per page (5 commits) for traceability.

Files (per page):

  • Move: apps/admin/src/components/merchants/tabs/Merchant<X>Tab.jsx โ†’ apps/admin/src/merchant/tabs/<X>.jsx

  • Modify: apps/admin/src/merchant/MerchantShell.jsx to render <X> (instead of placeholder)

  • [ ] Step 23.1: Move + rename each file

bash
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx apps/admin/src/merchant/tabs/Offers.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantVenuesTab.jsx apps/admin/src/merchant/tabs/Venues.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantNotesTab.jsx apps/admin/src/merchant/tabs/Notes.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantPhotosTab.jsx apps/admin/src/merchant/tabs/Photos.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/MerchantAddressTab.jsx apps/admin/src/merchant/tabs/Address.jsx
  • [ ] Step 23.2: Inside each moved file, rename the component + refactor data loading

For each Merchant<X>Tab exported component, rename to <X> (drop the Merchant prefix and Tab suffix). Update the export default line.

Then refactor data loading: the old code used useOutletContext() to receive merchantId + merchant data from MerchantDetail. Now it must read merchantId from URL params and fetch its own data. Apply the Task 22.2 pattern:

jsx
import { useParams } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { getMerchantData } from '../../firebase'  // or @shared/lib/merchantApi after Task 30

export default function Offers() {  // (or Venues, Notes, Photos, Address per file)
  const { merchantId } = useParams()
  const [data, setData] = useState({ /* shape this tab expects */ })
  // ... loadData useEffect, error state, then render existing JSX using `data`
}

The exact data shape each tab needs differs. Read the old file to see what useOutletContext() was destructuring; that's what data needs to provide.

Update other imports in the moved file:

  • '../firebase' โ†’ '../../firebase' (one extra level: merchant/tabs/X.jsx is 2 deep from src/)

  • '../<shared component>' โ†’ '@shared/components/<X>'

  • [ ] Step 23.3: Wire each into MerchantShell

In apps/admin/src/merchant/MerchantShell.jsx:

jsx
import Offers from './tabs/Offers'
import Venues from './tabs/Venues'
import Notes from './tabs/Notes'
import Photos from './tabs/Photos'
import Address from './tabs/Address'
// ...
<Route path="offers/*" element={<Offers />} />
<Route path="venues" element={<Venues />} />
<Route path="notes" element={<Notes />} />
<Route path="photos" element={<Photos />} />
<Route path="address" element={<Address />} />

(Note: offers/* keeps the wildcard for the upcoming offer subroutes from Task 24.)

After this step, the <Placeholder> helper inside MerchantShell.jsx is no longer referenced โ€” delete it.

  • [ ] Step 23.4: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 23.5: Commit (one commit per page is preferred for review traceability)

If batching into one commit:

bash
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app add -u apps/admin/src/merchant/tabs/ apps/admin/src/merchant/MerchantShell.jsx apps/admin/src/components/merchants/tabs/
git -C /home/mechelle/repos/lantern_app status --short
git -C /home/mechelle/repos/lantern_app commit -m "refactor(admin): move merchant tabs (Offers, Venues, Notes, Photos, Address) into src/merchant/tabs/

Tabs renamed (drop the 'Tab' suffix), data loading refactored from
useOutletContext to useParams + per-page fetch. MerchantShell now renders
real pages instead of placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

If one commit per page (5 commits), use messages like "refactor(admin): move MerchantOffersTab to src/merchant/tabs/Offers.jsx".


Task 24: Move offer flows (OfferDetail, OfferForm, OffersList) to src/merchant/offers/ โ€‹

These are referenced by Offers (the page from Task 23). They live in merchants/tabs/ today.

bash
mkdir -p /home/mechelle/repos/lantern_app/apps/admin/src/merchant/offers
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/OfferDetail.jsx apps/admin/src/merchant/offers/OfferDetail.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/OfferForm.jsx apps/admin/src/merchant/offers/OfferForm.jsx
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/tabs/OffersList.jsx apps/admin/src/merchant/offers/OffersList.jsx

Update imports inside each file. Update MerchantShell.jsx routes for offer subroutes:

jsx
<Route path="offers" element={<OffersList />} />
<Route path="offers/new" element={<OfferForm mode="create" />} />
<Route path="offers/:offerId" element={<OfferDetail />} />
<Route path="offers/:offerId/edit" element={<OfferForm mode="edit" />} />

Verify, commit. Commit message: "refactor(admin): move offer flows into src/merchant/offers/".


Task 25: Move MerchantSwitcher to src/merchant/ โ€‹

bash
git -C /home/mechelle/repos/lantern_app mv apps/admin/src/components/merchants/MerchantSwitcher.jsx apps/admin/src/merchant/MerchantSwitcher.jsx

Update imports inside the file. Wire it into MerchantShell.jsx (visible only when isAdmin):

jsx
{isAdmin && <MerchantSwitcher currentMerchantId={urlMerchantId} onPick={(id) => navigate(`/merchant/${id}/overview`)} />}

(Add to the sidebar header or appropriate position; match existing styling.)

Verify, commit. Commit message: "refactor(admin): move MerchantSwitcher into src/merchant/, render in shell for admins only".


Task 26: Delete obsolete files โ€‹

  • apps/admin/src/components/merchants/MerchantDetail.jsx โ€” superseded by MerchantShell

  • apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx โ€” superseded by App.jsx role fork

  • apps/admin/src/components/merchants/MerchantDetailFields.jsx โ€” fold into Overview if still needed; otherwise delete

  • apps/admin/src/components/merchants/__tests__/ โ€” move/delete based on what's left

  • [ ] Step 26.1: Confirm nothing imports these

bash
grep -rn "MerchantDetail\b\|MerchantsIndexRedirect\b" /home/mechelle/repos/lantern_app/apps/admin/src/

Expected: no matches outside the files themselves and any tests. If a test imports MerchantDetail, the test is obsolete โ€” move/delete it.

  • [ ] Step 26.2: Delete with git rm
bash
git -C /home/mechelle/repos/lantern_app rm apps/admin/src/components/merchants/MerchantDetail.jsx
git -C /home/mechelle/repos/lantern_app rm apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx
git -C /home/mechelle/repos/lantern_app rm apps/admin/src/components/merchants/MerchantDetailFields.jsx 2>/dev/null || true
# Also delete test files referencing deleted components
  • [ ] Step 26.3: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 26.4: Commit
"chore(admin): delete obsolete MerchantDetail, MerchantsIndexRedirect, MerchantDetailFields"

Task 27: Delete AdminDashboard.jsx and its test โ€‹

AdminDashboard.jsx is still referenced from somewhere if Task 13 didn't fully replace it. After App.jsx forks directly into shells, AdminDashboard is dead.

  • [ ] Step 27.1: Confirm no live references
bash
grep -rn "AdminDashboard" /home/mechelle/repos/lantern_app/apps/admin/src/

Expected: only the file itself and (possibly) AdminDashboard.test.jsx.

  • [ ] Step 27.2: Delete
bash
git -C /home/mechelle/repos/lantern_app rm apps/admin/src/components/AdminDashboard.jsx
git -C /home/mechelle/repos/lantern_app rm apps/admin/src/components/__tests__/AdminDashboard.test.jsx 2>/dev/null || true
  • [ ] Step 27.3: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 27.4: Commit
"chore(admin): delete AdminDashboard.jsx โ€” superseded by App.jsx role fork + AdminShell"

Phase F โ€” Backend dual-mount (Tasks 28-30) โ€‹

Task 28: Extract handlers from routes/adminMerchants.js to handlers/merchantHandlers.js โ€‹

Files:

  • Create: services/api/auth/src/handlers/merchantHandlers.js

  • Modify: services/api/auth/src/routes/adminMerchants.js (becomes thin wiring)

  • [ ] Step 28.1: Create the handlers file

Read routes/adminMerchants.js end-to-end. For each router.method(...) block, extract the (req, res, next) => {...} callback into a named exported function in handlers/merchantHandlers.js.

bash
cat /home/mechelle/repos/lantern_app/services/api/auth/src/routes/adminMerchants.js

The handlers to extract:

  • listMerchants โ€” was router.get('/', ...)
  • getMerchantDetail โ€” was router.get('/:merchantId', ...)
  • createMerchantUserForMerchant โ€” was router.post('/:merchantId/users', ...)
  • detachUserFromMerchant โ€” was router.delete('/:merchantId/users/:userId', ...)
  • associateVenueWithMerchant โ€” was router.post('/:merchantId/venues', ...)
  • disassociateVenueFromMerchant โ€” was router.delete('/:merchantId/venues/:venueId', ...)

Each is moved verbatim into merchantHandlers.js as a named export. Imports follow them (getAuth, getFirestore, FieldValue, zod, randomBytes, sendMerchantInviteEmail).

js
// services/api/auth/src/handlers/merchantHandlers.js
import { getAuth } from 'firebase-admin/auth'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
import { z } from 'zod'
import { randomBytes } from 'crypto'
import { sendMerchantInviteEmail } from '../lib/email.js'

// (zod schemas โ€” copy from adminMerchants.js)
const AssociateVenueBody = z.object({ venueId: z.string().min(1) })
const CreateMerchantUserBody = z.object({ /* ... */ })

export async function listMerchants(req, res, next) {
  // copy verbatim from old GET / handler
}

export async function getMerchantDetail(req, res, next) {
  // copy verbatim from old GET /:merchantId handler
}

export async function createMerchantUserForMerchant(req, res, next) {
  // copy verbatim from old POST /:merchantId/users handler
}

export async function detachUserFromMerchant(req, res, next) {
  // copy verbatim from old DELETE /:merchantId/users/:userId handler
}

export async function associateVenueWithMerchant(req, res, next) {
  // copy verbatim from old POST /:merchantId/venues handler
}

export async function disassociateVenueFromMerchant(req, res, next) {
  // copy verbatim from old DELETE /:merchantId/venues/:venueId handler
}
  • [ ] Step 28.2: Replace adminMerchants.js with thin wiring
js
// services/api/auth/src/routes/adminMerchants.js
import { Router } from 'express'
import * as h from '../handlers/merchantHandlers.js'

const router = Router()

router.get('/', h.listMerchants)
router.get('/:merchantId', h.getMerchantDetail)
router.post('/:merchantId/users', h.createMerchantUserForMerchant)
router.delete('/:merchantId/users/:userId', h.detachUserFromMerchant)
router.post('/:merchantId/venues', h.associateVenueWithMerchant)
router.delete('/:merchantId/venues/:venueId', h.disassociateVenueFromMerchant)

export default router
  • [ ] Step 28.3: Verify
bash
cd /home/mechelle/repos/lantern_app/services/api/auth && node --check src/handlers/merchantHandlers.js
cd /home/mechelle/repos/lantern_app/services/api/auth && node --check src/routes/adminMerchants.js
cd /home/mechelle/repos/lantern_app/services/api/auth && npm run test:run 2>&1 | tail -5

All tests pass. The behavior is identical โ€” only the file structure changed.

  • [ ] Step 28.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/handlers/merchantHandlers.js services/api/auth/src/routes/adminMerchants.js
git -C /home/mechelle/repos/lantern_app commit -m "refactor(auth-api): extract merchant handlers into pure functions

Same handlers, separated from their wiring. Lets us mount the same
handlers under /auth/admin/merchants and /auth/merchant/me with
different middleware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 29: Add merchantSelf.js route file at /auth/merchant/me โ€‹

Files:

  • Create: services/api/auth/src/routes/merchantSelf.js

  • Modify: services/api/auth/src/index.js (mount the new router)

  • [ ] Step 29.1: Create merchantSelf.js

js
// services/api/auth/src/routes/merchantSelf.js
/**
 * Merchant self-service routes โ€” endpoints scoped to the authenticated
 * merchant's own merchantId (resolved from their token claim, not the URL).
 */

import { Router } from 'express'
import * as h from '../handlers/merchantHandlers.js'

const router = Router()

// Inject merchantId from the authenticated user's token into req.params
// before any handler runs. The handlers in merchantHandlers.js read
// req.params.merchantId โ€” they don't care where it came from.
router.use((req, res, next) => {
  if (!req.user?.merchantId) {
    return res.status(401).json({ error: 'NO_MERCHANT', message: 'No merchant associated with your account' })
  }
  req.params.merchantId = req.user.merchantId
  next()
})

// v1 mounts only getMerchantDetail. Mutations stay admin-only.
router.get('/', h.getMerchantDetail)

export default router
  • [ ] Step 29.2: Mount it in index.js

In services/api/auth/src/index.js, find the existing merchantDispatch block (created in earlier merchant auth work). Add:

js
import merchantSelfRoutes from './routes/merchantSelf.js'
// ... within the merchantDispatch block ...
merchantDispatch.use('/me', verifyFirebaseToken, requireMerchant, merchantSelfRoutes)

Make sure requireMerchant is imported from ./middleware/auth.js (Task 14 added it).

  • [ ] Step 29.3: Verify syntax + tests
bash
cd /home/mechelle/repos/lantern_app/services/api/auth && node --check src/routes/merchantSelf.js
cd /home/mechelle/repos/lantern_app/services/api/auth && node --check src/index.js
cd /home/mechelle/repos/lantern_app/services/api/auth && npm run test:run 2>&1 | tail -5
  • [ ] Step 29.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add services/api/auth/src/routes/merchantSelf.js services/api/auth/src/index.js
git -C /home/mechelle/repos/lantern_app commit -m "feat(auth-api): mount /auth/merchant/me routes for merchant self-service

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 30: Update shared/lib/merchantApi.js for role-aware URL selection โ€‹

Files:

  • Create: apps/admin/src/shared/lib/merchantApi.js

  • Modify: existing getMerchantData callers if any (likely just firebase.js)

  • [ ] Step 30.1: Create the role-aware client

js
// apps/admin/src/shared/lib/merchantApi.js
import { authRequest, parseResponse } from './apiClient'
import { auth as firebaseAuth } from '../../firebase'

const API_BASE_URL = import.meta.env.VITE_AUTH_API_URL

/**
 * Fetch merchant detail. Role-aware:
 *  - admin: GET /auth/admin/merchants/:merchantId (any merchant)
 *  - merchant: GET /auth/merchant/me (own merchant; merchantId arg is ignored
 *    by the server, kept by the client for symmetry/logging)
 */
export async function getMerchantDetail(merchantId) {
  const role = await getCurrentRole()
  const url = role === 'admin'
    ? `${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}`
    : `${API_BASE_URL}/auth/merchant/me`
  return parseResponse(await authRequest(url))
}

async function getCurrentRole() {
  const user = firebaseAuth.currentUser
  if (!user) return null
  const tokenResult = await user.getIdTokenResult()
  return tokenResult.claims.role || null
}

If parseResponse isn't exported from apiClient.js, look for the existing helper that admin code uses (it's likely an internal helper inside authApi.js). Either:

  • Export parseResponse from apiClient.js (small change, one line).
  • Or copy the parse logic inline to merchantApi.js (DRY violation but localized).

Prefer exporting; check if other files would benefit too.

  • [ ] Step 30.2: Wire callers to the new function

In firebase.js, find getMerchantData:

bash
grep -n "getMerchantData" /home/mechelle/repos/lantern_app/apps/admin/src/firebase.js

It currently calls the admin URL directly via authApi. Replace with a call to the new shared client:

js
export async function getMerchantData(merchantId) {
  const { getMerchantDetail } = await import('./shared/lib/merchantApi')
  return getMerchantDetail(merchantId)
}
  • [ ] Step 30.3: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 30.4: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/shared/lib/merchantApi.js apps/admin/src/firebase.js
# Plus apiClient.js if you exported parseResponse
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): role-aware merchantApi.getMerchantDetail

When the current user is admin, calls /auth/admin/merchants/:id (any).
When the current user is merchant, calls /auth/merchant/me (own).
firebase.js's getMerchantData delegates to the new shared client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase G โ€” Wire merchant write paths (Tasks 31-32, may grow) โ€‹

Task 31: Audit merchant-shell write paths โ€‹

Files:

  • Read: each merchant-shell tab to find its write actions and the API endpoints they call

  • [ ] Step 31.1: For each merchant tab, document its write endpoints

bash
grep -rn "fetch\|authRequest\|publicRequest\|firebase.*update\|firebase.*set" \
  /home/mechelle/repos/lantern_app/apps/admin/src/merchant/tabs/ \
  /home/mechelle/repos/lantern_app/apps/admin/src/merchant/offers/

Build a table of (tab name, action, current endpoint, current auth model):

TabActionEndpointAuth model/me mount needed?
OverviewEdit business name???
OffersCRUD offersservices/api/merchants/Firestore rulesNo โ€” already works
Venues(read-only?)???
NotesEdit notes???
PhotosUploadFirebase StorageStorage rulesNo โ€” already works
AddressEdit address???
  • [ ] Step 31.2: Decide per gap

For each "yes โ€” needs /me mount" row:

  • If a handler exists in merchantHandlers.js, add a route to merchantSelf.js.

  • If no handler exists, create one in merchantHandlers.js (mirror admin patterns), then mount.

  • If the action is admin-only (e.g., team management), the merchant tab should NOT expose it โ€” remove the action from the merchant view.

  • [ ] Step 31.3: Document findings

Append the audit findings to docs/superpowers/specs/2026-04-27-admin-merchant-shell-split-design.md under a new "Audit findings (Phase G)" section, OR write to a fresh doc at docs/superpowers/notes/2026-04-27-merchant-write-audit.md. Either way, the implementer's discoveries are persisted for the reviewer.

  • [ ] Step 31.4: Commit the audit doc
bash
git -C /home/mechelle/repos/lantern_app add docs/superpowers/notes/2026-04-27-merchant-write-audit.md
# (or whatever doc you wrote)
git -C /home/mechelle/repos/lantern_app commit -m "docs: audit merchant-shell write paths and document /me mount gaps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 32: Add /me mounts and/or new endpoints for the gaps surfaced in Task 31 โ€‹

Files:

  • Modify: services/api/auth/src/routes/merchantSelf.js
  • Possibly create new handlers in services/api/auth/src/handlers/merchantHandlers.js
  • Possibly modify the merchant frontend (apps/admin/src/merchant/...) to call the new endpoints

This is shaped by Task 31's findings. For each gap:

  • [ ] Step 32.1: For each new handler needed

Example: if "edit business name" needs a handler:

js
// In merchantHandlers.js
const UpdateBusinessBody = z.object({
  businessName: z.string().min(1),
})

export async function updateMerchantBusiness(req, res, next) {
  try {
    const { merchantId } = req.params
    const body = UpdateBusinessBody.parse(req.body)
    const callerUid = req.user.uid
    const db = getFirestore()
    await db.collection('merchants').doc(merchantId).update({
      businessName: body.businessName,
      updatedAt: FieldValue.serverTimestamp(),
      updatedBy: callerUid,
    })
    db.collection('adminActions').add({
      action: 'updateMerchantBusiness',
      merchantId, performedBy: callerUid,
      performedAt: FieldValue.serverTimestamp(),
    }).catch((err) => console.error('failed to log updateMerchantBusiness', err))
    return res.json({ success: true, merchantId })
  } catch (err) {
    next(err)
  }
}

Then mount it:

  • In routes/adminMerchants.js (admin can edit any): router.patch('/:merchantId/business', h.updateMerchantBusiness)
  • In routes/merchantSelf.js: router.patch('/business', h.updateMerchantBusiness)

The frontend call in merchant/tabs/Overview.jsx calls a role-aware client function in shared/lib/merchantApi.js:

js
export async function updateMerchantBusiness(merchantId, body) {
  const role = await getCurrentRole()
  const url = role === 'admin'
    ? `${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}/business`
    : `${API_BASE_URL}/auth/merchant/me/business`
  return parseResponse(await authRequest(url, { method: 'PATCH', body: JSON.stringify(body) }))
}

Repeat per gap.

  • [ ] Step 32.2: One commit per gap

For each gap fixed, commit with a focused message. Example:

  • "feat(auth-api): support merchant-self updates to business name (PATCH /me/business)"
  • "feat(auth-api): support merchant-self notes editing"

The implementer should expect 2-6 commits in this task depending on Task 31's findings. If zero gaps surface (all writes already merchant-accessible via services/api/merchants/ or storage rules), this task is empty โ€” note that and move on.

  • [ ] Step 32.3: Verify build + tests after each commit
bash
cd /home/mechelle/repos/lantern_app/services/api/auth && npm run test:run 2>&1 | tail -5
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5

Phase H โ€” Profile + tests (Tasks 33-37) โ€‹

Task 33: Create MyMerchantProfile.jsx โ€‹

Files:

  • Create: apps/admin/src/merchant/profile/MyMerchantProfile.jsx

  • Modify: MerchantShell.jsx to wire it into /profile route

  • [ ] Step 33.1: Inspect MyAdminProfile.jsx for the pattern

bash
cat /home/mechelle/repos/lantern_app/apps/admin/src/admin/profile/MyAdminProfile.jsx | head -80

The merchant version is simpler: shows the merchant's own user info + a "change merchant portal password" link that triggers the existing forgot-password flow.

  • [ ] Step 33.2: Write MyMerchantProfile.jsx
jsx
import React, { useEffect, useState } from 'react'
import LanternLogo from '@shared/components/LanternLogo'
import { auth, requestMerchantPasswordReset } from '../../firebase'

export default function MyMerchantProfile() {
  const [user, setUser] = useState(null)
  const [resetSent, setResetSent] = useState(false)
  const [resetError, setResetError] = useState(null)

  useEffect(() => {
    setUser(auth.currentUser)
  }, [])

  if (!user) return <p>Loading...</p>

  const handleResetPassword = async () => {
    setResetError(null)
    try {
      await requestMerchantPasswordReset(user.email)
      setResetSent(true)
    } catch (err) {
      setResetError(err.message || 'Failed to send reset email')
    }
  }

  return (
    <div className="profile-container">
      <h2>My Merchant Profile</h2>
      <div className="form-card">
        <h3 className="form-section-title">Account</h3>
        <p><strong>Email:</strong> {user.email}</p>
        <p><strong>Display name:</strong> {user.displayName || 'โ€”'}</p>
      </div>
      <div className="form-card" style={{ marginTop: 'var(--space-3)' }}>
        <h3 className="form-section-title">Merchant portal password</h3>
        <p className="text-muted">
          Reset your merchant portal password. Your Lantern app passphrase is independent and will not be affected.
        </p>
        {resetSent ? (
          <p className="form-success">Reset email sent. Check your inbox.</p>
        ) : (
          <button className="btn btn-secondary" onClick={handleResetPassword}>
            Reset portal password
          </button>
        )}
        {resetError && <p className="form-error">{resetError}</p>}
      </div>
    </div>
  )
}
  • [ ] Step 33.3: Wire into MerchantShell

In MerchantShell.jsx, replace the placeholder profile route:

jsx
import MyMerchantProfile from './profile/MyMerchantProfile'
// ...
<Route path="profile" element={<MyMerchantProfile />} />
  • [ ] Step 33.4: Verify build + tests
bash
npm run build -w lantern-admin 2>&1 | tail -5
npm test -w lantern-admin -- --run 2>&1 | tail -5
  • [ ] Step 33.5: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/merchant/profile/MyMerchantProfile.jsx apps/admin/src/merchant/MerchantShell.jsx
git -C /home/mechelle/repos/lantern_app commit -m "feat(admin): add MyMerchantProfile page

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 34: Component test for MerchantShell โ€‹

Files:

  • Create: apps/admin/src/merchant/__tests__/MerchantShell.test.jsx

  • [ ] Step 34.1: Write the test

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import MerchantShell from '../MerchantShell'

vi.mock('../../firebase', () => ({
  auth: { currentUser: null },
}))

function renderShell({ user, isAdmin = false, path = '/merchant/m-1/overview' }) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route
          path="/merchant/:merchantId/*"
          element={<MerchantShell user={user} isAdmin={isAdmin} onSignOut={() => {}} />}
        />
      </Routes>
    </MemoryRouter>
  )
}

describe('MerchantShell', () => {
  it('renders the shell when merchant viewing their own merchant', () => {
    renderShell({ user: { merchantId: 'm-1' }, isAdmin: false })
    // Sidebar should have the tabs
    expect(screen.getByText(/Overview/i)).toBeInTheDocument()
    expect(screen.getByText(/Offers/i)).toBeInTheDocument()
  })

  it('redirects merchant viewing another merchant to their own', () => {
    renderShell({ user: { merchantId: 'm-1' }, isAdmin: false, path: '/merchant/m-2/overview' })
    // After the redirect resolves, the shell should be at m-1 (verify by URL or content).
    // Since MemoryRouter doesn't update document.location, assert by checking the redirect-effect:
    // If MerchantShell rendered the placeholder for the wrong merchant, this assertion fails.
    expect(screen.queryByText(/m-2/)).not.toBeInTheDocument()
  })

  it('admin viewing any merchant sees the switcher', () => {
    renderShell({ user: { merchantId: null }, isAdmin: true, path: '/merchant/m-7/overview' })
    // The switcher should be visible (its placeholder text or aria-label, depending on implementation)
    // Adjust this check to whatever MerchantSwitcher actually renders.
    expect(screen.queryByText(/Switch merchant|Pick merchant|m-7/i)).toBeInTheDocument()
  })

  it('real merchant does not see the "Admin" sidebar item', () => {
    renderShell({ user: { merchantId: 'm-1' }, isAdmin: false })
    expect(screen.queryByText(/^Admin$/)).not.toBeInTheDocument()
  })

  it('admin sees the "Admin" sidebar item', () => {
    renderShell({ user: { merchantId: null }, isAdmin: true, path: '/merchant/m-1/overview' })
    expect(screen.getByText(/^Admin$/)).toBeInTheDocument()
  })
})

These tests are intentionally lightweight โ€” assertions hit visible text. If MerchantShell's specific wording differs, adjust the regexes.

  • [ ] Step 34.2: Run tests
bash
npm test -w lantern-admin -- --run MerchantShell 2>&1 | tail -10

If any test fails because of how MerchantShell wires data fetches (e.g., placeholder tries to fetch on mount), mock the data fetches at the top of the test file:

js
vi.mock('../../shared/lib/merchantApi', () => ({
  getMerchantDetail: vi.fn().mockResolvedValue({ merchant: { businessName: 'Test' }, owners: [], venues: [] }),
}))
  • [ ] Step 34.3: Commit
bash
git -C /home/mechelle/repos/lantern_app add apps/admin/src/merchant/__tests__/MerchantShell.test.jsx
git -C /home/mechelle/repos/lantern_app commit -m "test(admin): MerchantShell renders, redirects on role mismatch, switcher visibility

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 35: Component test for AdminShell โ€‹

Files:

  • Create: apps/admin/src/admin/__tests__/AdminShell.test.jsx

  • [ ] Step 35.1: Write the test

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import AdminShell from '../AdminShell'

vi.mock('../../firebase', () => ({
  auth: {},
  getAdminProfile: vi.fn().mockResolvedValue({ displayName: 'Test Admin' }),
}))
// Mock all admin-page subimports to keep this focused on the shell:
vi.mock('../users/UserManagement', () => ({
  default: () => <div data-testid="users">UserManagement</div>,
}))
vi.mock('../merchants/MerchantsAll', () => ({
  default: () => (
    <div data-testid="merchants-all">
      <button onClick={() => window.open('/merchant/m-1/overview', '_blank')}>Open m-1</button>
    </div>
  ),
}))
// (mock other pages as needed)

function renderShell(initialPath = '/admin') {
  return render(
    <MemoryRouter initialEntries={[initialPath]}>
      <Routes>
        <Route path="/admin/*" element={<AdminShell user={{ uid: 'admin-1' }} isAdmin onSignOut={() => {}} />} />
      </Routes>
    </MemoryRouter>
  )
}

describe('AdminShell', () => {
  beforeEach(() => vi.clearAllMocks())

  it('renders the admin sidebar', async () => {
    renderShell('/admin')
    expect(await screen.findByText(/Users/i)).toBeInTheDocument()
    expect(await screen.findByText(/Merchants/i)).toBeInTheDocument()
    expect(await screen.findByText(/Docs/i)).toBeInTheDocument()
  })

  it('Merchants list "open in merchant mode" calls window.open', async () => {
    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
    renderShell('/admin/merchants/all')
    fireEvent.click(await screen.findByText(/Open m-1/i))
    expect(openSpy).toHaveBeenCalledWith('/merchant/m-1/overview', '_blank')
    openSpy.mockRestore()
  })
})
  • [ ] Step 35.2: Run + commit
bash
npm test -w lantern-admin -- --run AdminShell 2>&1 | tail -10
git -C /home/mechelle/repos/lantern_app add apps/admin/src/admin/__tests__/AdminShell.test.jsx
git -C /home/mechelle/repos/lantern_app commit -m "test(admin): AdminShell renders sidebar; Merchants row opens new tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 36: Route-guard tests in App.test.jsx โ€‹

Files:

  • Create or update: apps/admin/src/__tests__/App.test.jsx

  • [ ] Step 36.1: Write the test

The tests need to mock firebase.js heavily (auth state, role claims). The test confirms App.jsx's redirects.

Sketch:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

vi.mock('../firebase', () => ({
  auth: {},
  onAuthChange: (cb) => { cb({ /* user */ }, { role: 'merchant', merchantId: 'm-1' }); return () => {} },
}))

// (Mock both shells to render a sentinel so we can assert redirection behavior.)
vi.mock('../admin/AdminShell', () => ({ default: () => <div data-testid="admin-shell">admin</div> }))
vi.mock('../merchant/MerchantShell', () => ({ default: () => <div data-testid="merchant-shell">merchant</div> }))

import App from '../App'

describe('App role fork', () => {
  it('real merchant landing on /admin/users redirects to their merchant', async () => {
    const { container } = render(
      <MemoryRouter initialEntries={['/admin/users']}>
        <App />
      </MemoryRouter>
    )
    await waitFor(() => {
      expect(container.querySelector('[data-testid="merchant-shell"]')).toBeInTheDocument()
      expect(container.querySelector('[data-testid="admin-shell"]')).not.toBeInTheDocument()
    })
  })

  it('old /users redirects to /admin/users (which renders for admins)', async () => {
    // (re-mock firebase with admin role for this test)
    // similar setup; assert admin-shell rendered
  })

  // ... etc per the spec's manual smoke matrix
})

The exact test shape depends on how onAuthChange is structured. Read App.jsx first, mock the appropriate hooks, then write the assertions.

  • [ ] Step 36.2: Run + commit
bash
npm test -w lantern-admin -- --run App 2>&1 | tail -10
git -C /home/mechelle/repos/lantern_app add apps/admin/src/__tests__/App.test.jsx
git -C /home/mechelle/repos/lantern_app commit -m "test(admin): App role fork + back-compat redirect tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 37: merchantApi.test.js โ€” role-aware URL selection โ€‹

Files:

  • Create: apps/admin/src/shared/lib/__tests__/merchantApi.test.js

  • [ ] Step 37.1: Write the test

js
import { describe, it, expect, vi, beforeEach } from 'vitest'

const fetchMock = vi.fn()
global.fetch = fetchMock

vi.mock('../apiClient', () => ({
  authRequest: (url, opts) => fetchMock(url, opts),
  parseResponse: async (res) => res.json(),
}))

vi.mock('../../../firebase', () => {
  const tokenResultByRole = (role) => ({ claims: { role } })
  return {
    auth: {
      currentUser: { getIdTokenResult: vi.fn() },
    },
    __setRole(role) {
      this.auth.currentUser.getIdTokenResult.mockResolvedValue(tokenResultByRole(role))
    },
  }
})

import * as fb from '../../../firebase'
import { getMerchantDetail } from '../merchantApi'

describe('merchantApi.getMerchantDetail', () => {
  beforeEach(() => {
    fetchMock.mockReset()
    fetchMock.mockResolvedValue({ json: async () => ({}) })
  })

  it('admin role โ†’ calls /auth/admin/merchants/:id', async () => {
    fb.__setRole('admin')
    await getMerchantDetail('m-7')
    expect(fetchMock).toHaveBeenCalledWith(
      expect.stringMatching(/\/auth\/admin\/merchants\/m-7$/),
      expect.anything()
    )
  })

  it('merchant role โ†’ calls /auth/merchant/me (ignores merchantId arg)', async () => {
    fb.__setRole('merchant')
    await getMerchantDetail('m-anything')
    expect(fetchMock).toHaveBeenCalledWith(
      expect.stringMatching(/\/auth\/merchant\/me$/),
      expect.anything()
    )
  })
})
  • [ ] Step 37.2: Run + commit
bash
npm test -w lantern-admin -- --run merchantApi 2>&1 | tail -10
git -C /home/mechelle/repos/lantern_app add apps/admin/src/shared/lib/__tests__/merchantApi.test.js
git -C /home/mechelle/repos/lantern_app commit -m "test(admin): merchantApi role-aware URL selection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase I โ€” Final validation + PR (Task 38) โ€‹

Task 38: Final validation + manual smoke matrix + PR โ€‹

  • [ ] Step 38.1: Run full validation
bash
npm run validate -- --workspace apps/admin 2>&1 | tail -20
cd /home/mechelle/repos/lantern_app/services/api/auth && npm run validate 2>&1 | tail -10

Expected: build green, tests green, lint green (modulo the pre-existing audit failure unrelated to this PR).

  • [ ] Step 38.2: Run the bundle isolation check
bash
npm run build -w lantern-admin 2>&1 | tail -5
node /home/mechelle/repos/lantern_app/tooling/scripts/check-shell-isolation.mjs

Expected: OK โ€” shells are isolated in the bundle.

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

Per the spec's smoke matrix. Run admin + auth-API dev servers (npm run dev -w services/api/auth + npm run dev -w lantern-admin) and walk:

ActionExpected
Admin signs inLands on /admin/, sees admin sidebar
Admin clicks "Merchants"Goes to /admin/merchants/all (admin list)
Admin clicks a merchant rowOpens /merchant/<id>/overview in a NEW BROWSER TAB
Admin uses the switcher in merchant modeURL updates; new merchant's data loads
Admin clicks "โ† Admin" in merchant modeReturns to /admin/
Real merchant signs inLands on /merchant/<theirId>/overview
Real merchant types /admin/users in URL barRedirected to their own /merchant/<theirId>/overview
Real merchant types /merchant/<other-id>/overviewRedirected to their own
Old URL /usersRedirected to /admin/users (which then redirects merchants)
Old URL /merchants/<id>Redirected to /merchant/<id>/overview
Real merchant edits notes / offers / business nameWrites succeed via /auth/merchant/me/* (or merchants-API)
Real merchant clicks "Reset portal password" in profileTriggers reset email; sets new password; signs in successfully
  • [ ] Step 38.4: Push branch + open PR
bash
git -C /home/mechelle/repos/lantern_app push -u origin claude/merchant-integration
gh pr create --title "feat: merchant integration โ€” auth decoupling + admin/merchant shell split" \
  --body "$(cat <<'EOF'
## Summary

Three integrations land together in this PR:

- **Merchant user create/attach/detach/delete UX** in admin โ€” replaces the misplaced "Create Merchant" tab with a "Create Merchant User" flow plus attach, detach, and delete actions in the user detail panel.
- **Merchant auth decoupling** โ€” separate `merchantPasswordHash` on `merchantProfiles`, parallel `/auth/merchant/*` endpoints, three-tier sign-in fall-through (admin โ†’ merchant โ†’ Firebase legacy). Resetting a merchant's portal password no longer destroys their Lantern encryption keys.
- **Admin / merchant shell split** โ€” `apps/admin/src/` restructured into `src/admin/`, `src/merchant/`, `src/shared/` with hard cross-import boundaries enforced by ESLint and a build-time bundle validator. Two top-level URL trees (`/admin/*`, `/merchant/:id/*`) with a role fork in `App.jsx`. Backend handlers extracted and dual-mounted (`/auth/admin/merchants/:id/*` + `/auth/merchant/me/*`).

## Specs

- `docs/superpowers/specs/2026-04-26-merchant-user-attach-flow-design.md`
- `docs/superpowers/specs/2026-04-27-merchant-auth-decoupling-design.md`
- `docs/superpowers/specs/2026-04-27-admin-merchant-shell-split-design.md`

## Test plan

- [ ] Fresh merchant onboarding (set passphrase + portal password, sign in, land on `/merchant/<id>/overview`)
- [ ] Promoted merchant onboarding (Lantern user attached to merchant, sets only portal password, Lantern encryption preserved)
- [ ] Three-tier sign-in matrix (admin / merchant / Lantern-only / wrong-password each surface correctly)
- [ ] Forgot-password isolation (merchant resets portal password; Lantern encryption canary still readable)
- [ ] Admin "Merchants" sidebar item โ†’ list page โ†’ row click opens merchant mode in a new tab
- [ ] Admin in merchant mode uses the switcher to navigate between merchants
- [ ] Real merchant attempting `/admin/users` is redirected to their own merchant
- [ ] Real merchant attempting `/merchant/<other-id>/...` is redirected to their own
- [ ] Old `/users`, `/docs`, `/merchants/<id>` URLs back-compat redirect
- [ ] `npm run validate` passes (lint zone rule fires on cross-zone import; build green; chunk-isolation check passes)

## Notes

- Some commits on the branch are from parallel work (`api-docs-renderer` package, signup form refinements, storybook stories) and are not part of this integration. They land along with this PR but are functionally orthogonal.
- One commit (`e469b93`) has a misleading message because a pre-commit hook bundled an unrelated styles.css change with the App.jsx three-tier sign-in changes. The code is correct; only the message is off.
- A follow-up agent should be scheduled (~2 weeks post-merge) to remove the back-compat URL redirects once the team has migrated bookmarks.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Known Gap: Backend Integration Tests โ€‹

The auth API still lacks route-level integration test scaffolding (Express + Firebase emulator + supertest). This PR ships unit tests around the extracted handlers (Phase F's merchantHandlers.test.js) but defers full integration tests to a follow-up issue ("Add route-level integration tests for auth-api admin + merchant endpoints"). Reference all three specs from this PR in that issue's description.


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

  • apps/merchant/ separate-app split (own Vite project / own Cloudflare Pages target)
  • @lantern/ui shared package extraction
  • Greenfield merchant dashboard / KPIs / activity feed
  • Merchant self-onboarding (signup, billing)
  • "View as merchant" admin impersonation
  • Multi-role users (single uid that's both admin AND merchant)
  • Lockout enforcement on failedLoginAttempts
  • Multi-factor auth on the merchant portal
  • Email change flow for merchants
  • Merchant-side mutation endpoints beyond what surfaces in Phase G's audit
  • Visual differentiation between admin and merchant shells
  • Removing the back-compat redirects (separate follow-up agent)

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

Built with VitePress