From 3c7bb59421720613d4221e9c900b3258327400b0 Mon Sep 17 00:00:00 2001
From: belviskhoremk
Date: Wed, 12 Aug 2026 10:38:20 +0200
Subject: [PATCH] docs: add Phase 2B admin panel implementation plan
---
.../plans/2026-08-12-phase2b-admin-panel.md | 2712 +++++++++++++++++
1 file changed, 2712 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md
diff --git a/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md b/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md
new file mode 100644
index 0000000..1022ab9
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-phase2b-admin-panel.md
@@ -0,0 +1,2712 @@
+# Phase 2B: Admin Panel Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give Mira a working admin panel — login, CRUD for products/services/schedule (with image upload), and the ability to view and act on bookings/inquiries.
+
+**Architecture:** Lazy-loaded `/admin/*` route subtree in the existing React app, session-gated by a `ProtectedRoute` wrapper backed by `supabase.auth`. Every page talks to Postgres directly via `supabase-js` (no new data-fetching library), matching the codebase's existing pattern (see `src/hooks/useBooking.ts`). RLS from Phase 2A already gates every write to the fixed admin UUID — no separate authorization layer needed client-side.
+
+**Tech Stack:** React 19, TypeScript, react-router-dom v7, `@supabase/supabase-js`, Vitest + React Testing Library, framer-motion (for consistency with the public site's transition feel), Postgres/Supabase (migrations + pgTAP).
+
+## Global Constraints
+
+- Admin UUID (from Phase 2A, hardcoded in every admin-gated RLS policy): `21c90224-e0b2-412e-a431-8e7f097c1cef`
+- No new frontend dependencies — use `supabase-js`, React state, and what's already in `package.json`
+- All admin pages are default exports; CSS via CSS Modules (`ComponentName.module.css`), matching every existing page in `src/pages/`
+- New migrations must be added as new files — never edit an already-applied migration (`supabase/migrations/2026080*` through `2026081*`)
+- Design spec: `docs/superpowers/specs/2026-08-12-phase2b-admin-panel-design.md`
+
+---
+
+### Task 1: Storage bucket policies for `salon-images`
+
+**Files:**
+- Create: `supabase/migrations/_salon_images_storage_policies.sql` — generate `` by running `date -u +%Y%m%d%H%M%S` at the start of this task and using its output as the filename prefix (this avoids colliding with any other in-flight migration work)
+- Modify: `supabase/tests/database/rls.test.sql`
+
+**Interfaces:**
+- Consumes: nothing from other tasks in this plan
+- Produces: a public-readable, admin-write-only `salon-images` Storage bucket, consumed by Task 6's `uploadSalonImage()`
+
+- [ ] **Step 1: Write the failing pgTAP tests**
+
+Open `supabase/tests/database/rls.test.sql`. Note its current `select plan(N)` count. You'll add 3 new test cases, so you'll bump it to `N + 3`. Insert the following block right before the final `select * from finish();` line:
+
+```sql
+-- salon-images bucket: anyone can read, only admin can write
+set local role anon;
+select lives_ok(
+ $$ select * from storage.objects where bucket_id = 'salon-images' $$,
+ 'anon can read salon-images objects'
+);
+
+select throws_ok(
+ $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'test.jpg') $$,
+ '42501',
+ null,
+ 'anon cannot upload to salon-images'
+);
+
+set local role authenticated;
+set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';
+select lives_ok(
+ $$ insert into storage.objects (bucket_id, name) values ('salon-images', 'admin-test.jpg') $$,
+ 'admin can upload to salon-images'
+);
+```
+
+Update the `select plan(N)` line at the top of the file to the new total.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `npx supabase test db`
+Expected: FAIL — the new tests error out because the `salon-images` bucket and its policies don't exist yet locally (`insert or update on table "objects" violates foreign key constraint` or similar, since `bucket_id` won't resolve, or a generic RLS-default-deny failure). This confirms the tests are actually exercising something real.
+
+- [ ] **Step 3: Write the migration**
+
+Create `supabase/migrations/_salon_images_storage_policies.sql`:
+
+```sql
+-- The salon-images bucket was created manually in the live project during
+-- Phase 2A's Task 8 (private, no policies — reserved for this task). Local
+-- dev stacks don't have it at all (it wasn't created via migration), so
+-- this creates it if missing and makes it public either way: product/
+-- service photos are marketing images meant to be publicly visible once the
+-- public site (Sub-project C) reads live data.
+insert into storage.buckets (id, name, public)
+values ('salon-images', 'salon-images', true)
+on conflict (id) do update set public = true;
+
+create policy "salon_images_public_read" on storage.objects
+ for select using (bucket_id = 'salon-images');
+
+create policy "salon_images_admin_write" on storage.objects
+ for all
+ using (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid)
+ with check (bucket_id = 'salon-images' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid);
+```
+
+- [ ] **Step 4: Apply the migration and run the tests**
+
+Run: `npx supabase db reset` then `npx supabase test db`
+Expected: all tests pass, including the 3 new ones. If `insert into storage.objects` fails with a missing-column error, check the actual columns Supabase's `storage.objects` table requires in this CLI version (`\d storage.objects` via `npx supabase db query --local "select column_name, is_nullable, column_default from information_schema.columns where table_schema='storage' and table_name='objects'"`) and adjust the test inserts to supply any other required columns — don't just add `not null` workarounds that weaken the test.
+
+- [ ] **Step 5: Run full local verification**
+
+Run: `npx vitest run && npm run build && npm run lint`
+Expected: unaffected, all pass (this task only touches SQL).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add supabase/migrations supabase/tests/database/rls.test.sql
+git commit -m "feat: add public-read, admin-write RLS policies for salon-images bucket"
+```
+
+---
+
+### Task 2: Allow `bookings.status = 'completed'`
+
+**Files:**
+- Create: `supabase/migrations/_add_completed_booking_status.sql`
+- Modify: `supabase/tests/database/rls.test.sql`
+
+**Interfaces:**
+- Consumes: nothing from other tasks in this plan
+- Produces: `bookings.status` accepting `'completed'` in addition to `'confirmed'`/`'cancelled'`, consumed by Task 10's `AdminBookings` page
+
+- [ ] **Step 1: Write the failing pgTAP test**
+
+Add before `select * from finish();` in `supabase/tests/database/rls.test.sql` (still as the admin role from the end of the existing file — reuse the `set local role authenticated; set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';` context already established near the end of the file, or repeat it if you inserted Task 1's tests after that point):
+
+```sql
+select lives_ok(
+ $$ update public.bookings set status = 'completed' where service_id = 'knotless' $$,
+ 'admin can mark a booking completed'
+);
+
+select throws_ok(
+ $$ update public.bookings set status = 'bogus' where service_id = 'knotless' $$,
+ '23514',
+ null,
+ 'booking status is still constrained to known values'
+);
+```
+
+Bump `select plan(N)` by 2.
+
+- [ ] **Step 2: Run the tests to verify the first one fails**
+
+Run: `npx supabase test db`
+Expected: the "admin can mark a booking completed" test FAILs with a check constraint violation (SQLSTATE 23514), since `'completed'` isn't in the current `bookings_status_check` constraint. The second test should already pass (it's testing existing behavior).
+
+- [ ] **Step 3: Write the migration**
+
+Create `supabase/migrations/_add_completed_booking_status.sql`:
+
+```sql
+-- Widen the bookings.status check constraint to allow 'completed', so the
+-- admin panel can mark past appointments completed (not just cancelled).
+alter table public.bookings drop constraint bookings_status_check;
+alter table public.bookings add constraint bookings_status_check
+ check (status in ('confirmed', 'cancelled', 'completed'));
+```
+
+If `drop constraint bookings_status_check` fails with "constraint does not exist", find the real name first: `npx supabase db query --local "select conname from pg_constraint where conrelid = 'public.bookings'::regclass and contype = 'c'"`, then use that name instead.
+
+- [ ] **Step 4: Apply the migration and run the tests**
+
+Run: `npx supabase db reset` then `npx supabase test db`
+Expected: all tests pass.
+
+- [ ] **Step 5: Run full local verification**
+
+Run: `npx vitest run && npm run build && npm run lint`
+Expected: unaffected, all pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add supabase/migrations supabase/tests/database/rls.test.sql
+git commit -m "feat: allow bookings.status to be marked completed"
+```
+
+---
+
+### Task 3: Admin auth hook and route guard
+
+**Files:**
+- Create: `src/hooks/useAdminAuth.ts`
+- Create: `src/hooks/useAdminAuth.test.ts`
+- Create: `src/components/ProtectedRoute.tsx`
+- Create: `src/components/ProtectedRoute.test.tsx`
+
+**Interfaces:**
+- Consumes: `supabase` from `src/lib/supabase.ts`
+- Produces: `useAdminAuth(): { session: Session | null; loading: boolean }` (default export from `src/hooks/useAdminAuth.ts`); `ProtectedRoute` component (default export from `src/components/ProtectedRoute.tsx`) — renders `` when there's a session, redirects to `/admin/login` otherwise, renders nothing while loading. Consumed by Task 12's `AdminApp`.
+
+- [ ] **Step 1: Write the failing hook test**
+
+Create `src/hooks/useAdminAuth.test.ts`:
+
+```ts
+import { renderHook, waitFor } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { Session } from '@supabase/supabase-js'
+
+const getSessionMock = vi.fn()
+const onAuthStateChangeMock = vi.fn()
+
+vi.mock('../lib/supabase', () => ({
+ supabase: {
+ auth: {
+ getSession: (...args: unknown[]) => getSessionMock(...args),
+ onAuthStateChange: (...args: unknown[]) => onAuthStateChangeMock(...args),
+ },
+ },
+}))
+
+const FAKE_SESSION = { access_token: 'token', user: { id: 'admin' } } as unknown as Session
+
+describe('useAdminAuth', () => {
+ beforeEach(() => {
+ getSessionMock.mockReset()
+ onAuthStateChangeMock.mockReset()
+ onAuthStateChangeMock.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
+ })
+
+ it('starts loading, then resolves with the current session', async () => {
+ getSessionMock.mockResolvedValue({ data: { session: FAKE_SESSION } })
+ const { default: useAdminAuth } = await import('./useAdminAuth')
+ const { result } = renderHook(() => useAdminAuth())
+
+ expect(result.current.loading).toBe(true)
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.session).toBe(FAKE_SESSION)
+ })
+
+ it('resolves with a null session when signed out', async () => {
+ getSessionMock.mockResolvedValue({ data: { session: null } })
+ const { default: useAdminAuth } = await import('./useAdminAuth')
+ const { result } = renderHook(() => useAdminAuth())
+
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.session).toBeNull()
+ })
+
+ it('updates session when the auth state changes', async () => {
+ getSessionMock.mockResolvedValue({ data: { session: null } })
+ let capturedCallback: ((event: string, session: Session | null) => void) | undefined
+ onAuthStateChangeMock.mockImplementation((callback) => {
+ capturedCallback = callback
+ return { data: { subscription: { unsubscribe: vi.fn() } } }
+ })
+ const { default: useAdminAuth } = await import('./useAdminAuth')
+ const { result } = renderHook(() => useAdminAuth())
+
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ capturedCallback?.('SIGNED_IN', FAKE_SESSION)
+ await waitFor(() => expect(result.current.session).toBe(FAKE_SESSION))
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `npx vitest run src/hooks/useAdminAuth.test.ts`
+Expected: FAIL — `./useAdminAuth` does not exist yet.
+
+- [ ] **Step 3: Implement the hook**
+
+Create `src/hooks/useAdminAuth.ts`:
+
+```ts
+import { useEffect, useState } from 'react'
+import type { Session } from '@supabase/supabase-js'
+import { supabase } from '../lib/supabase'
+
+export interface AdminAuthState {
+ session: Session | null
+ loading: boolean
+}
+
+export default function useAdminAuth(): AdminAuthState {
+ const [session, setSession] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ supabase.auth.getSession().then(({ data }) => {
+ setSession(data.session)
+ setLoading(false)
+ })
+
+ const { data: listener } = supabase.auth.onAuthStateChange((_event, newSession) => {
+ setSession(newSession)
+ })
+
+ return () => listener.subscription.unsubscribe()
+ }, [])
+
+ return { session, loading }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx vitest run src/hooks/useAdminAuth.test.ts`
+Expected: PASS, 3 tests.
+
+- [ ] **Step 5: Write the failing ProtectedRoute test**
+
+Create `src/components/ProtectedRoute.test.tsx`:
+
+```tsx
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+import { describe, it, expect, vi } from 'vitest'
+
+const useAdminAuthMock = vi.fn()
+vi.mock('../hooks/useAdminAuth', () => ({ default: () => useAdminAuthMock() }))
+
+import ProtectedRoute from './ProtectedRoute'
+
+function renderWithRoute() {
+ return render(
+
+
+ Login page
+ )
+}
+
+export default AdminInquiries
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx vitest run src/pages/admin/AdminInquiries.test.tsx`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/pages/admin/AdminInquiries.tsx src/pages/admin/AdminInquiries.module.css src/pages/admin/AdminInquiries.test.tsx
+git commit -m "feat: add admin inquiries page with mark-as-read action"
+```
+
+---
+
+### Task 12: Wire up `/admin/*` routing
+
+**Files:**
+- Create: `src/pages/admin/AdminApp.tsx`
+- Create: `src/pages/admin/AdminApp.test.tsx`
+- Modify: `src/App.tsx`
+
+**Interfaces:**
+- Consumes: `ProtectedRoute` (Task 3), `AdminLogin` (Task 4), `AdminLayout` (Task 5), `AdminProducts` (Task 7), `AdminServices` (Task 8), `AdminSchedule` (Task 9), `AdminBookings` (Task 10), `AdminInquiries` (Task 11)
+- Produces: `AdminApp` component (default export) — the full `/admin/*` route subtree, mounted once in `src/App.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/pages/admin/AdminApp.test.tsx`:
+
+```tsx
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, it, expect, vi } from 'vitest'
+
+vi.mock('../../hooks/useAdminAuth', () => ({
+ default: () => ({ session: { user: { id: 'admin' } }, loading: false }),
+}))
+vi.mock('../../lib/supabase', () => ({
+ supabase: {
+ auth: { signOut: vi.fn() },
+ from: () => ({
+ select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }),
+ }),
+ },
+}))
+
+import AdminApp from './AdminApp'
+
+describe('AdminApp routing', () => {
+ it('renders the login page at /admin/login', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByRole('heading', { name: /admin login/i })).toBeInTheDocument()
+ })
+
+ it('renders the products page (with layout) at /admin/products when authenticated', async () => {
+ render(
+
+
+ ,
+ )
+ expect(await screen.findByRole('heading', { name: /^products$/i })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `npx vitest run src/pages/admin/AdminApp.test.tsx`
+Expected: FAIL — `./AdminApp` does not exist yet.
+
+- [ ] **Step 3: Implement AdminApp**
+
+Create `src/pages/admin/AdminApp.tsx`:
+
+```tsx
+import { Routes, Route } from 'react-router-dom'
+import ProtectedRoute from '../../components/ProtectedRoute'
+import AdminLogin from './AdminLogin'
+import AdminLayout from './AdminLayout'
+import AdminProducts from './AdminProducts'
+import AdminServices from './AdminServices'
+import AdminSchedule from './AdminSchedule'
+import AdminBookings from './AdminBookings'
+import AdminInquiries from './AdminInquiries'
+
+function AdminApp() {
+ return (
+
+ } />
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
+
+export default AdminApp
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `npx vitest run src/pages/admin/AdminApp.test.tsx`
+Expected: PASS, 2 tests.
+
+- [ ] **Step 5: Mount AdminApp in the main router**
+
+Read `src/App.tsx` first — it currently has one `` inside `AppRoutes()`, with public pages nested under a `}>` wrapper (so they get the public `Nav`/`Footer`). The admin route must NOT be nested under that `Layout` wrapper (the admin panel has its own `AdminLayout` with a sidebar, not the public nav/footer), so add it as a sibling route at the top level of ``, lazy-loaded the same way `Home` already is.
+
+Modify `src/App.tsx`:
+
+```tsx
+import { lazy, Suspense } from 'react'
+import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
+import Layout from './components/Layout'
+import Shop from './pages/Shop'
+import Book from './pages/Book'
+import Rules from './pages/Rules'
+
+const Home = lazy(() => import('./pages/Home'))
+const AdminApp = lazy(() => import('./pages/admin/AdminApp'))
+
+interface AppProps {
+ initialEntries?: string[]
+}
+
+function AppRoutes() {
+ return (
+
+ }>
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+
+
+
+
+ }
+ />
+
+ )
+}
+
+function App({ initialEntries }: AppProps) {
+ if (initialEntries) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+export default App
+```
+
+- [ ] **Step 6: Run the full test suite**
+
+Run: `npx vitest run`
+Expected: PASS, all tests including every admin page's tests plus the pre-existing public-site tests.
+
+- [ ] **Step 7: Run build and lint**
+
+Run: `npm run build && npm run lint`
+Expected: both succeed with no errors (the build's existing >500kB chunk-size warning is pre-existing and expected).
+
+- [ ] **Step 8: Manual verification against the local Supabase stack**
+
+Run `npx supabase start` (if not already running) and `npm run dev`, then in a browser:
+1. Visit `/admin/products` while signed out — confirm it redirects to `/admin/login`.
+2. Sign in with the local admin user (create one first if the local stack doesn't have one: `SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_SERVICE_ROLE_KEY= ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=test-password-123 npx tsx scripts/create-admin.ts`, then also set that same UUID as the admin gate in a throwaway local-only way — actually simplest: since RLS policies are hardcoded to the *production* admin UUID `21c90224-e0b2-412e-a431-8e7f097c1cef`, sign in via the Supabase Studio UI at `http://127.0.0.1:54323` and directly set the local test user's UID to that exact value, or accept that local manual testing exercises the UI/auth flow correctly while write operations will get `42501` until the UID matches — note this discrepancy in your task report rather than silently working around it).
+3. Confirm the sidebar nav links work and each page loads without a console error.
+4. Create a product with an image upload, confirm it appears in the list and the image renders.
+5. Cancel a booking (seed one first via the seed script or the public `/book` flow) and confirm it disappears from the "confirmed" actions.
+
+Report what you observed — this step is about catching integration issues automated tests can't (routing edge cases, real Storage upload behavior, RLS UID mismatches), not about achieving a specific pass/fail.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/pages/admin/AdminApp.tsx src/pages/admin/AdminApp.test.tsx src/App.tsx
+git commit -m "feat: wire up /admin/* routing"
+```
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** login (Task 4), products/services CRUD with image upload (Tasks 6-8), schedule template + exceptions (Task 9), bookings/inquiries view + act (Tasks 10-11), route protection (Task 3), routing/lazy-loading (Task 12), Storage bucket policies (Task 1). All spec sections covered.
+- **Type consistency:** `AdminProduct`/`AdminService`/`ScheduleTemplate`/`ScheduleException`/`AdminBooking`/`AdminInquiry` are each defined once, in the task that first needs them, matching real Postgres column names (snake_case) exactly rather than the frontend's existing camelCase mock-data types (`Product`/`Service` in `src/data/`) — those are separate, intentionally not reused, since this admin panel talks to the live schema, not the mock data.
+- **Deviations from the design spec, explicit:** the spec's wording ("mark an inquiry as responded") used a status value that doesn't exist in the schema (`'new'`/`'read'` are the only two) — Task 11 uses `'read'` instead of inventing a new status, avoiding an unnecessary migration. The spec's wording for schedule exceptions ("closed-or-custom-hours") doesn't match the actual `schedule_exceptions` schema, which only has `closed: boolean` and a free-text `note` — Task 9 implements the real schema (closed toggle + note), not structured custom hours.
+- **No placeholders:** every step has literal file contents; the two new migration timestamps are the only execution-time-generated values, generated via a documented command rather than guessed, to avoid collision with any other in-flight migration work on this repository.