Skip to content

Admin Ban Wiring + Manual Ban View - Manual Test Plan โ€‹

FieldValue
Branch / PRclaude/sealed-identity-stage-b (working tree on ac25e601, ban-wiring changes uncommitted at time of writing)
Environmentlocal dev: admin :3001 -> auth-api :8084, hits LIVE lantern-app-dev Firebase
Build flags / confign/a (no feature flag gates the admin ban UI)
Build (commit)__________ (fill with git rev-parse --short HEAD at test time)
TesterMechelle
Date2026-06-28 (follow-up log added 2026-07-13)

Summary โ€‹

#ScenarioResultNotes
1Moderation page: Cases | Manual ban tab switch[x] passno issues
2Manual ban - Phone mode UI + confirm modal (cancel)[x] passdefaults to Phone; cancel writes nothing
3Manual ban - User ID mode swaps label/hint[x] passlabel/hint/input swap correct
4Manual ban - client validation (empty fields)[ ] pass [ ] fail [x] blockedsubmit correctly disabled, but NO required-field error messages surface (UI follow-up)
5Manual ban - non-existent UID -> error banner (no write)[x] pass404 NOT_FOUND banner, no write. UI nits: Review-ban button shifts on error; drop the divider above the CTA
6Manual ban - guardrail errors (self / admin)[x] pass422 SELF_BAN then CANNOT_BAN_ADMIN; no writes
7LIVE: Manual ban by phone -> blocks re-registration[x] pass (partial)ban works; success banner vanishes after a few seconds (should persist until navigation)
8LIVE: Manual ban by userId -> disables login[x] pass (partial)login blocked, but the message reads "incorrect pin" instead of "user disabled"; banned user shows on the Users page
9LIVE: Ban a SEALED account (privacy invariant)[x] pass (confirmed 2026-07-13) [ ] fail [ ] blockedinitially blocked (test-data confusion); privacy invariant CONFIRMED in the 2026-07-01 sealed-ban run - see the ยง9 follow-up log
10LIVE: Users-page Ban repoint (the bug fix)[x] passroutes through the Express moderation API + writes banned_accounts
11LIVE: Users-page Unban -> restores both axes[x] passunban restores login + overturns the number ban

