Skip to content
All writing

The admin console: a web app on a mobile app's Cognito pool

6 min read

nextjs
aws
react
buildinpublic

Every support question in Part 36 implied a place to answer it from. The web app had been a landing page and legal documents; it needed to become a console — a dashboard, a deletion queue, user lookup, announcements, the activity feed. The Next.js + MUI half of the monorepo finally earned its keep.

The architectural wrinkle that shaped everything: the console and the phone app share one Cognito user pool. Every shopper is a valid Cognito user. Which means a successful sign-in to the console proves precisely nothing about whether you belong there.

TL;DRThe front door signs non-admins straight back out: sign-up is removed from the web auth page, and a valid session that isn't in an admin group is ended with a plain message — because on a shared pool, a session proves identity, not membership. That's front-door only; the schema's group rules stay the real boundary, and the route guard's group check is defence in depth for a session that changes under you. The deletion queue renders its runbook inline; the user lookup surfaces the S3 identity id that's unrecoverable after deletion. Every admin table spreads one defaults object — which is also the dark-mode fix, because the table library reads the light palette's literals under CSS-variables mode and its colour maths throws on var(...). Forms get their own routes, never dialogs; labels above fields, never floating.

(Part 37 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The front door: a valid session proves nothing

Two decisions make the console admin-only at the door:

  1. No sign-up on the web auth page. Accounts are created in the phone app — or by hand, for admins. The console has no reason to mint users.
  2. A successful sign-in that isn't in SUPER_ADMIN or ADMIN is signed straight back out, with "this console is for administrators." The check reads the groups claim off the access token, in one module that is the single definition of the gate. The route guard carries the same check as defence in depth — for a session that changes under you (a demotion mid-session, a stale tab).

And the honest caveat, written in the code and the docs: all of that is front-door only. The API's allow.groups rules are the real boundary. Models on plain authenticated access — profiles, lists, receipts — remain readable by any signed-in user through the API, because that's what the phone app needs. The console's gate keeps the wrong people from seeing controls whose writes would fail; it isn't what stops the writes. Knowing which of your walls is load-bearing is most of security architecture.

The screens, and the decision in each

/deletion-requests — the queue of requests filed from the public website (in-app deletion runs the Lambda immediately and never files one, per Part 32). The fulfilment runbook renders inline, next to the row's status, so working a request no longer means opening a markdown file beside the console. A runbook nobody has open is a runbook nobody follows.

/users — lookup for support questions, and one deliberately prominent detail: each user's row shows the Cognito sub and the S3 identity id. The identity id is unrecoverable once the Cognito user is deleted, and the deletion runbook needs it first — so the screen puts it where you'll capture it before you do anything irreversible.

/announcements — a composer with a live phone preview (the announcement rendered as the app will show it) and a typed confirmation for anything blocking (Part 35). /activity — the feed, with its filters in the URL.

Inviting an admin — a dialog on the users page calling a group-gated mutation, with distinct toasts for "invited fresh" vs "promoted an existing user," and retry: 0 on the mutation, because a slow identity-provider call that retries is how you double-invite someone.

One defaults object for every table (and the dark-mode fix inside it)

Admin lists are material-react-table, and the first two screens each had their own configuration. The third would have had a third. So: one adminTableDefaults() that every table spreads first, one stat-card component, and a rule for the cards themselves — a stat earns its place only if it changes what the admin does next, and the "alert" style means something is wrong, never something is big. (Yes, that's Part 26's Home rule, applied to a different audience.)

The defaults object is also where the dark-mode bug got fixed, and the bug is worth understanding. The table library paints its own surfaces by reading theme.palette.*. Under MUI's CSS-variables colour-scheme mode, those values are the light scheme's literal colours — the flipping references live in theme.vars.* — so every surface the library painted for itself stayed white under .dark. The obvious fix, handing it CSS variables, fails differently: the library runs darken()/lighten() over its base background colour, and MUI's colour maths throws on var(...). So the surfaces are painted through sx (which does no maths) — and only the two properties the library assigns directly without maths take variables, which happens to be the only way to reach the row-action popover, the one surface sx can't touch. A fix that's three lines and a paragraph of reasoning, which is why the paragraph lives in the code.

Tables also got a fixed frame: sticky header, body scrolling inside a bounded container, toolbar and pagination pinned — with head cells kept opaque because rows scroll under them (the web cousin of Part 12's sticky-header lesson).

Forms get routes; labels sit above fields

Two house rules the console enforces everywhere:

  • Forms get their own route (/announcements/new, /announcements/[id]), never a dialog on the list page. A form in a dialog can't be linked, refreshed, or half-finished safely.
  • Labels above fields — never MUI's floating labels. One shared form-field component (FormControlFormLabel → input), and pickers get no label prop for the same reason. Floating labels collide with placeholder text, autofill, and every screen reader test I've run.

Every destructive action goes through one shared delete dialog; confirm() is banned. Web reads page through the same load-everything helper ported from mobile; admin writes use the user-pool auth mode, never the identity-pool mode the public deletion form uses.

The console also got light/dark/system theming — but default light, not system: the public landing was designed light, and dark is an explicit per-browser choice. And the reason the theme toggle sits in this post rather than being trivial is the next part: making MUI and Tailwind coexist broke in two spectacular ways before it worked.

What I took away

  • A shared identity pool means sessions prove identity, not membership. Gate at the door, enforce at the API, and know which is which.
  • Put the runbook next to the row. Documentation you have to go find is documentation that gets skipped.
  • Surface the value you'll need before the irreversible step — the identity id on the user row exists for the deletion runbook.
  • One defaults object per library, spread first, and put the workaround's reasoning in it.
  • Library colour maths and CSS variables don't mix — paint surfaces with plain styles, pass variables only where nothing computes on them.
  • retry: 0 on anything that creates. Retries turn slow into duplicate.

Next up

Part 38 is the war story this console produced: MUI vs Tailwind — two CSS systems, one cascade, and the two one-line changes that each silently erased every input outline in the app.

If your admin app shares a user pool with your consumer app, what happens today when a regular user signs in to it?


0 reactions · 0 comments

Discuss on dev.to