feat(fase-13): admin UI + sidebar entry + isServerAdmin store + e2e SA-01..04

13.3 admin UI:
  - (admin) route group with own layout + red banner + sidebar (Collectives,
    Admins, Audit log, Server). +layout.ts is client-rendered (ssr=false),
    same rationale as the (app) group.
  - /admin/collectives — paginated list via admin_list_collectives, search
    by name, soft-delete / restore / hard-delete modals (hard-delete needs
    explicit checkbox; force toggle bypasses the 30-day wait server-side).
  - /admin/collectives/[id] — members list with kick action; recent actions
    for the collective filtered from admin_actions.
  - /admin/admins — list via server_admins join to users (disambiguated by
    FK name — there are TWO FKs to users so the embed needs the explicit
    server_admins_user_id_fkey); promote modal does an email lookup; revoke
    button is replaced by "you (cannot revoke yourself while sole admin)"
    when applicable.
  - /admin/audit — paginated feed (default 50), action filter.
  - /admin/server — server-layer default-section toggles backed by
    admin_set_default_section + the new admin_clear_default_section RPC
    (added because patching the JSONB to `true` is NOT equivalent to "no
    opinion" — `true` explicitly overrides a collective OFF).

13.4 sidebar/drawer entry:
  - $isServerAdmin store (writable; +$isServerAdminLoaded for the gate
    race) refreshed on every onAuthStateChange in root layout, cleared on
    sign-out. Cached so the sidebar tile is synchronous.
  - DesktopSidebar + MobileDrawer render the entry only when the store is
    true. Distinct red icon so it never blends with normal nav.

Stores wiring:
  - features.ts gains serverSectionDefaults writable + enabledSections
    derived now reads server > collective > user > default. Loader
    piggybacks on loadCurrentUserFeatures so the server layer is fetched
    once per sign-in.
  - serverAdmin.ts new module — refreshServerAdminFlag() + clearServerAdminFlag().
  - Tracks isServerAdminLoaded so the (admin) layout doesn't redirect away
    during the millisecond between SIGNED_IN and the RPC resolving (caught
    empirically — every hard-load to /admin/* bounced to / without it).

13.5 tests:
  - admin.test.ts (SA-01..SA-04) with new loginAsAdmin fixture. Ana is the
    seed admin (server_admins seeded via supabase/seed.sql).
  - SA-04 + SV-02 reset their server-layer JSONB via the new
    admin_clear_default_section RPC so the suites don't leak state into
    each other.

Migration 025 adds admin_clear_default_section(text) — same SECURITY DEFINER
+ audit pattern as the rest. pgTAP 017 updated (23 plans, AR-T13b + the
SECURITY DEFINER list now includes the clear RPC).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 05:30:56 +02:00
parent 7556d77bf8
commit 27afda74f1
21 changed files with 1726 additions and 11 deletions

View File

@@ -0,0 +1,300 @@
/**
* SA-series — Server administration UI (Fase 13.5.2).
*
* SA-01 Ana (seed admin) sees the /admin link in the sidebar and can reach
* /admin/collectives. Borja (member) does NOT see the link, and
* visiting /admin directly redirects to /.
* SA-02 Admin soft-deletes a throwaway collective via the UI, then restores
* it. The status badge flips deleted ↔ active. The audit log page
* lists both actions.
* SA-03 Admin promotes Carmen to server_admin from /admin/admins; the entry
* appears in the list. Then revokes her. Trying to revoke the sole
* remaining admin is blocked by the server-side guard and the UI
* surfaces an error (without breaking the page).
* SA-04 Admin sets a server-level default-section override OFF for "notes"
* from /admin/server; a member of the seed collective who had notes
* ON locally sees the section disappear from their nav within a
* reload (no realtime on server_settings — acceptable; the page
* re-evaluates on next mount).
*
* Idempotency: every test creates its own throwaway collective via direct
* SQL through the in-page Supabase client (`__sb`). The afterEach clears it.
* Promotion state for Carmen is rolled back via direct DELETE.
*/
import { test, expect, type Page, type Browser } from '@playwright/test';
import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { loginAsAdmin } from '../fixtures/admin-login.js';
const CARMEN_ID = '33333333-3333-3333-3333-333333333333';
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
// Track collectives + extra admins created during the suite so we can clean
// them up. The afterAll runs once after the whole file.
const throwawayCollectiveIds = new Set<string>();
const extraAdminUserIds = new Set<string>([CARMEN_ID]); // Carmen may be promoted in SA-03
async function withAnaPage<T>(browser: Browser, fn: (page: Page) => Promise<T>): Promise<T> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await loginAsAdmin(page);
return await fn(page);
} finally {
await ctx.close();
}
}
async function createThrowawayCollective(page: Page, name: string): Promise<string> {
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
timeout: 5_000
});
const id = await page.evaluate(async (n) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
const { data, error } = await supabase.rpc('create_collective', { p_name: n, p_emoji: '🧪' });
if (error) throw new Error(error.message);
return (data as { id: string }).id;
}, name);
throwawayCollectiveIds.add(id);
return id;
}
async function resetUserAndCollectiveFlags(browser: Browser): Promise<void> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await loginAsAdmin(page);
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
timeout: 5_000
});
await page.evaluate(async () => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
await supabase
.from('users')
.update({ feature_flags: {} })
.eq('id', '11111111-1111-1111-1111-111111111111');
await supabase
.from('users')
.update({ feature_flags: {} })
.eq('id', '22222222-2222-2222-2222-222222222222');
await supabase
.from('collectives')
.update({ feature_flags: {} })
.eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
// Restore server_settings to "no opinion" for every known section
// so subsequent suites (e.g. section-visibility SV-02) see a clean
// server layer. admin_clear_default_section removes the key from
// the JSONB; `true` would NOT be equivalent to "no opinion" — it
// would explicitly override any collective OFF.
for (const s of ['lists', 'tasks', 'notes', 'search']) {
try {
await supabase.rpc('admin_clear_default_section', { p_section: s });
} catch {
/* idempotent */
}
}
});
} finally {
await ctx.close();
}
}
test.afterAll(async ({ browser }) => {
// Best-effort cleanup. Use Ana's session (admin) so the hard-delete RPC
// is callable for any leftover throwaway collectives.
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await loginAsAdmin(page);
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
timeout: 5_000
});
await page.evaluate(
async ({ collectives, extraAdmins }) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
for (const id of collectives) {
try {
await supabase.rpc('admin_hard_delete_collective', {
p_collective_id: id,
p_force: true
});
} catch {
/* may already be gone */
}
}
for (const u of extraAdmins) {
try {
await supabase.rpc('revoke_server_admin', { p_user: u });
} catch {
/* may not be an admin */
}
}
await supabase
.from('users')
.update({ feature_flags: {} })
.neq('id', '00000000-0000-0000-0000-000000000000');
await supabase
.from('collectives')
.update({ feature_flags: {} })
.neq('id', '00000000-0000-0000-0000-000000000000');
for (const s of ['lists', 'tasks', 'notes', 'search']) {
try {
await supabase.rpc('admin_clear_default_section', { p_section: s });
} catch {
/* idempotent */
}
}
},
{
collectives: Array.from(throwawayCollectiveIds),
extraAdmins: Array.from(extraAdminUserIds)
}
);
} catch {
// Don't mask the real test failure.
} finally {
await ctx.close();
}
});
test.describe('Server administration UI', () => {
test('SA-01: Ana sees the admin link + can reach /admin; Borja does not and gets bounced from /admin', async ({
browser
}) => {
// Ana
await withAnaPage(browser, async (page) => {
await expect(page.getByTestId('sidebar-admin-link')).toBeVisible();
await page.getByTestId('sidebar-admin-link').click();
await expect(page).toHaveURL(/\/admin\/collectives$/);
await expect(page.getByTestId('admin-banner')).toBeVisible();
await expect(page.getByTestId('admin-collectives-table')).toBeVisible();
});
// Borja — no admin link, direct hit redirects.
const borjaCtx = await browser.newContext();
const borjaPage = await borjaCtx.newPage();
try {
await loginAs(borjaPage, USERS.borja);
await expect(borjaPage.getByTestId('sidebar-admin-link')).toHaveCount(0);
await borjaPage.goto('/admin');
// The (admin)/+layout.svelte `$effect` redirects non-admins to /.
await expect(borjaPage).toHaveURL(/\/lists$|\/onboarding$|\/$/, { timeout: 10_000 });
await expect(borjaPage.getByTestId('admin-banner')).toHaveCount(0);
} finally {
await borjaCtx.close();
}
});
test('SA-02: soft-delete + restore round-trip + audit log entries', async ({ browser }) => {
await withAnaPage(browser, async (page) => {
const cid = await createThrowawayCollective(page, `SA-02 ${Date.now()}`);
await page.goto('/admin/collectives');
await expect(page.getByTestId(`admin-collective-row-${cid}`)).toBeVisible();
// Soft-delete.
await page.getByTestId(`admin-soft-delete-${cid}`).click();
await expect(page.getByTestId('admin-soft-delete-modal')).toBeVisible();
await page.getByTestId('admin-modal-reason').fill('SA-02 reason');
await page.getByTestId('admin-modal-confirm').click();
await expect(page.getByTestId('admin-soft-delete-modal')).toHaveCount(0);
// Status badge flipped.
await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText(
/Soft-deleted|Borrado/i
);
// Restore.
await page.getByTestId(`admin-restore-${cid}`).click();
await expect(page.getByTestId('admin-restore-modal')).toBeVisible();
await page.getByTestId('admin-modal-confirm').click();
await expect(page.getByTestId('admin-restore-modal')).toHaveCount(0);
await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText(
/Active|Activo/i
);
// Audit log shows both rows for this target.
await page.goto('/admin/audit');
await expect(page.getByTestId('admin-audit-table')).toBeVisible();
const actions = await page
.getByTestId('admin-audit-table')
.locator(`tr:has-text("${cid}")`)
.allTextContents();
const joined = actions.join('\n');
expect(joined).toMatch(/soft_delete_collective/);
expect(joined).toMatch(/restore_collective/);
});
});
test('SA-03: promote + revoke another admin; sole-admin revoke is blocked', async ({
browser
}) => {
await withAnaPage(browser, async (page) => {
await page.goto('/admin/admins');
// Promote Carmen.
await page.getByTestId('admin-promote-open').click();
await expect(page.getByTestId('admin-promote-modal')).toBeVisible();
await page.getByTestId('admin-promote-email').fill(USERS.carmen.email);
await page.getByTestId('admin-modal-confirm').click();
await expect(page.getByTestId('admin-promote-modal')).toHaveCount(0);
await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toBeVisible();
// Revoke Carmen — succeeds (Ana remains).
await page.getByTestId(`admin-revoke-${CARMEN_ID}`).click();
await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toHaveCount(0);
// Now Ana is the sole admin. The UI replaces the Revoke button with
// "you (cannot revoke yourself while sole admin)".
const anaRow = page.getByTestId(`admin-admin-row-${USERS.ana.id}`);
await expect(anaRow).toContainText(/cannot revoke yourself|auto-revocarte/i);
await expect(page.getByTestId(`admin-revoke-${USERS.ana.id}`)).toHaveCount(0);
});
});
test('SA-04: server-level OFF for notes wins over collective ON', async ({ browser }) => {
await withAnaPage(browser, async (page) => {
// Make sure collective has notes ON explicitly (admin override).
await page.evaluate(async () => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
await supabase
.from('collectives')
.update({ feature_flags: { notes: true } })
.eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
});
// Set server-level notes OFF.
await page.goto('/admin/server');
await expect(page.getByTestId('admin-server-sections')).toBeVisible();
await page.getByTestId('admin-server-off-notes').click();
await expect(page.getByTestId('admin-server-section-state-notes')).toContainText(/OFF/i);
});
// Borja (member of seed collective) loads /lists; the Notes entry must
// not be in his nav. There's no realtime on server_settings — a fresh
// page load is the contract.
const borjaCtx = await browser.newContext();
const borjaPage = await borjaCtx.newPage();
try {
await loginAs(borjaPage, USERS.borja);
await borjaPage.goto('/lists');
await expect(borjaPage.getByTestId('desktop-sidebar')).toBeVisible();
// The sidebar nav anchor for notes carries `data-section="notes"`.
await expect(
borjaPage.getByTestId('desktop-sidebar').locator('a[data-section="notes"]')
).toHaveCount(0);
} finally {
await borjaCtx.close();
}
await resetUserAndCollectiveFlags(browser);
});
});