Setup / preconditions โ€‹

  • Services running:
    • auth-api: from services/api/auth/, run npm run dev (listens on :8084).
    • admin: from repo root, npm run dev -w apps/admin (serves :3001).
    • VITE_AUTH_API_URL in root .env.local must point at the auth-api (e.g. http://localhost:8084). App Check works the same way it does for the existing admin API calls (Users page, Merchants), so if those load, moderation calls will too.
  • Sign in to the admin portal at http://localhost:3001 as an admin.
  • Test accounts / numbers (guardrail):
    • Phone bans: use a Prelude test number only (do not use a real subscriber number until approved).
    • userId bans: use a throwaway dev app-user you can afford to disable, NOT your own admin and NOT another admin.
    • For the sealed-account case you need a sealed app-user (a users/{uid} doc with NO phoneHash/phone field; auth_lookup/{hash}.encryptedUserIdBlob present).
  • Where to verify data (Firebase console, project lantern-app-dev):
    • banned_accounts/{banId} - phoneHash, emailHash, severity, expiresAt, bannedBy, evidence, appealStatus.
    • users/{uid} - banned, bannedAt, bannedBy, banRecordIds.
    • adminActions - newest doc: action, targetUserId/banId, performedBy, performedAt.
    • Auth tab - the user's Disabled flag.
    • Browser DevTools Network tab - the POST /auth/moderation/* request status + JSON body.
  • Reaching a clean state: after a LIVE ban, unban the same account (Scenario 11 covers the userId axis; for a phone ban, overturn it via the API or Firestore so the number can register again).

Scenarios โ€‹

1. Moderation page: Cases | Manual ban tab switch โ€‹

  • Goal: the new top-level PageTabs switch works and does not disturb the Cases prototype.
  • Preconditions: signed in as admin.
  • Steps:
    1. Navigate to Moderation in the left nav (/admin/moderation).
    2. Confirm the page opens on the Cases tab (amber underline) with the mock case list, metric cards, and the "Prototype with mock data" banner.
    3. Click the Manual ban tab.
    4. Click back to Cases.
  • Expected: the tab switch toggles between the two views; Cases shows the mock list + drawer behavior unchanged; switching to Manual ban hides the case list and the case drawer.
  • Verify (data/console): no console errors on switch; no network calls fire just from switching tabs.
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    No Issues Found.
Evidence:  See console log below.
Follow-up: ______________________________________________
Navigated to http://localhost:3001/admin/moderation
firebase.js:97 App Check initialized successfully
installHook.js:1 โš ๏ธ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition.
overrideMethod @ installHook.js:1
warnOnce @ react-router-dom.js?v=03be85c5:3614
logDeprecation @ react-router-dom.js?v=03be85c5:3617
logV6DeprecationWarnings @ react-router-dom.js?v=03be85c5:3619
(anonymous) @ react-router-dom.js?v=03be85c5:4411
react_stack_bottom_frame @ react-dom_client.js?v=03be85c5:12904
runWithFiberInDEV @ react-dom_client.js?v=03be85c5:851
commitHookEffectListMount @ react-dom_client.js?v=03be85c5:6617
commitHookPassiveMountEffects @ react-dom_client.js?v=03be85c5:6652
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7618
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7617
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7672
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7627
flushPassiveEffects @ react-dom_client.js?v=03be85c5:8808
(anonymous) @ react-dom_client.js?v=03be85c5:8562
performWorkUntilDeadline @ react-dom_client.js?v=03be85c5:36
<BrowserRouter>
exports.jsxDEV @ react_jsx-dev-runtime.js?v=03be85c5:193
(anonymous) @ App.jsx:365
react_stack_bottom_frame @ react-dom_client.js?v=03be85c5:12868
renderWithHooksAgain @ react-dom_client.js?v=03be85c5:4268
renderWithHooks @ react-dom_client.js?v=03be85c5:4219
updateFunctionComponent @ react-dom_client.js?v=03be85c5:5569
beginWork @ react-dom_client.js?v=03be85c5:6140
runWithFiberInDEV @ react-dom_client.js?v=03be85c5:851
performUnitOfWork @ react-dom_client.js?v=03be85c5:8429
workLoopSync @ react-dom_client.js?v=03be85c5:8325
renderRootSync @ react-dom_client.js?v=03be85c5:8309
performWorkOnRoot @ react-dom_client.js?v=03be85c5:7957
performWorkOnRootViaSchedulerTask @ react-dom_client.js?v=03be85c5:9059
performWorkUntilDeadline @ react-dom_client.js?v=03be85c5:36
<App>
exports.jsxDEV @ react_jsx-dev-runtime.js?v=03be85c5:193
(anonymous) @ main.jsx:41
installHook.js:1 โš ๏ธ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath.
overrideMethod @ installHook.js:1
warnOnce @ react-router-dom.js?v=03be85c5:3614
logDeprecation @ react-router-dom.js?v=03be85c5:3617
logV6DeprecationWarnings @ react-router-dom.js?v=03be85c5:3620
(anonymous) @ react-router-dom.js?v=03be85c5:4411
react_stack_bottom_frame @ react-dom_client.js?v=03be85c5:12904
runWithFiberInDEV @ react-dom_client.js?v=03be85c5:851
commitHookEffectListMount @ react-dom_client.js?v=03be85c5:6617
commitHookPassiveMountEffects @ react-dom_client.js?v=03be85c5:6652
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7618
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7617
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7672
recursivelyTraversePassiveMountEffects @ react-dom_client.js?v=03be85c5:7606
commitPassiveMountOnFiber @ react-dom_client.js?v=03be85c5:7627
flushPassiveEffects @ react-dom_client.js?v=03be85c5:8808
(anonymous) @ react-dom_client.js?v=03be85c5:8562
performWorkUntilDeadline @ react-dom_client.js?v=03be85c5:36
<BrowserRouter>
exports.jsxDEV @ react_jsx-dev-runtime.js?v=03be85c5:193
(anonymous) @ App.jsx:365
react_stack_bottom_frame @ react-dom_client.js?v=03be85c5:12868
renderWithHooksAgain @ react-dom_client.js?v=03be85c5:4268
renderWithHooks @ react-dom_client.js?v=03be85c5:4219
updateFunctionComponent @ react-dom_client.js?v=03be85c5:5569
beginWork @ react-dom_client.js?v=03be85c5:6140
runWithFiberInDEV @ react-dom_client.js?v=03be85c5:851
performUnitOfWork @ react-dom_client.js?v=03be85c5:8429
workLoopSync @ react-dom_client.js?v=03be85c5:8325
renderRootSync @ react-dom_client.js?v=03be85c5:8309
performWorkOnRoot @ react-dom_client.js?v=03be85c5:7957
performWorkOnRootViaSchedulerTask @ react-dom_client.js?v=03be85c5:9059
performWorkUntilDeadline @ react-dom_client.js?v=03be85c5:36
<App>
exports.jsxDEV @ react_jsx-dev-runtime.js?v=03be85c5:193
(anonymous) @ main.jsx:41
firebase_app-check.js?v=03be85c5:1104 App Check debug token: fe53aa9a-a743-4e3d-b97e-1f784074c409. You will need to add it to your app's App Check settings in the Firebase console for it to work.
AdminMigrationBanner.jsx:41 ๐Ÿ” CorruptedEncryptionAlert: Checking user aR7ivxIk48Wijw1nXBs8C9KyfhB2 trigger: null
installHook.js:1 ๐Ÿ” CorruptedEncryptionAlert: Checking user aR7ivxIk48Wijw1nXBs8C9KyfhB2 trigger: null
AdminMigrationBanner.jsx:45 ๐Ÿ” CorruptedEncryptionAlert: userData {exists: true, salt: true, encryptionCorrupted: false, encryptionReinitialized: true, lanternName: 'Celestial Glow'}
AdminMigrationBanner.jsx:64 ๐Ÿ” CorruptedEncryptionAlert: Already reinitialized
AdminMigrationBanner.jsx:45 ๐Ÿ” CorruptedEncryptionAlert: userData {exists: true, salt: true, encryptionCorrupted: false, encryptionReinitialized: true, lanternName: 'Celestial Glow'}
AdminMigrationBanner.jsx:64 ๐Ÿ” CorruptedEncryptionAlert: Already reinitialized

2. Manual ban - Phone mode UI + confirm modal (cancel) โ€‹

  • Goal: the phone-mode form renders on-brand and the confirm modal summarizes correctly, with no write on cancel.
  • Preconditions: on Moderation -> Manual ban.
  • Steps:
    1. Confirm Ban by defaults to Phone number (amber selected) and the field label reads "Phone number".
    2. Read the hint under the field: it should say it blocks re-registration and does not disable a logged-in account.
    3. Enter a Prelude test number, a reason, and leave Duration on Permanent.
    4. Click Review ban.
    5. In the confirm modal, read the summary; click Cancel.
  • Expected: confirm modal shows "Phone: <number>", "Duration: Permanent", "Reason: <text>", plus the amber "acts on a live account immediately" warning. Cancel closes the modal and returns to the filled form. No success banner.
  • Verify (data/console): Network tab shows NO POST /auth/moderation/* request (cancel does not call the API).
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Defaults to phone
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

3. Manual ban - User ID mode swaps label/hint โ€‹

  • Goal: switching to User ID mode re-labels the field and updates the hint + input type.
  • Preconditions: on Manual ban.
  • Steps:
    1. Click the User ID segment of the Ban by toggle.
    2. Observe the field label, placeholder, and hint.
  • Expected: label becomes "User ID", placeholder "Firebase Auth UID", and the hint now reads that it disables login, ends active sessions, and blocks re-registration when a number is on file. The amber selection moves to User ID.
  • Verify (data/console): no console errors.
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Swaps label/hint correctly
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

4. Manual ban - client validation (empty fields) โ€‹

  • Goal: the form cannot be submitted without an identifier and a reason.
  • Preconditions: on Manual ban, fields empty.
  • Steps:
    1. With both identifier and reason empty, observe the Review ban button.
    2. Type an identifier only (no reason); observe the button.
    3. Clear the identifier, type a reason only; observe the button.
  • Expected: Review ban is disabled until BOTH the identifier and the reason are non-empty. No confirm modal opens while disabled. (If a path reaches review with an empty field, an inline error appears instead.)
  • Verify (data/console): no network call while fields are incomplete.
Result:    [ ] pass   [ ] fail   [ X ] blocked
Actual:    For both instances, it correctly did not submit. However, there weren't any error messages that showed up to indicate that the fields were required. This may be a UI issue that needs to be addressed.
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

5. Manual ban - non-existent UID -> error banner (no write) โ€‹

  • Goal: a bad UID surfaces the server error cleanly and writes nothing.
  • Preconditions: on Manual ban, User ID mode.
  • Steps:
    1. Enter a clearly fake UID (e.g. uid_does_not_exist_123), a reason, Permanent.
    2. Click Review ban, then Confirm ban.
  • Expected: a red error banner appears reading "User not found" (the server rejects before any write). No success banner.
  • Verify (data/console): Network tab shows POST /auth/moderation/ban -> 404 { error: "NOT_FOUND" }. NO new banned_accounts doc and NO new adminActions doc for this attempt.
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Error banner appears as expected; no writes occurred.
Evidence:  See screenshot below
Follow-up: The "review ban" button gets shifted down when the error banner appears. A bit of an odd UI quirk, but not a functional issue. We should also remove that divider above the ban CTA, it isn't necessary and makes the UI look a bit odd.

alt text


6. Manual ban - guardrail errors (self / admin) โ€‹

  • Goal: the backend refuses self-bans and admin-bans, and the UI shows the message.
  • Preconditions: on Manual ban, User ID mode. Have your own admin UID and a second admin UID handy.
  • Steps:
    1. Enter YOUR OWN admin UID + a reason; Review ban -> Confirm ban.
    2. Then enter a DIFFERENT admin's UID + a reason; Review ban -> Confirm ban.
  • Expected: step 1 shows a red banner "Cannot ban yourself"; step 2 shows "Cannot ban admin users". No account is disabled in either case.
  • Verify (data/console): Network shows 422 SELF_BAN then 422 CANNOT_BAN_ADMIN. Auth tab: neither admin's Disabled flag changes.
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Error banners appear as expected; no writes occurred.
Evidence:  See screenshots below
Follow-up: ______________________________________________

alt text

alt text


7. LIVE: Manual ban by phone -> blocks re-registration โ€‹

LIVE write. Uses a Prelude test number only. Creates a real banned_accounts row. Clean up by overturning the ban afterward.

  • Goal: banning a number writes a peppered phoneHash ban and stops that number from registering.
  • Preconditions: Manual ban, Phone mode. Pick a Prelude test number that is NOT currently tied to a live account.
  • Steps:
    1. Enter the Prelude test number, a reason, Duration Permanent.
    2. Review ban -> Confirm ban.
    3. Note the success banner.
    4. In a separate flow, attempt to sign up / request an OTP with that same number.
  • Expected: green success banner "Phone ban created". The subsequent signup/OTP attempt on that number is refused at the ban gate (403 BANNED).
  • Verify (data/console): Network: POST /auth/moderation/ban-phone -> 200 { banId, message: "Phone ban created" }. Firestore banned_accounts/{banId}: phoneHash set (v1:...), emailHash null, severity: "permanent", expiresAt null, evidence NOT containing any userId. adminActions newest: action: "banPhone".
Result:    [ X ] pass ( partial )   [ ] fail   [ ] blocked
Actual:    The banned banner did show, but it disappeared after a few seconds. The ban was still in effect, but the banner should remain until the user navigates away from the page. 
Evidence:  Couldn't capture the screenshot fast enough and but the activity did show up in the network. Nothing in the console log.
Follow-up: ______________________________________________

alt text


8. LIVE: Manual ban by userId -> disables login โ€‹

LIVE write. Uses a throwaway dev app-user. Disables that account + revokes its sessions. Clean up via Scenario 11 (or unban from the Users page).

  • Goal: banning a userId disables Auth login, kills sessions, and (when a number is on file) blocks re-registration.
  • Preconditions: Manual ban, User ID mode. Have a throwaway app-user's UID, ideally logged in on a second device/browser so you can watch the session drop.
  • Steps:
    1. Enter the throwaway user's UID, a reason, Permanent.
    2. Review ban -> Confirm ban; note the success banner.
    3. On the second device where that user was logged in, try to act / refresh.
    4. Try to log in fresh as that user.
  • Expected: green "User banned permanently". The user's active session can no longer perform authenticated actions; a fresh login is rejected (auth/user-disabled).
  • Verify (data/console): Network: POST /auth/moderation/ban -> 200. Auth tab: user Disabled = true. Firestore users/{uid}: banned: true, bannedAt, bannedBy = your admin UID, banRecordIds present (if a number was on file). banned_accounts/{banId}: created with phoneHash (if number on file). adminActions newest: action: "banUser".
Result:    [ X ] pass ( partial )   [ ] fail   [ ] blocked
Actual:    When trying to signin again, I was correctly blocked. However the message says "incorrect pin" instead of "user disabled". Side note success, I do see the banned user in the admin users page, this likely still needs to be wired up in the moderation page.
Evidence:  See screenshot below.
Follow-up: Wondering in this scenario if we add in an option to reach out to support if the user believes this was a mistake.

alt text

admin > users successufilly shows the banned user with the disabled flag set to true

alt text


9. LIVE: Ban a SEALED account (privacy invariant) โ€‹

LIVE write. Uses a sealed throwaway app-user. This guards the Stage B section 14.6 invariant: no record may co-locate a phone (or phoneHash) with a userId.

  • Goal: a sealed account (no phone/phoneHash on its doc) can be login-banned WITHOUT creating any phone<->userId link.
  • Preconditions: a sealed users/{uid} (no phoneHash/phone); you know its UID.
  • Steps:
    1. Manual ban, User ID mode: enter the sealed user's UID, a reason, Permanent.
    2. Review ban -> Confirm ban.
  • Expected: green success; the sealed user's login is disabled.
  • Verify (data/console):
    • Auth tab: Disabled = true; Firestore users/{uid}.banned: true.
    • No number-ban leakage: there is NO banned_accounts row that pairs this user's number/phoneHash with the userId. If a banned_accounts row was written at all, its evidence is exactly source:moderation-ban (no plaintext userId), and it does NOT also carry a phoneHash that the sealed doc lacked. For a fully sealed account with no derivable number, expect NO banned_accounts row from this action.
    • adminActions newest: action: "banUser", targetUserId = the UID (audit log is allowed to reference the userId; the privacy invariant is specifically about banned_accounts).
Result:    [ ] pass   [ ] fail   [ X ] blocked
Actual:    I was going to unban the number that has a sealed account but I ended up seeing two "Nebula Candle" accounts. Now I am not sure if this is related to the ban I did on the user id or the phone. The phone number ban was done with a prelude test number ( which should also be a sealed account ).
Evidence:  See screenshot below.
Follow-up:

alt text

Follow-up log (2026-07-13, sealed-ban reconciliation run): the sealed-account userId ban + the Stage B section 14.6 privacy invariant are now CONFIRMED (this scenario was blocked here by a test-data mix-up, not a ban bug). In the 2026-07-01 sealed-ban run, a sealed account (userId 1af5b6a2...) was banned by userId via the admin portal and ban-doctor verified the invariant held: users/{uid}.banned=true + Auth disabled=true, but banRecordIds=[] (NO phone<->userId link), and the phone-side block materialized ONLY as a hash-only login-harvest banned_accounts row (evidence: source:login-harvest, no userId). So a sealed userId ban never co-locates a phone/phoneHash with a userId. Reinstate was also confirmed end-to-end: admin /unban plus the new Option B login-harvest self-clear now fully reinstates a sealed account (previously a deadlock at /lookup). The earlier "two Nebula Candle accounts" was a duplicate-test-account artifact, not a ban side effect.


10. LIVE: Users-page Ban repoint (the bug fix) โ€‹

LIVE write. This is the core fix: the Users-page Ban now routes through the Express moderation API, which (unlike the old Cloud Function) writes a banned_accounts record so the account cannot re-register.

  • Goal: banning from the Users table disables login AND blocks re-registration.
  • Preconditions: a throwaway, non-sealed app-user that HAS a number on file; find them in the Users list.
  • Steps:
    1. Go to Users, open the throwaway user's detail panel.
    2. Click Ban, enter a reason, choose a duration, confirm.
    3. Note the success message.
    4. Attempt to re-register / request an OTP with that user's number.
  • Expected: ban succeeds; the user cannot log in; re-registration with their number is refused at the gate (this is the behavior the legacy Cloud Function did NOT provide).
  • Verify (data/console): Network: POST /auth/moderation/ban -> 200 (NOT a Cloud Function call). Auth tab: Disabled = true. Firestore: users/{uid}.banned: true AND a NEW banned_accounts/{banId} with the user's phoneHash. adminActions: action: "banUser".
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Seemed to have banned correctly ( account was Soft Luster / I believe it isn't a sealed account or was at least created before the sealed account integration )
Evidence:  see screenshot below
Follow-up: Side note, after the ban, it removed my search filter and I had to search for the user again. Not sure if this is expected behavior or not.

alt text


11. LIVE: Users-page Unban -> restores both axes โ€‹

LIVE write. Reverses Scenario 10. Returns the throwaway account to a clean state.

  • Goal: unbanning re-enables login and overturns the number ban so the account can register again.
  • Preconditions: the user banned in Scenario 10.
  • Steps:
    1. Open that user's detail panel (now showing Banned).
    2. Click Unban.
    3. Attempt to log in / re-register with that user's number.
  • Expected: unban succeeds; login works again; re-registration is allowed again.
  • Verify (data/console): Network: POST /auth/moderation/unban -> 200. Auth tab: Disabled = false. Firestore: users/{uid}.banned: false, banRecordIds cleared; the prior banned_accounts/{banId} now has appealStatus: "overturned" and an expiresAt in the past. adminActions: action: "unbanUser".
Result:    [ X ] pass   [ ] fail   [ ] blocked
Actual:    Seemed to have unbanned correctly and I was able to log in again ( account was Soft Luster / I believe it isn't a sealed account or was at least created before the sealed account integration )
Evidence:  
Follow-up:

Built with VitePress