Skip to content

feat: read-only admin page gated by Spotify ID allowlist#209

Merged
cdtinney merged 11 commits into
mainfrom
cdtinney/admin-page-q
Apr 29, 2026
Merged

feat: read-only admin page gated by Spotify ID allowlist#209
cdtinney merged 11 commits into
mainfrom
cdtinney/admin-page-q

Conversation

@cdtinney
Copy link
Copy Markdown
Owner

Summary

Adds a read-only /admin page for the self-hosted owner. Access is gated server-side by a requireAdmin middleware that consults a ADMIN_SPOTIFY_IDS env-var allowlist (comma-separated Spotify user IDs), and client-side by a new AdminRoute wrapper. The page shows users (with derived token health), active sessions from the connect-pg-simple session table, pg_cron keepalive status, and a tail of the winston error log.

  • Server: requireAdmin middleware, /api/admin/{users,sessions,keepalive,logs} endpoints, isAdmin field on /api/auth/user, and a centralized log-file path constant in logger.ts.
  • Client: AdminPage with sections for each endpoint, AdminRoute wrapper, and an Admin link in the user menu visible only when isAdmin.
  • pg_cron status degrades gracefully when the extension isn't installed (e.g., local dev without the keepalive migration).
  • Admin endpoints expose only the metadata needed for display (no encrypted token blobs, session SIDs are truncated).

