From 64af4d748c38991972eecd402d0101b4b5bd47ee Mon Sep 17 00:00:00 2001 From: Rei Kurata Date: Thu, 20 Aug 2026 13:06:59 -0700 Subject: [PATCH 1/5] vault: draft admin-access feature spec Scaffold fofafu_vault/features/admin-access.md and draft Problem/Acceptance criteria/Out of scope/Open questions with the user: a single hardcoded admin account with full edit access (including DMs) across all user data. Design targets Supabase (is_admin() RLS bypass policies + a new admin Edge Function + admin_audit_log table) rather than the legacy Express/sqlite stack, since migrate-render-to-vercel-supabase is actively decommissioning Express. Co-Authored-By: Claude Sonnet 5 --- fofafu_vault/features/admin-access.md | 89 +++++++++++++++++++++++++++ fofafu_vault/kanban/company.md | 1 + fofafu_vault/kanban/engineering.md | 1 + fofafu_vault/log/2026-08-20.md | 2 + 4 files changed, 93 insertions(+) create mode 100644 fofafu_vault/features/admin-access.md create mode 100644 fofafu_vault/log/2026-08-20.md diff --git a/fofafu_vault/features/admin-access.md b/fofafu_vault/features/admin-access.md new file mode 100644 index 0000000..00ff67e --- /dev/null +++ b/fofafu_vault/features/admin-access.md @@ -0,0 +1,89 @@ +--- +slug: admin-access +title: Admin Access +owner: engineering +collaborators: [] +status: drafting +priority: P2 +created: 2026-08-20 +target: null +links: + kanban: "[[kanban/engineering]]" + designs: null +--- + +# Admin Access + +## Problem + +There is no admin capability in fofafu today. Every table (`families`, `announcements`, `comments`, `reactions`, `messages`, `availability_slots`, `playdate_requests`, `coach_events`) now lives in Supabase Postgres with RLS scoping every read/write to `auth.uid()` (see [[features/migrate-render-to-vercel-supabase]], [[features/supabase-rls-sensitive-columns]]), and there is no identity in the system that can see or fix another family's data. [[features/moderation-report-block]]'s own Problem statement names the gap directly — "the only available escape valves are leave the platform or ask an admin manually — both too heavy" — and its Out of scope explicitly defers "Admin moderation queue UI" because there's no admin to view it yet. + +This feature is that admin: one trusted account (Rei) that can view and correct any user's data — profile, posts, comments, reactions, DMs, playdate data — for support and moderation purposes, without needing raw database access for every fix. + +Scope decision from product: full edit access, including reading/editing private messages between other users. That is a real privacy line for a platform whose core data is about foster families and children, so this spec leans hard on RLS-level enforcement (not just application-code checks) and an append-only audit log of every admin action — see Acceptance criteria. + +## Acceptance criteria + +- [ ] A Postgres `is_admin()` SQL function (`SECURITY DEFINER`, matches the caller's `auth.uid()` against one hardcoded admin email) is the single source of truth for admin identity. No `role` column, no multi-admin support in v1 — this was an explicit product decision. +- [ ] Every RLS-enabled table (`families`, `announcements`, `comments`, `reactions`, `messages`, `availability_slots`, `playdate_requests`, `coach_events`) gets an additional `FOR ALL USING (is_admin()) WITH CHECK (is_admin())` policy, so the admin's own session token — not a service-role key — can read and write any row, including DMs in `messages` between two other users. +- [ ] A new `supabase/functions/admin/index.ts` Edge Function (same shape as the existing `message`/`family`/`playdates` functions: `supabaseForRequest`, `requireUserId`, segment-based routing per `supabase/functions/_shared/client.ts`) exposes admin CRUD across the tables above. Every route calls `rpc('is_admin')` and 403s before touching any data. +- [ ] Editing the identity-level fields Supabase itself owns (an arbitrary user's email, forcing a password reset, banning/deleting the account) goes through the Supabase Admin API (`supabase.auth.admin.*`), using a service-role client constructed only inside this function, only after the `is_admin()` check passes. The service-role key never reaches the frontend. +- [ ] Every admin mutation — table edits and `auth.admin.*` calls alike — writes one row to a new `admin_audit_log` table (`admin_user_id, action, target_type, target_id, before, after, created_at`) in the same request. No admin write path skips the audit log. +- [ ] `admin_audit_log` is itself RLS-protected: readable only via `is_admin()`, and no UPDATE/DELETE policy exists on it at all (append-only, matches this vault's own log convention). +- [ ] Frontend `/admin` route (React Query + `edgeRequest` against the new function — same convention as `messages.ts`/`playdates.ts`) with views for **Users** (families data + auth identity), **Content** (announcements/comments/reactions — edit/delete), and **Messages** (look up a conversation between two users, edit/delete individual messages, with an explicit "you are viewing a private conversation" banner given what this view grants). +- [ ] Non-admin sessions get a 403 from every `/admin/*` function route and cannot perform any admin action even if they reach the page directly by URL. The server-side `is_admin()` check is the actual security boundary; anything the frontend does to hide the nav link for non-admins is UX polish, not the gate. + +## Out of scope + +- Role column / multiple admins — single hardcoded admin only this pass (explicit product decision). If a second admin is ever needed, revisit `is_admin()` as a table-backed allowlist instead of an email literal. +- Admin "log in as" / impersonation of another user. +- A parallel Express `admin.controller.ts`. Express/Render is being decommissioned ([[features/migrate-render-to-vercel-supabase]]); this feature targets Supabase Edge Functions only, consistent with where every other live route has already moved. +- The moderation `reports` queue view — depends on [[features/moderation-report-block]]'s `reports` table, which doesn't exist yet (that feature is still `drafting`). Admin ships without a Reports tab; add one once that table lands. +- Rate limiting or anomaly alerting on admin actions (e.g. "admin touched 500 rows in a minute"). + +## Open questions + +- User deletion: hard delete (cascades via FK to a user's families/announcements/comments/messages/etc.) or soft delete/ban (`auth.admin.updateUserById(id, { ban_duration })`, or a `deleted_at` marker that keeps the data)? This changes the migration shape — needs a decision before backend-dev builds the DELETE routes. +- Should an admin-edited email address re-trigger Supabase's email verification flow, or is admin trusted to set a pre-verified address directly? +- Does `coach_events` need full admin edit access, or should it stay read-only in the admin UI (it's aggregate-only metrics, no draft/rewrite text, per [[features/reply-coach-live]])? Proposing read-only unless there's a concrete reason to edit it. + + + +## Engineering — Acceptance + +### Backend +*(filled by backend-dev)* + +### Frontend +*(filled by frontend-dev)* + +### Test plan +*(filled by qa-engineer)* + +### E2E coverage +*(filled by e2e-test-writer; "No E2E coverage" if the feature is backend-only)* + +### Code review +*(filled by code-reviewer; populated during building → review, not at speccing time)* + +## Design — Spec + +### Visual +*(filled by ui-designer)* + +### Microcopy +*(filled by ux-writer)* + +### Accessibility +*(filled by a11y-auditor)* + +## Marketing — Spec + +### Launch copy +*(filled by content-writer)* + +### SEO +*(filled by seo-specialist)* + +### Growth +*(filled by growth-analyst)* diff --git a/fofafu_vault/kanban/company.md b/fofafu_vault/kanban/company.md index 281ab58..b762ff9 100644 --- a/fofafu_vault/kanban/company.md +++ b/fofafu_vault/kanban/company.md @@ -5,6 +5,7 @@ team: company > Navigation: [[kanban/engineering]] · [[kanban/design]] · [[kanban/marketing]] · [[README]] · [[protocols/dispatch]] ## Backlog +- [ ] [[features/admin-access]] — single hardcoded admin with full edit access (incl. DMs) across all users' data via RLS bypass policies + audit log + admin UI - [ ] [[features/staging-environment]] — dedicated staging Supabase project + separate Vercel deployment, isolated from production - [ ] [[features/seed-prod-sample-data]] — production-safe sample family/post seeding so prod site doesn't look empty on first visit - [ ] [[features/reply-coach-live]] — Phase 2 follow-up to [[features/reply-coach]]: live Anthropic SDK + key plumbing + prompt caching + $5/day cost cap + 50/50 holdback experiment + `coach_events` aggregate table diff --git a/fofafu_vault/kanban/engineering.md b/fofafu_vault/kanban/engineering.md index 50362ab..5f6f6e0 100644 --- a/fofafu_vault/kanban/engineering.md +++ b/fofafu_vault/kanban/engineering.md @@ -5,6 +5,7 @@ team: engineering > Navigation: [[kanban/company]] · [[teams/engineering]] · [[standards/engineering-standards]] ## Backlog +- [ ] eng-backend-20 [[features/admin-access]] @engineering — single hardcoded admin (email-matched `is_admin()` SQL fn) with full read/edit access, incl. DMs, across all Supabase tables via RLS bypass policies + new `admin` Edge Function; every mutation audit-logged; admin UI in frontend - [ ] eng-infra-9 [[features/staging-environment]] @engineering — dedicated staging Supabase project + separate Vercel deployment so seeding/migration work never touches production data - [ ] eng-infra-1 [[features/migrate-render-to-vercel-supabase]] @engineering — Phase 5 parent ticket: migrate off Render entirely. Sub-tickets eng-infra-2..8 below track each workstream; this closes when all sub-tickets are Done and Render is decommissioned. - [ ] eng-infra-3 [[features/migrate-render-to-vercel-supabase]] @engineering — data migration script: dump sqlite rows, bulk-insert into Supabase Postgres, verify row counts + FK integrity — prod has no real user data yet (seed-prod-sample-data still open), so this is low-priority/may collapse to a re-seed once eng-backend-18 lands diff --git a/fofafu_vault/log/2026-08-20.md b/fofafu_vault/log/2026-08-20.md new file mode 100644 index 0000000..c2fee67 --- /dev/null +++ b/fofafu_vault/log/2026-08-20.md @@ -0,0 +1,2 @@ +- 13:05 #team/dispatch [[features/admin-access]] — feature scaffolded (owner=engineering, status=drafting) +- 13:05 #team/dispatch [[features/admin-access]] — Problem/Acceptance criteria/Out of scope/Open questions drafted with the user: single hardcoded admin (email-matched, no role column), full edit access incl. DMs, backend+UI both in scope. Design targets Supabase (is_admin() RLS bypass policies + new admin Edge Function + admin_audit_log) rather than the legacy Express/sqlite stack, since [[features/migrate-render-to-vercel-supabase]] is actively decommissioning Express — building a new Express admin controller now would be dead-end work. From 37b8766c249fdf535516d131d75b2968963284d7 Mon Sep 17 00:00:00 2001 From: Rei Kurata Date: Sat, 22 Aug 2026 20:31:45 -0700 Subject: [PATCH 2/5] vault: resolve admin-access identity decision is_admin() will match kurarei+8@gmail.com, per user decision. Not yet verified as a registered Supabase Auth user in the live project. Co-Authored-By: Claude Sonnet 5 --- fofafu_vault/features/admin-access.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fofafu_vault/features/admin-access.md b/fofafu_vault/features/admin-access.md index 00ff67e..1c31e9b 100644 --- a/fofafu_vault/features/admin-access.md +++ b/fofafu_vault/features/admin-access.md @@ -24,7 +24,7 @@ Scope decision from product: full edit access, including reading/editing private ## Acceptance criteria -- [ ] A Postgres `is_admin()` SQL function (`SECURITY DEFINER`, matches the caller's `auth.uid()` against one hardcoded admin email) is the single source of truth for admin identity. No `role` column, no multi-admin support in v1 — this was an explicit product decision. +- [ ] A Postgres `is_admin()` SQL function (`SECURITY DEFINER`, matches the caller's `auth.uid()` against the hardcoded admin email `kurarei+8@gmail.com`) is the single source of truth for admin identity. No `role` column, no multi-admin support in v1 — this was an explicit product decision. - [ ] Every RLS-enabled table (`families`, `announcements`, `comments`, `reactions`, `messages`, `availability_slots`, `playdate_requests`, `coach_events`) gets an additional `FOR ALL USING (is_admin()) WITH CHECK (is_admin())` policy, so the admin's own session token — not a service-role key — can read and write any row, including DMs in `messages` between two other users. - [ ] A new `supabase/functions/admin/index.ts` Edge Function (same shape as the existing `message`/`family`/`playdates` functions: `supabaseForRequest`, `requireUserId`, segment-based routing per `supabase/functions/_shared/client.ts`) exposes admin CRUD across the tables above. Every route calls `rpc('is_admin')` and 403s before touching any data. - [ ] Editing the identity-level fields Supabase itself owns (an arbitrary user's email, forcing a password reset, banning/deleting the account) goes through the Supabase Admin API (`supabase.auth.admin.*`), using a service-role client constructed only inside this function, only after the `is_admin()` check passes. The service-role key never reaches the frontend. @@ -43,6 +43,7 @@ Scope decision from product: full edit access, including reading/editing private ## Open questions +- ~~Which email should `is_admin()` match against?~~ **Resolved (2026-08-22):** `kurarei+8@gmail.com`. Not yet verified to exist as a registered Supabase Auth user in the live project — confirm (or sign it up) before the migration is written against a real user id, otherwise `is_admin()` matches zero rows and silently grants nobody access. - User deletion: hard delete (cascades via FK to a user's families/announcements/comments/messages/etc.) or soft delete/ban (`auth.admin.updateUserById(id, { ban_duration })`, or a `deleted_at` marker that keeps the data)? This changes the migration shape — needs a decision before backend-dev builds the DELETE routes. - Should an admin-edited email address re-trigger Supabase's email verification flow, or is admin trusted to set a pre-verified address directly? - Does `coach_events` need full admin edit access, or should it stay read-only in the admin UI (it's aggregate-only metrics, no draft/rewrite text, per [[features/reply-coach-live]])? Proposing read-only unless there's a concrete reason to edit it. From 0a0386fbb22709bb4d9d39cf003c2d81364c8452 Mon Sep 17 00:00:00 2001 From: Rei Kurata Date: Sun, 23 Aug 2026 07:53:05 -0700 Subject: [PATCH 3/5] vault: correct admin-access identity to kurarei+5@gmail.com Was kurarei+8@gmail.com; user had mixed up which test account was which. Co-Authored-By: Claude Sonnet 5 --- fofafu_vault/features/admin-access.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fofafu_vault/features/admin-access.md b/fofafu_vault/features/admin-access.md index 1c31e9b..107ace0 100644 --- a/fofafu_vault/features/admin-access.md +++ b/fofafu_vault/features/admin-access.md @@ -24,7 +24,7 @@ Scope decision from product: full edit access, including reading/editing private ## Acceptance criteria -- [ ] A Postgres `is_admin()` SQL function (`SECURITY DEFINER`, matches the caller's `auth.uid()` against the hardcoded admin email `kurarei+8@gmail.com`) is the single source of truth for admin identity. No `role` column, no multi-admin support in v1 — this was an explicit product decision. +- [ ] A Postgres `is_admin()` SQL function (`SECURITY DEFINER`, matches the caller's `auth.uid()` against the hardcoded admin email `kurarei+5@gmail.com`) is the single source of truth for admin identity. No `role` column, no multi-admin support in v1 — this was an explicit product decision. - [ ] Every RLS-enabled table (`families`, `announcements`, `comments`, `reactions`, `messages`, `availability_slots`, `playdate_requests`, `coach_events`) gets an additional `FOR ALL USING (is_admin()) WITH CHECK (is_admin())` policy, so the admin's own session token — not a service-role key — can read and write any row, including DMs in `messages` between two other users. - [ ] A new `supabase/functions/admin/index.ts` Edge Function (same shape as the existing `message`/`family`/`playdates` functions: `supabaseForRequest`, `requireUserId`, segment-based routing per `supabase/functions/_shared/client.ts`) exposes admin CRUD across the tables above. Every route calls `rpc('is_admin')` and 403s before touching any data. - [ ] Editing the identity-level fields Supabase itself owns (an arbitrary user's email, forcing a password reset, banning/deleting the account) goes through the Supabase Admin API (`supabase.auth.admin.*`), using a service-role client constructed only inside this function, only after the `is_admin()` check passes. The service-role key never reaches the frontend. @@ -43,7 +43,7 @@ Scope decision from product: full edit access, including reading/editing private ## Open questions -- ~~Which email should `is_admin()` match against?~~ **Resolved (2026-08-22):** `kurarei+8@gmail.com`. Not yet verified to exist as a registered Supabase Auth user in the live project — confirm (or sign it up) before the migration is written against a real user id, otherwise `is_admin()` matches zero rows and silently grants nobody access. +- ~~Which email should `is_admin()` match against?~~ **Resolved (2026-08-22), corrected (2026-08-23):** `kurarei+5@gmail.com` (user had mixed up which test account was which — previously recorded as `kurarei+8@gmail.com`). Not yet verified to exist as a registered Supabase Auth user in the live project — confirm (or sign it up) before the migration is written against a real user id, otherwise `is_admin()` matches zero rows and silently grants nobody access. - User deletion: hard delete (cascades via FK to a user's families/announcements/comments/messages/etc.) or soft delete/ban (`auth.admin.updateUserById(id, { ban_duration })`, or a `deleted_at` marker that keeps the data)? This changes the migration shape — needs a decision before backend-dev builds the DELETE routes. - Should an admin-edited email address re-trigger Supabase's email verification flow, or is admin trusted to set a pre-verified address directly? - Does `coach_events` need full admin edit access, or should it stay read-only in the admin UI (it's aggregate-only metrics, no draft/rewrite text, per [[features/reply-coach-live]])? Proposing read-only unless there's a concrete reason to edit it. From 3e2321c24afb9df010d0dd8d7157f995f5076d9d Mon Sep 17 00:00:00 2001 From: Rei Kurata Date: Mon, 24 Aug 2026 08:51:48 -0700 Subject: [PATCH 4/5] feat(admin-access): single hardcoded admin with full RLS-backed access + audit log is_admin() SECURITY DEFINER fn (kurarei+5@gmail.com) + FOR ALL RLS policies across families/announcements/comments/reactions/availability_slots/ messages/playdate_requests (coach_events read-only per spec's own default), admin_audit_log (append-only). messages/playdate_requests needed a BEFORE UPDATE trigger beyond the literal spec since their existing column-grants would otherwise silently block admin's own-session-token writes. New admin Edge Function: users/content/messages routes, single is_admin() gate, service-role client only where Supabase's Admin API is required (email/ban/reset-password), every mutation audit-logged. Frontend /admin (Users/Content/Messages) wired into App.tsx + RequireAuth, including the required "private conversation" banner on the Messages view. Backend 147/147, frontend 139/139, Deno 12/12, tsc clean both workspaces. No pgTAP harness exists in this repo for the RLS/trigger SQL (manual-review only) and no E2E coverage this pass -- both flagged in the feature spec rather than silently skipped. Co-Authored-By: Claude Sonnet 5 --- fofafu_vault/features/admin-access.md | 21 +- fofafu_vault/kanban/company.md | 2 +- fofafu_vault/kanban/engineering.md | 2 +- fofafu_vault/log/2026-08-23.md | 2 + frontend/src/App.tsx | 9 + frontend/src/api/admin.ts | 141 ++++++ frontend/src/components/Navbar.tsx | 13 +- frontend/src/components/icons.tsx | 9 + frontend/src/hooks/useIsAdmin.ts | 23 + .../src/pages/AdminPage/AdminPage.test.tsx | 155 +++++++ frontend/src/pages/AdminPage/AdminPage.tsx | 68 +++ frontend/src/pages/AdminPage/ContentView.tsx | 118 +++++ frontend/src/pages/AdminPage/MessagesView.tsx | 134 ++++++ frontend/src/pages/AdminPage/UsersView.tsx | 58 +++ supabase/functions/admin/index.test.ts | 282 ++++++++++++ supabase/functions/admin/index.ts | 417 ++++++++++++++++++ supabase/functions/deno.json | 3 + supabase/functions/deno.lock | 77 ++++ .../20260823000000_admin_access.sql | 189 ++++++++ 19 files changed, 1712 insertions(+), 11 deletions(-) create mode 100644 fofafu_vault/log/2026-08-23.md create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/hooks/useIsAdmin.ts create mode 100644 frontend/src/pages/AdminPage/AdminPage.test.tsx create mode 100644 frontend/src/pages/AdminPage/AdminPage.tsx create mode 100644 frontend/src/pages/AdminPage/ContentView.tsx create mode 100644 frontend/src/pages/AdminPage/MessagesView.tsx create mode 100644 frontend/src/pages/AdminPage/UsersView.tsx create mode 100644 supabase/functions/admin/index.test.ts create mode 100644 supabase/functions/admin/index.ts create mode 100644 supabase/functions/deno.json create mode 100644 supabase/functions/deno.lock create mode 100644 supabase/migrations/20260823000000_admin_access.sql diff --git a/fofafu_vault/features/admin-access.md b/fofafu_vault/features/admin-access.md index 107ace0..7c5e926 100644 --- a/fofafu_vault/features/admin-access.md +++ b/fofafu_vault/features/admin-access.md @@ -3,7 +3,7 @@ slug: admin-access title: Admin Access owner: engineering collaborators: [] -status: drafting +status: review priority: P2 created: 2026-08-20 target: null @@ -44,8 +44,8 @@ Scope decision from product: full edit access, including reading/editing private ## Open questions - ~~Which email should `is_admin()` match against?~~ **Resolved (2026-08-22), corrected (2026-08-23):** `kurarei+5@gmail.com` (user had mixed up which test account was which — previously recorded as `kurarei+8@gmail.com`). Not yet verified to exist as a registered Supabase Auth user in the live project — confirm (or sign it up) before the migration is written against a real user id, otherwise `is_admin()` matches zero rows and silently grants nobody access. -- User deletion: hard delete (cascades via FK to a user's families/announcements/comments/messages/etc.) or soft delete/ban (`auth.admin.updateUserById(id, { ban_duration })`, or a `deleted_at` marker that keeps the data)? This changes the migration shape — needs a decision before backend-dev builds the DELETE routes. -- Should an admin-edited email address re-trigger Supabase's email verification flow, or is admin trusted to set a pre-verified address directly? +- ~~User deletion: hard delete (cascades via FK to a user's families/announcements/comments/messages/etc.) or soft delete/ban (`auth.admin.updateUserById(id, { ban_duration })`, or a `deleted_at` marker that keeps the data)? This changes the migration shape — needs a decision before backend-dev builds the DELETE routes.~~ **Resolved (2026-08-23):** Soft delete/ban via `auth.admin.updateUserById(id, { ban_duration })`. No hard DELETE route on `/admin/users/:id` in v1 — safer default for a platform with a legal/compliance surface (foster families, minors); reversible if an admin bans the wrong account. +- Should an admin-edited email address re-trigger Supabase's email verification flow, or is admin trusted to set a pre-verified address directly? **Assumption (undecided by product, not blocking):** admin is trusted; edits go through `auth.admin.updateUserById` with `email_confirm: true`, no re-verification email sent. Flagging as an assumption rather than a silent default — revisit if that's wrong. - Does `coach_events` need full admin edit access, or should it stay read-only in the admin UI (it's aggregate-only metrics, no draft/rewrite text, per [[features/reply-coach-live]])? Proposing read-only unless there's a concrete reason to edit it. @@ -53,16 +53,23 @@ Scope decision from product: full edit access, including reading/editing private ## Engineering — Acceptance ### Backend -*(filled by backend-dev)* +`supabase/migrations/20260823000000_admin_access.sql`: `is_admin()` SECURITY DEFINER function (auth.users email lookup, not the JWT claim — avoids a stale-claim gap); `FOR ALL USING(is_admin()) WITH CHECK(is_admin())` on families/announcements/comments/reactions/availability_slots/messages/playdate_requests; `coach_events` gets admin SELECT-only per this doc's own Open Questions default. `messages`/`playdate_requests` needed an extra fix beyond the literal ACs: both already had a column-level `REVOKE UPDATE ... GRANT UPDATE()` from `20260711010000_auth_trigger_and_rls.sql`, which is role-wide and would have silently blocked the admin's own-session-token writes to any other column even with the new RLS policy in place. Fixed with a `BEFORE UPDATE` trigger per table that enforces the original non-admin column restriction for everyone except `is_admin()`. While auditing `playdate_requests`, found the existing non-admin `respondToRequest` path already writes `updated_at` alongside `status` — one column beyond what was ever granted, a latent pre-existing bug unrelated to this feature; not fixed as its own change, but incidentally resolved by the column grant this feature already needed to widen. `admin_audit_log` table: RLS readable only via `is_admin()`, no UPDATE/DELETE policy at all (append-only). + +`supabase/functions/admin/index.ts`: same shape as message/family/playdates (`supabaseForRequest`, segment routing). Single `is_admin()` RPC gate before any routing — 401 unauthenticated, 403 non-admin/RPC-error. Routes: `users` (list/get/patch incl. optional email via Admin API/ban-unban/force-password-reset), `content/:table` (announcements/comments/reactions — list/edit(not reactions)/delete), `messages/:userA/:userB` (conversation)+`messages/:id` (edit/delete). Service-role client constructed only inside handlers that need Supabase's Admin API (email/ban/reset-password), only after the gate passes, via an injectable factory (`getServiceRoleClient`) defaulting to the real one — added purely so the highest-risk paths could be unit tested at all. Every mutation writes one `admin_audit_log` row in the same request; documented in code why this isn't a single DB transaction (two separate PostgREST calls) and why an audit-log failure surfaces as a loud 500 rather than a silent success. + +Testing: no pgTAP/local-Postgres harness exists in this repo yet (no `supabase/tests/`, no committed `config.toml`) — attempted to stand one up for this feature (local stack on remapped ports, `supabase start`), but a fresh-database migration replay fails deterministically on `20260711010000_auth_trigger_and_rls.sql` (duplicate-policy error) regardless of pgdelta/volume state — a pre-existing gap unrelated to this feature (fails before reaching this migration at all). Flagging for a future infra ticket rather than fixing here. In its place: added `supabase/functions/deno.json` (scoped `nodeModulesDir`, doesn't touch the npm workspace) and `supabase/functions/admin/index.test.ts` — 12 Deno unit tests against a fake Supabase client + injectable fake service-role client, covering the auth/admin gate, every route's happy path, 400/404s, and the audit-log-failure-surfaces-500 behavior. `deno check` clean. The RLS policies and column-grant triggers themselves are reviewed manually only (see migration comments) — verify against a real/staging Supabase project before this ships. ### Frontend -*(filled by frontend-dev)* +`frontend/src/api/admin.ts` (Zod-validated wrappers for all 11 admin routes, mirrors `messages.ts`) + `hooks/useIsAdmin.ts` (`supabase.rpc('is_admin')` via React Query, UX-only — fails closed to `false` on any RPC error) + `pages/AdminPage/{AdminPage,UsersView,ContentView,MessagesView}.tsx` (tabbed: Users — list/ban/unban/force-password-reset; Content — announcements/comments editable, reactions delete-only; Messages — user-id-pair lookup with an explicit `role="alert"` "You are viewing a private conversation" banner, edit/delete per message). `/admin` wired into `App.tsx` inside `RequireAuth`; nav link in `Navbar.tsx` shown only when `useIsAdmin()` is true, and the page itself ``s a non-admin away — both UX polish, not the gate (that's server-side `is_admin()` on every route + RLS). Non-admin/unauthenticated behavior is covered by the Edge Function's own 401/403 gate tests, not re-derived client-side. ### Test plan -*(filled by qa-engineer)* +Backend: 147/147 (`npm run test:backend`, full suite incl. pre-existing features — unaffected). New: 12 Deno unit tests (`supabase/functions/admin/index.test.ts`) against a fake Supabase client + injectable fake service-role client — auth/admin gate (401/403, including an RPC-error case), every route's happy path, a 404-row-missing case, and the audit-log-insert-failure-surfaces-500 case (a mutation must never silently succeed with no trail). `deno check` clean. +Frontend: 139/139 (`npm run test:frontend`, 32/32 files, full suite incl. pre-existing pages — unaffected), `AdminPage.test.tsx` covering non-admin sees no admin content, the Users ban flow, Content edit+delete, and the Messages banner+edit+delete flow. `tsc --noEmit` clean both workspaces. +Note: while running the full suite, found backend was 100% failing on `better-sqlite3`'s native binding — caused by an earlier unscoped `deno check --node-modules-dir=auto` (run once, before `supabase/functions/deno.json` existed to scope it) restructuring the *root* `node_modules` into Deno's own npm-compat layout, which never runs npm's install/build scripts. Fixed with `rm -rf node_modules && npm ci` at the repo root; confirmed clean before reporting complete. Flagging in case any other worktree/session hit the same thing from a stray unscoped `deno check`/`deno test` at the repo root. +RLS policies and the column-grant triggers in the migration are **not** exercised by any of the above (no pgTAP/local-Postgres harness exists in this repo — see Backend section) — manual-review-only; verify against a real/staging Supabase project before this ships. ### E2E coverage -*(filled by e2e-test-writer; "No E2E coverage" if the feature is backend-only)* +None added this pass. This feature is not backend-only, so per this file's own template a Playwright E2E would normally be expected — deferred rather than skipped silently: a real E2E needs either a seeded admin test account against a real/staging Supabase project or a fully-mocked Playwright run, and this pass already ran long. RTL+MSW page-level tests (see Test plan) cover the UI flows short of a real browser hitting a real backend. ### Code review *(filled by code-reviewer; populated during building → review, not at speccing time)* diff --git a/fofafu_vault/kanban/company.md b/fofafu_vault/kanban/company.md index b762ff9..9daa02d 100644 --- a/fofafu_vault/kanban/company.md +++ b/fofafu_vault/kanban/company.md @@ -5,7 +5,6 @@ team: company > Navigation: [[kanban/engineering]] · [[kanban/design]] · [[kanban/marketing]] · [[README]] · [[protocols/dispatch]] ## Backlog -- [ ] [[features/admin-access]] — single hardcoded admin with full edit access (incl. DMs) across all users' data via RLS bypass policies + audit log + admin UI - [ ] [[features/staging-environment]] — dedicated staging Supabase project + separate Vercel deployment, isolated from production - [ ] [[features/seed-prod-sample-data]] — production-safe sample family/post seeding so prod site doesn't look empty on first visit - [ ] [[features/reply-coach-live]] — Phase 2 follow-up to [[features/reply-coach]]: live Anthropic SDK + key plumbing + prompt caching + $5/day cost cap + 50/50 holdback experiment + `coach_events` aggregate table @@ -18,6 +17,7 @@ team: company - [ ] [[features/migrate-render-to-vercel-supabase]] — eng-infra-4 (frontend supabase-js auth swap) + eng-infra-5 (frontend Edge Function wiring for announcements/family/community/search) both closed to Review; frontend 132/132 tests green, tsc/build clean. Same-day correction: an earlier attempt to delete the old Express auth endpoints was a production regression (would have broken messages/playdates/uploads/coach auth entirely), caught before merge — reverted, and auth.middleware.ts now accepts a Supabase session token as a fallback alongside the legacy JWT. Backend 147/147 tests pass, tsc clean. Phase 5 parent (eng-infra-1) remains building — eng-infra-3/6/7/8 still outstanding ## Review +- [ ] [[features/admin-access]] — single hardcoded admin (`kurarei+5@gmail.com`) via `is_admin()` + RLS across all Supabase tables (DMs included) + `admin_audit_log` + new `admin` Edge Function + `/admin` frontend (Users/Content/Messages, incl. required private-conversation banner). Backend 147/147, frontend 139/139, Deno 12/12, tsc clean. Found and flagged (not fixed): this repo's migrations don't currently replay clean from scratch on the current CLI (`supabase start`) — unrelated pre-existing gap. No pgTAP harness exists for the RLS/trigger SQL (manual-review-only) and no E2E coverage this pass — both flagged in the feature spec, not silent gaps. - [ ] [[features/supabase-rls-sensitive-columns]] — P0 security: RLS `USING (true)` policies on families/announcements/comments/reactions/availability_slots lacked `TO` clause (implicitly anon-readable); rescoped to `TO authenticated` + `REVOKE SELECT FROM anon`, defense-in-depth REVOKE on messages/playdate_requests/coach_events; migration `20260714000000_restrict_pii_to_authenticated.sql`; no password/secret columns exist in public schema (auth.users not PostgREST-exposed). 2 ACs (exact flagged table ID, live Advisor re-run) need human follow-up — no live Supabase dashboard/CLI access in sandbox; flagged in Open Questions. - [ ] [[features/brand-contrast-fix]] — WCAG 1.4.3 fix: `color.brand.primary.pressed` (#3F7E54, 4.86:1 vs white, independently verified by ui-designer + a11y-auditor) introduced; 22 CTA sites (19 files) migrated to accessible pair + hover parity; frontend 119/119, tsc/build clean, 0 must-fix code review, 11/11 pages 0 axe violations; both team kanban cards in Review - [ ] [[features/reply-coach-live]] — LiveClaudeClient wraps @anthropic-ai/sdk behind existing ClaudeClient seam; prompt caching (cache_control), ANTHROPIC_API_KEY boot-refusal, reply_coach_live_enabled flag, $5/day cost cap, 50/50 holdback by user_id hash, coach_events aggregate table (no draft/rewrite text); backend 141/141 (13/13 coach-live.test.ts), tsc clean; 2 non-blocking code-review must-fix items carried forward (unchecked Zod validation on live response, cache-hit rate not yet logged); design Microcopy 10/10 static voice-rule audit pass (Fixture B/C dogfood tone-fidelity deferred, no real API key this pass); marketing Growth/SEO/Launch-copy specs complete, page build deferred to post-holdback; no real ANTHROPIC_API_KEY used anywhere diff --git a/fofafu_vault/kanban/engineering.md b/fofafu_vault/kanban/engineering.md index 5f6f6e0..dc64dcf 100644 --- a/fofafu_vault/kanban/engineering.md +++ b/fofafu_vault/kanban/engineering.md @@ -5,7 +5,6 @@ team: engineering > Navigation: [[kanban/company]] · [[teams/engineering]] · [[standards/engineering-standards]] ## Backlog -- [ ] eng-backend-20 [[features/admin-access]] @engineering — single hardcoded admin (email-matched `is_admin()` SQL fn) with full read/edit access, incl. DMs, across all Supabase tables via RLS bypass policies + new `admin` Edge Function; every mutation audit-logged; admin UI in frontend - [ ] eng-infra-9 [[features/staging-environment]] @engineering — dedicated staging Supabase project + separate Vercel deployment so seeding/migration work never touches production data - [ ] eng-infra-1 [[features/migrate-render-to-vercel-supabase]] @engineering — Phase 5 parent ticket: migrate off Render entirely. Sub-tickets eng-infra-2..8 below track each workstream; this closes when all sub-tickets are Done and Render is decommissioned. - [ ] eng-infra-3 [[features/migrate-render-to-vercel-supabase]] @engineering — data migration script: dump sqlite rows, bulk-insert into Supabase Postgres, verify row counts + FK integrity — prod has no real user data yet (seed-prod-sample-data still open), so this is low-priority/may collapse to a re-seed once eng-backend-18 lands @@ -19,6 +18,7 @@ team: engineering ## In Progress ## Review +- [ ] eng-backend-20 [[features/admin-access]] @engineering — single hardcoded admin (`kurarei+5@gmail.com`, confirmed registered) via SECURITY DEFINER `is_admin()` + `FOR ALL` RLS policies across families/announcements/comments/reactions/availability_slots/messages/playdate_requests (`coach_events` read-only), `admin_audit_log` (append-only), new `admin` Edge Function (users/content/messages routes, service-role Admin API for email/ban/reset-password, audit-logged every mutation); messages/playdate_requests needed a BEFORE UPDATE trigger fix beyond the literal spec since their existing column-grants would've silently blocked admin's own-session writes — see feature spec Backend section. Frontend `/admin` (Users/Content/Messages views incl. required "private conversation" banner) wired into App.tsx + RequireAuth. Backend 147/147, frontend 139/139, Deno 12/12, tsc clean both workspaces — full monorepo suite re-run clean after fixing a real node_modules regression this work introduced (see Test plan section for detail). No E2E (Playwright) coverage added this pass — flagged, not silent. RLS/trigger SQL is manual-review-only (no pgTAP harness in this repo); verify against a real/staging project before shipping. - [ ] eng-frontend-19 [[features/community-playdate-badge]] @engineering — Home dashboard's Community sidebar now shows City, State under each family name and a "🗓 Playdate" badge (links straight to the request flow via `/family/:id?requestSlot=`) when a family has a future free slot; `supabase/functions/community/index.ts` extended to return `city`/`state`/`nextFreeSlotId` per row; family name truncates past 24 characters only; frontend 134/134, tsc clean; no backend Express changes (legacy/superseded controller left untouched) - [ ] eng-infra-9 [[features/supabase-rls-sensitive-columns]] @engineering — P0 security fix for Supabase Advisor's `sensitive_columns_exposed`: static audit of all 6 pre-existing migrations found no literal password/token columns (Supabase Auth owns credentials, not PostgREST-exposed), but 5 tables' `USING (true)` SELECT policies had no `TO` clause, exposing `families.kid_count/city/state` (foster-family PII) to the unauthenticated `anon` role; new migration `20260714000000_restrict_pii_to_authenticated.sql` scopes those policies to `TO authenticated` + `REVOKE SELECT FROM anon` on all 8 public tables (defense-in-depth); qa-engineer's introspection queries ready for a human to run against live project `rlizubjugevyxsfzmpny`; tech-lead reconciled a stale "FAIL" framing in QA's Test plan (written before the migration landed) against the migration file — no real backend/QA disagreement, just parallel-execution timing. Two ACs (identify exact flagged table by name; re-run live Advisor to confirm clear) remain open pending human dashboard/CLI access — documented, not buried, in Open questions and the reconciliation note. - [ ] eng-infra-6 [[features/migrate-render-to-vercel-supabase]] @engineering — Edge Functions port, batch 2 done: `supabase/functions/{message,playdates,coach}/index.ts` mirror the batch-1 pattern (RLS-scoped client via `_shared/client.ts`); app-level rules RLS can't express (busy-slot hiding, no self-requests, no duplicate pending requests, coach rate-limit/cost-cap/holdback) replicated in the functions. `frontend/src/api/messages.ts`/`playdates.ts` repointed at `edgeRequest`; `coach` has no frontend consumer yet so only the function was ported. Deployed + live-verified against `rlizubjugevyxsfzmpny`. A PostgREST filter-injection bug in `message/index.ts`'s `GET /threads/:userId` (caught by automated commit review — unvalidated `partnerId` interpolated into `.or()`) was found and fixed same-day, redeployed. Backend 147/147 (unmodified), frontend 132/132, tsc/build clean. PR #50. diff --git a/fofafu_vault/log/2026-08-23.md b/fofafu_vault/log/2026-08-23.md new file mode 100644 index 0000000..0438b86 --- /dev/null +++ b/fofafu_vault/log/2026-08-23.md @@ -0,0 +1,2 @@ +- 18:43 #team/eng/backend [[features/admin-access]] — implemented directly by the user's request rather than via full `/dispatch` fan-out (this session is running from the sibling `runfun` project, where this repo's own dispatcher/backend-dev/etc. agent roster isn't loaded — flagged to the user, who chose direct implementation). Resolved both Open Questions with the user first: user deletion is soft delete/ban (not hard delete), admin email `kurarei+5@gmail.com` confirmed already registered. `supabase/migrations/20260823000000_admin_access.sql`: `is_admin()` SECURITY DEFINER fn, `FOR ALL` RLS admin policies across families/announcements/comments/reactions/availability_slots/messages/playdate_requests (`coach_events` read-only per this doc's own proposed default), `admin_audit_log` (append-only). Found and fixed a real gap beyond the literal ACs: `messages`/`playdate_requests`' existing column-level grants (from `20260711010000_auth_trigger_and_rls.sql`) would have silently blocked admin's own-session-token writes to any column but the one each was narrowed to, even with the new RLS policy — added a `BEFORE UPDATE` trigger per table exempting `is_admin()`. Also found (not fixed, out of scope): `playdate_requests`' existing non-admin update path writes a column it was never granted, a latent pre-existing bug the same grant-widening incidentally resolves. `supabase/functions/admin/index.ts`: users/content/messages routes, single `is_admin()` RPC gate, service-role client (injectable factory) only inside handlers needing Supabase's Admin API, every mutation audit-logged. No pgTAP/local-Postgres harness exists in this repo yet; attempted to add one for this feature (local stack on remapped ports) but a fresh-DB migration replay fails deterministically on the pre-existing `auth_trigger_and_rls` migration regardless of pgdelta/volume state — unrelated to this feature, worth its own infra ticket, not chased further. Added `supabase/functions/deno.json` + `admin/index.test.ts` instead: 12 Deno unit tests (fake Supabase + fake service-role client), `deno check` clean. RLS/trigger SQL itself is manually reviewed only — verify against a real/staging project before this ships. Frontend `/admin` UI in progress. +- 19:11 #team/eng/frontend [[features/admin-access]] — `frontend/src/api/admin.ts` + `hooks/useIsAdmin.ts` + `pages/AdminPage/{AdminPage,UsersView,ContentView,MessagesView}` (tabbed Users/Content/Messages, incl. the required "you are viewing a private conversation" `role="alert"` banner) + `App.tsx`/`Navbar.tsx` wiring, all UX-only gating (server-side `is_admin()` is the real one). `AdminPage.test.tsx` MSW-mocked at the network boundary; this pass's own a11y guard test caught 4 real violations (wrong Tailwind token pair) in the new code, fixed before completion. Independently re-verified everything myself rather than trusting the build pass's self-report: `tsc --noEmit` clean both workspaces; full monorepo suite backend 147/147 + frontend 139/139 + Deno 12/12. Along the way found and fixed a real regression this session itself introduced: an earlier unscoped `deno check --node-modules-dir=auto` (before `supabase/functions/deno.json` existed to scope it) had restructured the repo-root `node_modules` into Deno's own npm-compat layout, which skips npm install/build scripts — silently broke `better-sqlite3`'s native binding and 100% of backend tests. Fixed with `rm -rf node_modules && npm ci`; confirmed green after. `requested_status: review` — engineering-only classification (frontmatter collaborators: []), no design/marketing track needed for an internal admin tool; no formal tech-lead spawned this pass (implemented directly per the user, from a sibling-project session without this repo's own agent roster loaded — see the 18:43 entry), so this review-audit was done by the same session that built it rather than a separate lead. E2E (Playwright) coverage intentionally not added this pass (flagged in the feature spec Test plan / E2E coverage sections, not silent). RLS/trigger SQL remains manual-review-only (no pgTAP harness in this repo) — verify against a real/staging Supabase project before shipping. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d0d939..e97f05a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import MessagesPage from '@/pages/Messages'; import MessageThreadPage from '@/pages/MessageThread'; import SearchPage from '@/pages/Search'; import PlaydatesPage from '@/pages/PlaydatesPage/PlaydatesPage'; +import AdminPage from '@/pages/AdminPage/AdminPage'; import { RequireAuth } from '@/components/RequireAuth'; export function App() { @@ -91,6 +92,14 @@ export function App() { } /> + + + + } + /> } /> ); diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..74c51b1 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; +import { edgeRequest } from './edgeClient'; + +const FN = 'admin'; + +export const AdminUserDTO = z.object({ + id: z.string(), + familyId: z.string(), + name: z.string(), + bio: z.string(), + kidCount: z.number().nullable(), + avatarUrl: z.string().nullable(), + createdAt: z.string(), + email: z.string().nullable(), + banned: z.boolean(), +}); +export type AdminUserDTO = z.infer; + +export const AdminUserDetailDTO = AdminUserDTO.extend({ updatedAt: z.string() }); +export type AdminUserDetailDTO = z.infer; + +export const AdminAnnouncementDTO = z.object({ + id: z.string(), + user_id: z.string(), + content: z.string(), + media_url: z.string().nullable(), + media_type: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); +export type AdminAnnouncementDTO = z.infer; + +export const AdminCommentDTO = z.object({ + id: z.string(), + announcement_id: z.string(), + user_id: z.string(), + content: z.string(), + created_at: z.string(), + updated_at: z.string(), +}); +export type AdminCommentDTO = z.infer; + +export const AdminReactionDTO = z.object({ + id: z.string(), + announcement_id: z.string(), + user_id: z.string(), + type: z.string(), + created_at: z.string(), +}); +export type AdminReactionDTO = z.infer; + +export const AdminMessageDTO = z.object({ + id: z.string(), + sender_id: z.string(), + receiver_id: z.string(), + content: z.string(), + read: z.boolean(), + created_at: z.string(), +}); +export type AdminMessageDTO = z.infer; + +export type ContentTable = 'announcements' | 'comments' | 'reactions'; + +const CONTENT_SCHEMA = { + announcements: AdminAnnouncementDTO, + comments: AdminCommentDTO, + reactions: AdminReactionDTO, +} as const; + +export async function listUsers(): Promise { + const data = await edgeRequest(FN, '/users'); + return z.array(AdminUserDTO).parse(data); +} + +export async function getUser(id: string): Promise { + const data = await edgeRequest(FN, `/users/${id}`); + return AdminUserDetailDTO.parse(data); +} + +export interface UpdateUserInput { + name?: string; + bio?: string; + kidCount?: number | null; + avatarUrl?: string | null; + email?: string; +} + +export async function updateUser(id: string, input: UpdateUserInput): Promise { + const data = await edgeRequest(FN, `/users/${id}`, { method: 'PATCH', body: input }); + return AdminUserDetailDTO.parse(data); +} + +export async function setUserBan(id: string, input: { hours?: number; unban?: boolean }): Promise<{ id: string; banned: boolean }> { + return edgeRequest<{ id: string; banned: boolean }>(FN, `/users/${id}/ban`, { method: 'POST', body: input }); +} + +export async function forcePasswordReset(id: string): Promise<{ id: string; sent: boolean }> { + return edgeRequest<{ id: string; sent: boolean }>(FN, `/users/${id}/reset-password`, { method: 'POST' }); +} + +export async function listContent( + table: T, +): Promise>> { + const data = await edgeRequest(FN, `/content/${table}`); + return z.array(CONTENT_SCHEMA[table]).parse(data) as Array>; +} + +export async function updateContent( + table: 'announcements' | 'comments', + id: string, + content: string, +): Promise { + const data = await edgeRequest(FN, `/content/${table}/${id}`, { method: 'PATCH', body: { content } }); + return CONTENT_SCHEMA[table].parse(data); +} + +export async function deleteContent(table: ContentTable, id: string): Promise<{ deleted: true }> { + return edgeRequest<{ deleted: true }>(FN, `/content/${table}/${id}`, { method: 'DELETE' }); +} + +export async function getConversation(userIdA: string, userIdB: string): Promise { + const data = await edgeRequest(FN, `/messages/${userIdA}/${userIdB}`); + return z.array(AdminMessageDTO).parse(data); +} + +export async function updateMessage(id: string, content: string): Promise { + const data = await edgeRequest(FN, `/messages/${id}`, { method: 'PATCH', body: { content } }); + return AdminMessageDTO.parse(data); +} + +export async function deleteMessage(id: string): Promise<{ deleted: true }> { + return edgeRequest<{ deleted: true }>(FN, `/messages/${id}`, { method: 'DELETE' }); +} + +export const adminKeys = { + users: ['admin', 'users'] as const, + user: (id: string) => ['admin', 'users', id] as const, + content: (table: ContentTable) => ['admin', 'content', table] as const, + conversation: (userIdA: string, userIdB: string) => + ['admin', 'messages', [userIdA, userIdB].sort().join(':')] as const, +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index d5c4d57..34429ad 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -2,6 +2,7 @@ import type { SVGProps } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '@/stores/auth'; +import { useIsAdmin } from '@/hooks/useIsAdmin'; import { unreadCount, messageKeys } from '@/api/messages'; import { BrandMark, @@ -11,6 +12,7 @@ import { HomeIcon, LogOutIcon, MessageIcon, + ShieldIcon, } from '@/components/icons'; interface NavLink { @@ -38,6 +40,7 @@ export function Navbar() { const clear = useAuthStore((s) => s.clear); const navigate = useNavigate(); const location = useLocation(); + const { isAdmin } = useIsAdmin(); const { data: unread } = useQuery({ queryKey: messageKeys.unread, @@ -47,6 +50,12 @@ export function Navbar() { }); const unreadN = unread?.count ?? 0; + // Nav-link visibility is UX only — the real admin gate is server-side + // (is_admin() enforced by RLS + every /admin/* function route). + const links = isAdmin + ? [...NAV_LINKS, { to: '/admin', label: 'Admin', Icon: ShieldIcon, match: (p: string) => p.startsWith('/admin') }] + : NAV_LINKS; + const handleSignOut = () => { clear(); navigate('/login'); @@ -79,7 +88,7 @@ export function Navbar() {
- {NAV_LINKS.map((link) => { + {links.map((link) => { const active = link.match(location.pathname); const badge = link.to === '/messages' ? unreadN : 0; const Icon = link.Icon; @@ -124,7 +133,7 @@ export function Navbar() { aria-label="Mobile navigation" className="fixed inset-x-0 bottom-0 z-40 flex border-t border-ink-muted/20 bg-surface-card shadow-[0_-2px_12px_rgba(0,0,0,.08)] md:hidden" > - {NAV_LINKS.map((link) => { + {links.map((link) => { const active = link.match(location.pathname); const badge = link.to === '/messages' ? unreadN : 0; const Icon = link.Icon; diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 98cb34e..2f86809 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -199,6 +199,15 @@ export function CalendarIcon(props: IconProps) { ); } +export function ShieldIcon(props: IconProps) { + return ( + + + + + ); +} + export function BrandMark(props: SVGProps) { return (

Loading…

+ + ); + } + + // UX-only redirect — the actual gate is the server-side is_admin() check + // on every /admin/* function route and RLS policy. This just keeps a + // non-admin from staring at an empty/erroring page. + if (!isAdmin) { + return ; + } + + return ( + +

Admin

+

+ Full read/write access to every user's data, including private messages. Every action here is logged. +

+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === 'users' && } + {tab === 'content' && } + {tab === 'messages' && } +
+
+ ); +} diff --git a/frontend/src/pages/AdminPage/ContentView.tsx b/frontend/src/pages/AdminPage/ContentView.tsx new file mode 100644 index 0000000..bf8bc9c --- /dev/null +++ b/frontend/src/pages/AdminPage/ContentView.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { listContent, updateContent, deleteContent, adminKeys, type ContentTable } from '@/api/admin'; +import { cn } from '@/utils/cn'; + +const TABLES: { id: ContentTable; label: string; editable: boolean }[] = [ + { id: 'announcements', label: 'Announcements', editable: true }, + { id: 'comments', label: 'Comments', editable: true }, + { id: 'reactions', label: 'Reactions', editable: false }, +]; + +export function ContentView() { + const [table, setTable] = useState('announcements'); + const editable = TABLES.find((t) => t.id === table)?.editable ?? false; + const queryClient = useQueryClient(); + + const { data, isPending, isError } = useQuery({ + queryKey: adminKeys.content(table), + queryFn: () => listContent(table), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, content }: { id: string; content: string }) => + updateContent(table as 'announcements' | 'comments', id, content), + onSuccess: () => queryClient.invalidateQueries({ queryKey: adminKeys.content(table) }), + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteContent(table, id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: adminKeys.content(table) }), + }); + + return ( +
+
+ {TABLES.map((t) => ( + + ))} +
+ + {isPending &&

Loading…

} + {isError &&

Could not load {table}.

} + +
    + {data?.map((row) => ( +
  • + {'content' in row ? ( + editable ? ( + updateMutation.mutate({ id: row.id, content })} + /> + ) : ( +

    {row.content}

    + ) + ) : ( +

    {row.type}

    + )} + +
  • + ))} +
+
+ ); +} + +function EditableContent({ content, onSave }: { content: string; onSave: (content: string) => void }) { + const [value, setValue] = useState(content); + const [editing, setEditing] = useState(false); + + if (!editing) { + return ( +
+

{content}

+ +
+ ); + } + + return ( +
+