diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 082b4f6..1f359ef 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -240,5 +240,64 @@ "section_label_lists": "Lists", "section_label_tasks": "Tasks", "section_label_notes": "Notes", - "section_label_search": "Search" + "section_label_search": "Search", + "admin_banner": "Admin mode — actions are logged", + "admin_nav_collectives": "Collectives", + "admin_nav_admins": "Admins", + "admin_nav_audit": "Audit log", + "admin_nav_server": "Server", + "admin_menu_entry": "Server admin", + "admin_collectives_title": "Collectives", + "admin_collectives_search_placeholder": "Search by name…", + "admin_collectives_empty": "No collectives match.", + "admin_collectives_col_name": "Name", + "admin_collectives_col_members": "Members", + "admin_collectives_col_created": "Created", + "admin_collectives_col_status": "Status", + "admin_collectives_status_active": "Active", + "admin_collectives_status_deleted": "Soft-deleted", + "admin_action_view": "View", + "admin_action_soft_delete": "Soft-delete", + "admin_action_restore": "Restore", + "admin_action_hard_delete": "Hard-delete", + "admin_action_remove": "Remove", + "admin_modal_soft_delete_title": "Soft-delete collective", + "admin_modal_soft_delete_help": "The collective is hidden from members but recoverable for 30 days. Reason is logged.", + "admin_modal_reason_label": "Reason", + "admin_modal_reason_placeholder": "Why?", + "admin_modal_hard_delete_title": "Hard-delete collective", + "admin_modal_hard_delete_help": "This is irreversible. All lists, items, tasks, and notes will be deleted.", + "admin_modal_hard_delete_confirm": "I understand this is irreversible", + "admin_modal_hard_delete_force_label": "Force (bypass 30-day wait)", + "admin_modal_cancel": "Cancel", + "admin_modal_confirm": "Confirm", + "admin_collective_detail_back": "Back to collectives", + "admin_collective_detail_members": "Members", + "admin_collective_detail_recent_actions": "Recent actions", + "admin_modal_remove_member_title": "Remove member", + "admin_modal_remove_member_help": "Membership is revoked immediately. Reason is logged.", + "admin_admins_title": "Server admins", + "admin_admins_promote": "Promote user", + "admin_admins_promote_help": "Enter the email of the user to promote.", + "admin_admins_email_label": "Email", + "admin_admins_email_placeholder": "user@example.com", + "admin_admins_email_not_found": "No user with that email.", + "admin_admins_revoke_self": "you (cannot revoke yourself while sole admin)", + "admin_admins_revoke": "Revoke", + "admin_audit_title": "Audit log", + "admin_audit_empty": "No actions logged yet.", + "admin_audit_col_when": "When", + "admin_audit_col_actor": "Actor", + "admin_audit_col_action": "Action", + "admin_audit_col_target": "Target", + "admin_audit_col_payload": "Payload", + "admin_server_title": "Server settings", + "admin_server_default_sections": "Default section visibility", + "admin_server_default_sections_help": "Server-level overrides. ON or OFF here wins over every collective and user override.", + "admin_server_section_state_off": "OFF", + "admin_server_section_state_on": "ON", + "admin_server_section_state_unset": "No opinion", + "admin_server_info": "Server info", + "admin_error_forbidden": "You don't have access to this area.", + "admin_error_unknown": "Something went wrong. Please try again." } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 6fbbf25..ac860cf 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -240,5 +240,64 @@ "section_label_lists": "Listas", "section_label_tasks": "Tareas", "section_label_notes": "Notas", - "section_label_search": "Buscar" + "section_label_search": "Buscar", + "admin_banner": "Modo admin — las acciones se registran", + "admin_nav_collectives": "Colectivos", + "admin_nav_admins": "Admins", + "admin_nav_audit": "Registro", + "admin_nav_server": "Servidor", + "admin_menu_entry": "Admin del servidor", + "admin_collectives_title": "Colectivos", + "admin_collectives_search_placeholder": "Buscar por nombre…", + "admin_collectives_empty": "Ningún colectivo coincide.", + "admin_collectives_col_name": "Nombre", + "admin_collectives_col_members": "Miembros", + "admin_collectives_col_created": "Creado", + "admin_collectives_col_status": "Estado", + "admin_collectives_status_active": "Activo", + "admin_collectives_status_deleted": "Borrado", + "admin_action_view": "Ver", + "admin_action_soft_delete": "Soft-delete", + "admin_action_restore": "Restaurar", + "admin_action_hard_delete": "Eliminar", + "admin_action_remove": "Expulsar", + "admin_modal_soft_delete_title": "Soft-delete colectivo", + "admin_modal_soft_delete_help": "El colectivo queda oculto para los miembros pero recuperable durante 30 días. Se registra el motivo.", + "admin_modal_reason_label": "Motivo", + "admin_modal_reason_placeholder": "¿Por qué?", + "admin_modal_hard_delete_title": "Eliminar colectivo", + "admin_modal_hard_delete_help": "Esto es irreversible. Se eliminarán todas las listas, ítems, tareas y notas.", + "admin_modal_hard_delete_confirm": "Entiendo que es irreversible", + "admin_modal_hard_delete_force_label": "Forzar (saltar espera de 30 días)", + "admin_modal_cancel": "Cancelar", + "admin_modal_confirm": "Confirmar", + "admin_collective_detail_back": "Volver a colectivos", + "admin_collective_detail_members": "Miembros", + "admin_collective_detail_recent_actions": "Acciones recientes", + "admin_modal_remove_member_title": "Expulsar miembro", + "admin_modal_remove_member_help": "La membresía se revoca al momento. Se registra el motivo.", + "admin_admins_title": "Admins del servidor", + "admin_admins_promote": "Promover usuario", + "admin_admins_promote_help": "Introduce el email del usuario a promover.", + "admin_admins_email_label": "Email", + "admin_admins_email_placeholder": "usuario@ejemplo.com", + "admin_admins_email_not_found": "No hay ningún usuario con ese email.", + "admin_admins_revoke_self": "tú (no puedes auto-revocarte si eres el único admin)", + "admin_admins_revoke": "Revocar", + "admin_audit_title": "Registro de acciones", + "admin_audit_empty": "Aún no hay acciones registradas.", + "admin_audit_col_when": "Cuándo", + "admin_audit_col_actor": "Actor", + "admin_audit_col_action": "Acción", + "admin_audit_col_target": "Objetivo", + "admin_audit_col_payload": "Payload", + "admin_server_title": "Configuración del servidor", + "admin_server_default_sections": "Visibilidad por defecto de secciones", + "admin_server_default_sections_help": "Override a nivel servidor. ON u OFF aquí prevalece sobre cualquier colectivo o usuario.", + "admin_server_section_state_off": "OFF", + "admin_server_section_state_on": "ON", + "admin_server_section_state_unset": "Sin opinión", + "admin_server_info": "Información del servidor", + "admin_error_forbidden": "No tienes acceso a esta zona.", + "admin_error_unknown": "Algo ha ido mal. Inténtalo de nuevo." } diff --git a/apps/web/src/lib/components/layout/DesktopSidebar.svelte b/apps/web/src/lib/components/layout/DesktopSidebar.svelte index fdeef3a..977e4a7 100644 --- a/apps/web/src/lib/components/layout/DesktopSidebar.svelte +++ b/apps/web/src/lib/components/layout/DesktopSidebar.svelte @@ -3,11 +3,12 @@ import { displayName } from '$lib/stores/auth'; import { currentCollective, userCollectives } from '$lib/stores/collective'; import { enabledSections } from '$lib/stores/features'; + import { isServerAdmin } from '$lib/stores/serverAdmin'; import type { SectionKey } from '@colectivo/types'; import Avatar from '$lib/components/Avatar.svelte'; import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte'; import * as m from '$lib/paraglide/messages'; - import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus } from 'lucide-svelte'; + import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus, Shield } from 'lucide-svelte'; // Fase 12.3.1: nav items declared with their section key so we can filter // by $enabledSections (precedence resolved client-side; see features.ts). @@ -100,6 +101,18 @@
+ {#if $isServerAdmin} + + + + {m.admin_menu_entry()} + + {/if}
diff --git a/apps/web/src/lib/components/layout/MobileDrawer.svelte b/apps/web/src/lib/components/layout/MobileDrawer.svelte index 87907c3..c5a1051 100644 --- a/apps/web/src/lib/components/layout/MobileDrawer.svelte +++ b/apps/web/src/lib/components/layout/MobileDrawer.svelte @@ -1,11 +1,12 @@ + +{#if $authLoading || ($isAuthenticated && !$isServerAdminLoaded)} +
+ {m.loading()} +
+{:else if $isAuthenticated && $isServerAdmin} +
+ +
+ + {m.admin_banner()} +
+ +
+ + + + +
+ {@render children()} +
+
+
+{/if} diff --git a/apps/web/src/routes/(admin)/+layout.ts b/apps/web/src/routes/(admin)/+layout.ts new file mode 100644 index 0000000..e42f86a --- /dev/null +++ b/apps/web/src/routes/(admin)/+layout.ts @@ -0,0 +1,9 @@ +// Fase 13.3.1 — admin route group is client-rendered only (same rationale as +// the (app) group: getSession() in load races Supabase's async storage init). +// The actual is_server_admin() gate fires in +layout.svelte once the auth +// listener resolves; the RPCs themselves enforce server-side. Belt-and-braces. +export const ssr = false; + +export function load() { + return {}; +} diff --git a/apps/web/src/routes/(admin)/admin/+page.svelte b/apps/web/src/routes/(admin)/admin/+page.svelte new file mode 100644 index 0000000..884c3a1 --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/+page.svelte @@ -0,0 +1,11 @@ + + +
diff --git a/apps/web/src/routes/(admin)/admin/admins/+page.svelte b/apps/web/src/routes/(admin)/admin/admins/+page.svelte new file mode 100644 index 0000000..74ca495 --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/admins/+page.svelte @@ -0,0 +1,183 @@ + + +
+
+

{m.admin_admins_title()}

+ +
+ + {#if error} + + {/if} + + {#if loading} +

{m.loading()}

+ {:else} +
    + {#each admins as a (a.user_id)} +
  • +
    +

    {a.display_name || a.email}

    +

    {a.email}

    +
    + {#if a.user_id === $currentUser?.id && totalAdmins <= 1} + {m.admin_admins_revoke_self()} + {:else} + + {/if} +
  • + {/each} +
+ {/if} +
+ +{#if showPromote} +
+ +
+{/if} diff --git a/apps/web/src/routes/(admin)/admin/audit/+page.svelte b/apps/web/src/routes/(admin)/admin/audit/+page.svelte new file mode 100644 index 0000000..8505b5a --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/audit/+page.svelte @@ -0,0 +1,118 @@ + + +
+
+

{m.admin_audit_title()}

+ void load()} + placeholder="action…" + class="ml-auto w-56 rounded-md border border-slate-200 bg-surface-raised px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800" + data-testid="admin-audit-filter" + /> +
+ + {#if error} + + {/if} + + {#if loading} +

{m.loading()}

+ {:else if rows.length === 0} +

{m.admin_audit_empty()}

+ {:else} +
+ + + + + + + + + + + + {#each rows as row (row.id)} + + + + + + + + {/each} + +
{m.admin_audit_col_when()}{m.admin_audit_col_actor()}{m.admin_audit_col_action()}{m.admin_audit_col_target()}{m.admin_audit_col_payload()}
{new Date(row.created_at).toLocaleString()}{row.actor_name}{row.action} + {row.target_type} + {#if row.target_id} +
{row.target_id} + {/if} +
+
{JSON.stringify(row.payload, null, 2)}
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(admin)/admin/collectives/+page.svelte b/apps/web/src/routes/(admin)/admin/collectives/+page.svelte new file mode 100644 index 0000000..ce9a601 --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/collectives/+page.svelte @@ -0,0 +1,300 @@ + + +
+
+

+ {m.admin_collectives_title()} +

+ void load()} + placeholder={m.admin_collectives_search_placeholder()} + class="ml-auto w-72 rounded-md border border-slate-200 bg-surface-raised px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-400 focus:outline-none dark:border-slate-800 dark:text-slate-50 dark:placeholder:text-slate-500" + data-testid="admin-collectives-search" + /> +
+ + {#if error} + + {/if} + + {#if loading} +

{m.loading()}

+ {:else if rows.length === 0} +

+ {m.admin_collectives_empty()} +

+ {:else} +
+ + + + + + + + + + + + {#each rows as row (row.id)} + + + + + + + + {/each} + +
{m.admin_collectives_col_name()}{m.admin_collectives_col_members()}{m.admin_collectives_col_created()}{m.admin_collectives_col_status()}
+ {row.emoji} + + {row.name} + + {row.member_count}{formatDate(row.created_at)} + {#if row.deleted_at} + + {m.admin_collectives_status_deleted()} + + {:else} + + {m.admin_collectives_status_active()} + + {/if} + +
+ {#if row.deleted_at} + + {:else} + + {/if} + +
+
+
+ {/if} +
+ +{#if softDeleteTarget} +
+ +
+{/if} + +{#if restoreTarget} +
+ +
+{/if} + +{#if hardDeleteTarget} +
+ +
+{/if} diff --git a/apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte b/apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte new file mode 100644 index 0000000..fd2ecbb --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte @@ -0,0 +1,219 @@ + + +
+ + + {m.admin_collective_detail_back()} + + + {#if loading} +

{m.loading()}

+ {:else if !collective} +

{m.error_generic()}

+ {:else} +
+

+ {collective.emoji} + {collective.name} +

+ {#if collective.deleted_at} +

+ {m.admin_collectives_status_deleted()} — {new Date(collective.deleted_at).toLocaleString()} +

+ {/if} +
+ + {#if error} + + {/if} + +

+ {m.admin_collective_detail_members()} +

+
    + {#each members as member (member.user_id)} +
  • + +
    +

    {member.display_name}

    +

    {member.email}

    +
    + {member.role} + +
  • + {/each} +
+ +

+ {m.admin_collective_detail_recent_actions()} +

+ {#if actions.length === 0} +

{m.admin_audit_empty()}

+ {:else} +
    + {#each actions as action (action.id)} +
  • +

    {new Date(action.created_at).toLocaleString()}

    +

    {action.action}

    + {#if action.payload && Object.keys(action.payload).length > 0} +
    {JSON.stringify(action.payload, null, 2)}
    + {/if} +
  • + {/each} +
+ {/if} + {/if} +
+ +{#if removeTarget} +
+ +
+{/if} diff --git a/apps/web/src/routes/(admin)/admin/server/+page.svelte b/apps/web/src/routes/(admin)/admin/server/+page.svelte new file mode 100644 index 0000000..bee7e02 --- /dev/null +++ b/apps/web/src/routes/(admin)/admin/server/+page.svelte @@ -0,0 +1,130 @@ + + +
+

{m.admin_server_title()}

+ + {#if error} + + {/if} + +

+ {m.admin_server_default_sections()} +

+

{m.admin_server_default_sections_help()}

+ + {#if loading} +

{m.loading()}

+ {:else} +
    + {#each SECTION_KEYS as section (section)} + {@const state = defaultSections[section]} +
  • + {labelFor(section)} + + {state === 'on' ? m.admin_server_section_state_on() : state === 'off' ? m.admin_server_section_state_off() : m.admin_server_section_state_unset()} + + + +
  • + {/each} +
+ {/if} +
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index f6f42e1..55af756 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -11,6 +11,7 @@ subscribeCollectiveFeatures, teardownFeatureSubscriptions } from '$lib/stores/features'; + import { refreshServerAdminFlag, clearServerAdminFlag } from '$lib/stores/serverAdmin'; import type { FeatureFlags } from '@colectivo/types'; import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { i18n } from '$lib/i18n'; @@ -47,6 +48,10 @@ currentUser.set(session?.user ?? null); authLoading.set(false); + if (!session) { + clearServerAdminFlag(); + } + if (session) { // Defer out of the onAuthStateChange callback so the internal auth // lock is fully released before we make any PostgREST query. @@ -64,6 +69,11 @@ /* non-fatal */ } + // Fase 13.4: refresh the cached `is_server_admin()` flag. + // Best-effort — a failure leaves the flag false, which is + // the safe default (admin area is gated server-side too). + await refreshServerAdminFlag(); + // Fase 10.8: auto-detect language for genuinely new users. // Best-effort, runs after every auth event so newly-seeded // (post-Keycloak-self-registration) rows pick up the user's @@ -106,6 +116,7 @@ subscription.unsubscribe(); collectiveUnsub(); void teardownFeatureSubscriptions(); + clearServerAdminFlag(); }; }); diff --git a/apps/web/tests/e2e/admin.test.ts b/apps/web/tests/e2e/admin.test.ts new file mode 100644 index 0000000..16270ef --- /dev/null +++ b/apps/web/tests/e2e/admin.test.ts @@ -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(); +const extraAdminUserIds = new Set([CARMEN_ID]); // Carmen may be promoted in SA-03 + +async function withAnaPage(browser: Browser, fn: (page: Page) => Promise): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/apps/web/tests/e2e/section-visibility.test.ts b/apps/web/tests/e2e/section-visibility.test.ts index eee8db2..eee8232 100644 --- a/apps/web/tests/e2e/section-visibility.test.ts +++ b/apps/web/tests/e2e/section-visibility.test.ts @@ -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 { diff --git a/apps/web/tests/fixtures/admin-login.ts b/apps/web/tests/fixtures/admin-login.ts new file mode 100644 index 0000000..7db6f75 --- /dev/null +++ b/apps/web/tests/fixtures/admin-login.ts @@ -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 { + 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 + }); +} diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 217615c..fc32a2e 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -614,6 +614,10 @@ export interface Database { Args: { p_section: string; p_enabled: boolean }; Returns: void; }; + admin_clear_default_section: { + Args: { p_section: string }; + Returns: void; + }; }; Enums: { language_code: LanguageCode; diff --git a/supabase/migrations/025_admin_rpcs.sql b/supabase/migrations/025_admin_rpcs.sql index a02e71b..224f86b 100644 --- a/supabase/migrations/025_admin_rpcs.sql +++ b/supabase/migrations/025_admin_rpcs.sql @@ -511,3 +511,48 @@ $$; REVOKE ALL ON FUNCTION public.admin_set_default_section(text, boolean) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.admin_set_default_section(text, boolean) TO authenticated; + +-- ── admin_clear_default_section ───────────────────────────────────────────── +-- Removes a section key from the server-layer JSONB, restoring "no opinion" +-- (fall through to collective → user → default true). Needed because patching +-- the value to `true` is NOT semantically equivalent to "no opinion" — `true` +-- explicitly overrides a collective OFF. +CREATE OR REPLACE FUNCTION public.admin_clear_default_section(p_section text) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); + v_next jsonb; +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_section IS NULL OR p_section = '' THEN + RAISE EXCEPTION 'section_required' USING ERRCODE = '22023'; + END IF; + + UPDATE public.server_settings + SET value = value - p_section, + updated_by = v_actor, + updated_at = now() + WHERE key = 'default_sections' + RETURNING value INTO v_next; + + PERFORM public._log_admin_action( + v_actor, + 'clear_default_section', + 'server_setting', + NULL, + jsonb_build_object('section', p_section, 'value_after', v_next) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_clear_default_section(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_clear_default_section(text) TO authenticated; diff --git a/supabase/tests/017_admin_rpcs.sql b/supabase/tests/017_admin_rpcs.sql index e5b6351..27e3659 100644 --- a/supabase/tests/017_admin_rpcs.sql +++ b/supabase/tests/017_admin_rpcs.sql @@ -10,7 +10,7 @@ CREATE EXTENSION IF NOT EXISTS pgtap; BEGIN; -SELECT plan(22); +SELECT plan(23); -- ── server_settings table ────────────────────────────────────────────────── @@ -87,6 +87,11 @@ SELECT has_function( 'AR-T13: admin_set_default_section(text, boolean) exists' ); +SELECT has_function( + 'public', 'admin_clear_default_section', ARRAY['text'], + 'AR-T13b: admin_clear_default_section(text) exists' +); + -- Every admin_* function plus grant/revoke MUST be SECURITY DEFINER so the -- `if not is_server_admin() then raise` guard is the gate, not RLS. SELECT results_eq( @@ -96,6 +101,7 @@ SELECT results_eq( AND prosecdef = true ORDER BY proname COLLATE "C" $$, $$ VALUES + ('admin_clear_default_section'::text COLLATE "C"), ('admin_hard_delete_collective'::text COLLATE "C"), ('admin_list_collectives'::text COLLATE "C"), ('admin_remove_member'::text COLLATE "C"),