View File

@@ -76,6 +76,22 @@ async function resetAllFlags(browser: import('@playwright/test').Browser): Promi
await loginAs(anaPage, USERS.ana);
await patchRow(anaPage, 'users', USERS.ana.id, { feature_flags: {} });
await patchRow(anaPage, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} });
// Fase 13: also clear the server-layer defaults so a leftover from the
// admin suite (which can run earlier in `just test-all`) doesn't break
// our precedence assertions. Ana is the seed server_admin so the RPC
// passes the gate.
await anaPage.evaluate(async () => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
for (const s of ['lists', 'tasks', 'notes', 'search']) {
try {
await supabase.rpc('admin_clear_default_section', { p_section: s });
} catch {
/* non-admin: ignore */
}
}
});
} catch {
/* don't mask original failure */
} finally {

29
apps/web/tests/fixtures/admin-login.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
/**
* Server-admin login helper (Fase 13.5.2).
*
* Ana is pre-seeded into `public.server_admins` by `supabase/seed.sql`. This
* helper is a thin sugar over `loginAs(page, USERS.ana)` that ALSO waits for
* `$isServerAdmin` to flip true (which only happens once the root layout's
* `refreshServerAdminFlag()` resolves). Tests that rely on the sidebar admin
* entry being rendered need this; tests that hit /admin/* directly do not
* (the layout's effect handles the wait).
*
* We don't expose a non-Ana admin promotion path here — the integration suite
* already covers `grant_server_admin` end-to-end. If a test needs a different
* admin it should call grant_server_admin from a privileged-client setup step
* and rely on the next token refresh; we have no such test today.
*/
import type { Page } from '@playwright/test';
import { USERS } from './users.js';
import { loginAs } from './login.js';
export async function loginAsAdmin(page: Page): Promise<void> {
await loginAs(page, USERS.ana);
// `refreshServerAdminFlag()` is fire-and-forget inside the auth callback
// and the sidebar tile keys off the resulting store. Wait until the
// rendered tile (or its data-testid) actually appears so any subsequent
// click is deterministic.
await page.waitForSelector('[data-testid="sidebar-admin-link"]', {
timeout: 10_000
});
}