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>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|