feat: read-only admin page gated by Spotify ID allowlist#209
Merged
Conversation
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.
802f64d to
23b320e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a read-only
/adminpage for the self-hosted owner. Access is gated server-side by arequireAdminmiddleware that consults aADMIN_SPOTIFY_IDSenv-var allowlist (comma-separated Spotify user IDs), and client-side by a newAdminRoutewrapper. The page shows users (with derived token health), active sessions from the connect-pg-simplesessiontable, pg_cron keepalive status, and a tail of the winston error log.requireAdminmiddleware,/api/admin/{users,sessions,keepalive,logs}endpoints,isAdminfield on/api/auth/user, and a centralized log-file path constant inlogger.ts.AdminPagewith sections for each endpoint,AdminRoutewrapper, and an Admin link in the user menu visible only whenisAdmin.Testing
isAdminandrequireAdmincovering missing user, non-admin, and admin cases/api/admin/*endpoint returns 401 unauthenticated/admincovering unauthenticated →/, authenticated-non-admin →/visualization, and admin → renderpnpm format:check,pnpm lint,pnpm client:test,pnpm server:test,pnpm client:build, both typechecks (build configs) all pass locallyADMIN_SPOTIFY_IDS=cdtinneyin your.env, restart, log in, navigate to/admin(or via the user menu)