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}
+ {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}
+
+
+
{m.admin_admins_promote()}
+
{m.admin_admins_promote_help()}
+
+ {#if promoteError}
+
{promoteError}
+ {/if}
+
+
+
+
+
+
+{/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 @@
+
+
+
+
+
+ {#if error}
+ {error}
+ {/if}
+
+ {#if loading}
+ {m.loading()}
+ {:else if rows.length === 0}
+ {m.admin_audit_empty()}
+ {:else}
+
+
+
+
+ | {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()} |
+
+
+
+ {#each rows as row (row.id)}
+
+ | {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)}
+ |
+
+ {/each}
+
+
+
+ {/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 @@
+
+
+
+
+
+ {#if error}
+
+ {error}
+
+ {/if}
+
+ {#if loading}
+ {m.loading()}
+ {:else if rows.length === 0}
+
+ {m.admin_collectives_empty()}
+
+ {:else}
+
+
+
+
+ | {m.admin_collectives_col_name()} |
+ {m.admin_collectives_col_members()} |
+ {m.admin_collectives_col_created()} |
+ {m.admin_collectives_col_status()} |
+ |
+
+
+
+ {#each rows as row (row.id)}
+
+ |
+ {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}
+
+
+ |
+
+ {/each}
+
+
+
+ {/if}
+
+
+{#if softDeleteTarget}
+
+
+
{m.admin_modal_soft_delete_title()}
+
{m.admin_modal_soft_delete_help()}
+
+
+
+
+
+
+
+{/if}
+
+{#if restoreTarget}
+
+
+
{m.admin_action_restore()}
+
{restoreTarget.name}
+
+
+
+
+
+
+{/if}
+
+{#if hardDeleteTarget}
+
+
+
{m.admin_modal_hard_delete_title()}
+
{m.admin_modal_hard_delete_help()}
+
+
+
+
+
+
+
+
+{/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}
+
+
+ {#if error}
+ {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}
+
+
+
{m.admin_modal_remove_member_title()}
+
{removeTarget.display_name} ({removeTarget.email})
+
{m.admin_modal_remove_member_help()}
+
+
+
+
+
+
+
+{/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}
+ {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"),