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:
300
apps/web/tests/e2e/admin.test.ts
Normal file
300
apps/web/tests/e2e/admin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user