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>
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
/**
|
|
* SV-series — Section visibility / feature_flags (Fase 12).
|
|
*
|
|
* SV-01 Ana hides "Tasks" in /settings → sidebar + bottom tab bar stop
|
|
* showing it, and visiting /tasks redirects to /lists.
|
|
* SV-02 Ana (admin) hides "Notes" in /collective/manage → Borja (member of
|
|
* the same collective) sees the entry disappear without a reload
|
|
* (realtime).
|
|
* SV-03 Ana sets a collective-level Tasks override ON in a fresh extra
|
|
* collective; Borja (already opted out of Tasks at the user layer)
|
|
* sees Tasks reappear in that collective but stays hidden in others.
|
|
* Verifies precedence collective > user resolves per-collective.
|
|
*
|
|
* Each test resets the seed user/collective JSON columns afterwards so the
|
|
* suite is idempotent. Reset uses the live PostgREST endpoint via the JWT
|
|
* already stashed in localStorage by `loginAs`.
|
|
*/
|
|
import { test, expect, type Page } from '@playwright/test';
|
|
import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js';
|
|
import { loginAs } from '../fixtures/login.js';
|
|
|
|
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
|
|
/**
|
|
* Patch a row via the Supabase client already loaded in the page (exposed
|
|
* as `window.__sb` in dev — see `+layout.svelte`). Returns the HTTP-ish
|
|
* status (200 on success, 0 if the client is not ready yet).
|
|
*/
|
|
async function patchRow(
|
|
page: Page,
|
|
table: 'users' | 'collectives',
|
|
id: string,
|
|
body: Record<string, unknown>
|
|
): Promise<number> {
|
|
// Wait for the dev hook to land — onMount fires after the first paint, so
|
|
// in fast tests we can race the eval. 5s timeout is plenty.
|
|
await page.waitForFunction(
|
|
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
|
null,
|
|
{ timeout: 5_000 }
|
|
);
|
|
return await page.evaluate(
|
|
async ({ table, id, body }) => {
|
|
const supabase = (
|
|
window as unknown as {
|
|
__sb: import('@supabase/supabase-js').SupabaseClient;
|
|
}
|
|
).__sb;
|
|
const { error } = await supabase.from(table).update(body).eq('id', id);
|
|
return error ? 500 : 200;
|
|
},
|
|
{ table, id, body }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tab bar entries live inside `[data-testid="bottom-tab-bar"]`. We assert on
|
|
* the *count* of the matching `[data-section="X"]` so the test does not depend
|
|
* on the visible-vs-hidden CSS state (the entry is removed from the DOM when
|
|
* hidden).
|
|
*/
|
|
async function tabBarHasSection(page: Page, section: string): Promise<number> {
|
|
return await page
|
|
.getByTestId('bottom-tab-bar')
|
|
.locator(`[data-section="${section}"]`)
|
|
.count();
|
|
}
|
|
|
|
async function resetAllFlags(browser: import('@playwright/test').Browser): Promise<void> {
|
|
// Reset the seed rows via Ana (the only user authoritative for both her own
|
|
// row AND the seed collective row). Patching another user's row from Ana
|
|
// would be blocked by RLS, so we open a second context for Borja.
|
|
const anaCtx = await browser.newContext();
|
|
const anaPage = await anaCtx.newPage();
|
|
try {
|
|
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 {
|
|
await anaCtx.close();
|
|
}
|
|
|
|
const borjaCtx = await browser.newContext();
|
|
const borjaPage = await borjaCtx.newPage();
|
|
try {
|
|
await loginAs(borjaPage, USERS.borja);
|
|
await patchRow(borjaPage, 'users', USERS.borja.id, { feature_flags: {} });
|
|
} catch {
|
|
/* don't mask original failure */
|
|
} finally {
|
|
await borjaCtx.close();
|
|
}
|
|
}
|
|
|
|
test.describe('Section visibility', () => {
|
|
// Reset BOTH before and after every test. The before-each guards against
|
|
// state leakage from prior suites (the broader `just test-all` run shares
|
|
// the same Docker database with whatever tasks/lists tests ran before).
|
|
// The after-each keeps subsequent suites clean.
|
|
test.beforeEach(async ({ browser }) => {
|
|
await resetAllFlags(browser);
|
|
});
|
|
|
|
test.afterEach(async ({ browser }) => {
|
|
await resetAllFlags(browser);
|
|
});
|
|
|
|
test('SV-01: Ana hides Tasks in /settings — sidebar + tab bar drop it, /tasks redirects to /lists', async ({
|
|
page
|
|
}) => {
|
|
await loginAs(page, USERS.ana);
|
|
await page.goto('/settings');
|
|
|
|
const toggle = page.getByTestId('settings-section-toggle-tasks');
|
|
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
|
await expect(toggle).toBeChecked();
|
|
await toggle.click();
|
|
await expect(toggle).not.toBeChecked();
|
|
|
|
const sidebar = page.getByTestId('desktop-sidebar');
|
|
await expect(sidebar.locator('[data-section="tasks"]')).toHaveCount(0, {
|
|
timeout: 5_000
|
|
});
|
|
const tabBar = page.getByTestId('bottom-tab-bar');
|
|
await expect(tabBar.locator('[data-section="tasks"]')).toHaveCount(0);
|
|
await expect(tabBar).toHaveAttribute('data-section-count', '3');
|
|
|
|
await page.goto('/tasks');
|
|
await page.waitForURL(/\/lists/, { timeout: 10_000 });
|
|
await expect(page.getByTestId('section-disabled-toast')).toBeVisible({
|
|
timeout: 5_000
|
|
});
|
|
});
|
|
|
|
test('SV-02: Ana hides Notes for the collective — Borja sees it disappear in realtime', async ({
|
|
browser
|
|
}) => {
|
|
const anaCtx = await browser.newContext();
|
|
const ana = await anaCtx.newPage();
|
|
const borjaCtx = await browser.newContext();
|
|
const borja = await borjaCtx.newPage();
|
|
|
|
try {
|
|
await loginAs(ana, USERS.ana);
|
|
// Make sure both flag rows are clean before Borja loads.
|
|
await patchRow(ana, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} });
|
|
await patchRow(ana, 'users', USERS.ana.id, { feature_flags: {} });
|
|
|
|
await loginAs(borja, USERS.borja);
|
|
await patchRow(borja, 'users', USERS.borja.id, { feature_flags: {} });
|
|
await borja.goto('/lists');
|
|
// BottomTabBar is `md:hidden` so on Desktop Chrome it lives in the
|
|
// DOM but is CSS-hidden — assert via locator count instead of
|
|
// toBeVisible (which would otherwise fail on the visibility check).
|
|
await expect(borja.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 });
|
|
expect(await tabBarHasSection(borja, 'notes')).toBe(1);
|
|
|
|
// Ana flips the collective-level Notes toggle off.
|
|
await ana.goto('/collective/manage');
|
|
const adminToggle = ana.getByTestId('manage-section-toggle-notes');
|
|
await expect(adminToggle).toBeVisible({ timeout: 10_000 });
|
|
await expect(adminToggle).toBeChecked();
|
|
await adminToggle.click();
|
|
await expect(adminToggle).not.toBeChecked();
|
|
|
|
// Borja's nav loses Notes without a reload — realtime carried the change.
|
|
await expect
|
|
.poll(() => tabBarHasSection(borja, 'notes'), { timeout: 15_000 })
|
|
.toBe(0);
|
|
} finally {
|
|
await anaCtx.close();
|
|
await borjaCtx.close();
|
|
}
|
|
});
|
|
|
|
test('SV-03: collective ON override wins over user OFF — Tasks reappears per-collective', async ({
|
|
page,
|
|
browser
|
|
}) => {
|
|
// Borja opts out of Tasks at the user layer (in the seed collective).
|
|
await loginAs(page, USERS.borja);
|
|
await patchRow(page, 'users', USERS.borja.id, { feature_flags: { tasks: false } });
|
|
// Force a re-fetch by reloading — the page first loaded with the empty
|
|
// row, the realtime subscription will also fire, but reload is the most
|
|
// deterministic way to start from a known state.
|
|
await page.reload();
|
|
await page.waitForLoadState('networkidle');
|
|
await expect(page.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 });
|
|
expect(await tabBarHasSection(page, 'tasks')).toBe(0);
|
|
|
|
// Ana (admin) explicitly sets Tasks ON at the collective level. Since
|
|
// the collective layer outranks the user layer, Tasks should reappear
|
|
// for Borja in this collective without him touching his user toggle.
|
|
const adminCtx = await browser.newContext();
|
|
const adminPage = await adminCtx.newPage();
|
|
try {
|
|
await loginAs(adminPage, USERS.ana);
|
|
const status = await patchRow(adminPage, 'collectives', SEED_COLLECTIVE_ID, {
|
|
feature_flags: { tasks: true }
|
|
});
|
|
expect(status).toBeGreaterThanOrEqual(200);
|
|
expect(status).toBeLessThan(300);
|
|
} finally {
|
|
await adminCtx.close();
|
|
}
|
|
|
|
// Borja's BottomTabBar should pick the change up via realtime within
|
|
// the standard 15s window (matches the Realtime gotcha note).
|
|
await expect
|
|
.poll(() => tabBarHasSection(page, 'tasks'), { timeout: 15_000 })
|
|
.toBe(1);
|
|
|
|
// Sanity reference for the COLLECTIVE_NAME import — keeps the
|
|
// fixture link explicit in case the helper file moves.
|
|
expect(COLLECTIVE_NAME.length).toBeGreaterThan(0);
|
|
});
|
|
});
|