Testing

  • Unit tests for isAdmin and requireAdmin covering missing user, non-admin, and admin cases
  • Supertest smoke test that confirms each /api/admin/* endpoint returns 401 unauthenticated
  • React routing tests for /admin covering unauthenticated → /, authenticated-non-admin → /visualization, and admin → render
  • pnpm format:check, pnpm lint, pnpm client:test, pnpm server:test, pnpm client:build, both typechecks (build configs) all pass locally
  • Manually: set ADMIN_SPOTIFY_IDS=cdtinney in your .env, restart, log in, navigate to /admin (or via the user menu)

cdtinney added 11 commits April 28, 2026 22:30
Admin access is gated by a comma-separated `ADMIN_SPOTIFY_IDS` env var
and exposed through a new `requireAdmin` middleware. The /admin page
shows users (with derived token health), active sessions from the
connect-pg-simple session table, pg_cron keepalive status (degrades
gracefully when pg_cron isn't installed), and a tail of the winston
error log.
- Token status pill now reads "fresh" / "stale" with a yellow warn
  color instead of "valid" / "expired" red, plus a one-liner clarifying
  that stale tokens auto-refresh on the next Spotify API call. The
  previous wording suggested an action was required when none is.
- /api/admin/logs now returns just the log file basename instead of
  the absolute filesystem path, removing unnecessary disclosure of the
  server's working directory.
- routes.test.tsx: extract a `renderAt(path)` helper to replace 7
  duplicated `<MemoryRouter initialEntries={[...]}><AppRoutes/>` blocks.
- admin.test.ts: collapse the 401/403 requireAdmin cases into a single
  parameterized `it.each`, and consolidate the mock res/next setup
  behind a small `callRequireAdmin` helper.
AdminPage.css was using hardcoded colors, borders, and radii that the
codebase already exposes as CSS custom properties in index.css. This
swaps the duplicated values for the existing tokens
(--color-text-primary, --color-text-secondary, --color-border-subtle,
--color-bg-surface, --radius-sm) and keeps only the semantic pill
colors and the deeper "code" background hardcoded.

Also drops the redundant `box-sizing: border-box` on .user-menu__item
since index.css applies that globally to *.
Server:
- packages/server/src/auth/admin.ts: drop the cachedRaw/cachedSet
  manual memo. Parsing a tiny comma-separated env var per request is
  cheap; the memo was solving a non-problem with two module-level
  mutables.
- packages/server/src/database/queries/adminQueries.ts: replace the
  three inline `safeQuery(async () => {...}, null as ...)` lambdas in
  getKeepaliveStatus with three named functions
  (queryLastPing/queryCronJobs/queryRecentCronRuns), each declaring
  its own return type explicitly. Removes the awkward `as` casts.
- packages/server/src/routes/admin.ts: extract `safeAdminHandler(name,
  fn)` to wrap each route's try/catch + logger.error + 500 response.
  Each handler shrinks from ~8 lines to ~4.

Client:
- packages/client/src/pages/AdminPage.tsx: extract a generic
  <DataTable<T>> with a column-config pattern; UsersSection,
  SessionsSection, and the two sub-tables in KeepaliveSection now use
  it instead of repeating <table>/<thead>/<tbody> JSX. Move repeated
  token-expiry math into a `tokenExpiresAt` helper. Promote the log
  entry renderer from a function to a <LogLine> component for
  symmetry. Rename `useFetch` → `useFetchOnce` to communicate the
  once-on-mount semantics that justify the exhaustive-deps suppression.
AdminPage.tsx was a single 290-line file containing the layout, four
section components, the fetch hook, formatters, the table component,
the section wrapper, and a status-line component. Split into focused
files under packages/client/src/features/admin/:

- formatters.ts — formatTimestamp + formatDuration. The latter now
  uses Intl.RelativeTimeFormat for locale-aware "in 5 minutes" /
  "5 minutes ago" output instead of hand-rolled "5m" abbreviations.
- useFetchOnce.ts — the mount-once data fetch hook + FetchState type.
- Section.tsx — Section + StatusLine helpers used by every section.
- DataTable.tsx — generic table with column-config pattern.
- UsersSection.tsx, SessionsSection.tsx, KeepaliveSection.tsx,
  LogsSection.tsx — one section per file.

AdminPage.tsx now just composes the four sections.
Switching the admin gate from a binary boolean to a typed user role
makes future expansion (e.g., adding 'moderator' or 'beta') a one-line
union extension instead of a new boolean prop wired through every
layer.

Server (auth/userType.ts, renamed from auth/admin.ts):
- New `UserType = 'admin' | 'user'` string literal union.
- `isAdmin()` becomes `getUserType(spotifyId)` which returns the type
  directly. The non-admin Spotify-ID-allowlist path now returns
  'user' instead of false.
- `requireAdmin` middleware becomes `requireUserType(type)` factory.
  Used as `requireUserType('admin')` in routes/admin.ts.
- /api/auth/user response now exposes `userType` instead of `isAdmin`.

Client:
- UserProfile.isAdmin → UserProfile.userType, with a UserType union
  re-exported from types/index.ts.
- AdminRoute checks `user.userType !== 'admin'`.
- UserMenu accepts `userType` instead of `isAdmin` so the menu can
  conditionally render per role without a per-role boolean prop.
- Tests updated to mock `userType` values.
`role` is the more conventional auth term and what the rest of the
ecosystem uses. Mechanical rename across server (UserType → Role,
getUserType → getRole, requireUserType → requireRole, file
auth/userType.ts → auth/role.ts) and client (UserProfile.userType →
UserProfile.role, UserMenu.userType → UserMenu.role).
Drops the keepalive and log-tail sections — both have easier
out-of-band alternatives (Supabase dashboard for pg_cron, ssh + tail
for logs) and the supporting code was disproportionate to the value.

What's gone:
- /api/admin/keepalive and /api/admin/logs routes plus their
  handler/parser/clamp helpers and the safeAdminHandler wrapper
- queryLastPing, queryCronJobs, queryRecentCronRuns, the safeQuery
  helper, and the AdminCron* / AdminKeepalive* types
- Centralized log-file path constants (revert; only the dropped
  /logs handler used them)
- Eight files under features/admin/: DataTable, Section,
  StatusLine, useFetchOnce, formatters, and the four section
  components. With only two sections left, the abstractions were
  pulling their weight purely for symmetry, not reuse.

What's left (admin scope):
- packages/server/src/routes/admin.ts: two GET handlers, ~28 lines.
- packages/server/src/database/queries/adminQueries.ts: listUsers
  and listActiveSessions, ~73 lines.
- packages/client/src/features/admin/api.ts: two fetch calls + two
  types, ~26 lines.
- packages/client/src/pages/AdminPage.tsx: a single Promise.all
  fetch, two inline tables, one inline relative-time helper using
  Intl.RelativeTimeFormat, ~123 lines.
- packages/client/src/pages/AdminPage.css: 34 lines (down from 124).
- font-family monospace was a deliberate "code"-feel choice but
  nothing else on the page is monospace. Inherit body sans-serif.
- color override duplicates what body already sets via `color: #fff`.
- text-align: left on td is the default; only th needs the override.
- font-weight: 600 on th overrides the default `bold`. Default is fine.

34 → 27 lines. No visual change beyond the font-family swap (which
matches the rest of the app).
- AdminPage.tsx: drop the formatRelative helper and the "Expires"
  column it served. Token Updated + Status already convey freshness;
  the relative-time cascade was 17 lines of code for a marginal
  column. Unify the loading/error/data paths into one render block
  with conditional content (was three separate early-return
  wrappers each duplicating the .admin-page div). Inline the
  AdminData interface (used once).
- role.ts: collapse parseAdminIds into the natural one-liner.
- role.test.ts: parameterize the five getRole cases with it.each
  (matching the requireRole tests in the same file).
- adminQueries.ts: inline the toBigIntNumber helper as a `== null`
  check + Number() at each call site.
@cdtinney cdtinney force-pushed the cdtinney/admin-page-q branch from 802f64d to 23b320e Compare April 29, 2026 05:30
@cdtinney cdtinney marked this pull request as ready for review April 29, 2026 05:30
@cdtinney cdtinney merged commit 3148ca5 into main Apr 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant