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:
2026-05-18 05:30:56 +02:00
parent 7556d77bf8
commit 27afda74f1
21 changed files with 1726 additions and 11 deletions

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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 @@
</nav>
<div class="p-3 pt-2">
{#if $isServerAdmin}
<!-- Fase 13.4: server-admin entry. Distinct red icon so it doesn't
blend with normal app nav. Hidden for non-admins. -->
<a
href="/admin"
data-testid="sidebar-admin-link"
class="mb-2 flex items-center gap-2 rounded-md border border-red-200 bg-red-50/50 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-950/40"
>
<Shield size={14} strokeWidth={1.5} />
{m.admin_menu_entry()}
</a>
{/if}
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<Avatar name={$displayName} size={32} />

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { currentCollective, userCollectives } from '$lib/stores/collective';
import { displayName } from '$lib/stores/auth';
import { isServerAdmin } from '$lib/stores/serverAdmin';
import Avatar from '$lib/components/Avatar.svelte';
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
import { logout } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
import { Settings, Users, LogOut, X, Plus } from 'lucide-svelte';
import { Settings, Users, LogOut, X, Plus, Shield } from 'lucide-svelte';
let showCreate = $state(false);
@@ -98,6 +99,18 @@
<Avatar name={$displayName} size={28} />
<span class="truncate text-sm font-medium text-text-primary">{$displayName}</span>
</div>
{#if $isServerAdmin}
<!-- Fase 13.4: server-admin entry. Same gating as DesktopSidebar. -->
<a
href="/admin"
onclick={close}
data-testid="drawer-admin-link"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/40"
>
<Shield size={14} strokeWidth={1.5} />
{m.admin_menu_entry()}
</a>
{/if}
<a
href="/settings"
onclick={close}

View File

@@ -42,26 +42,38 @@ import { currentCollective } from '$lib/stores/collective';
*/
export const currentUserFeatures = writable<{ feature_flags: FeatureFlags } | null>(null);
/**
* Server-layer overrides (Fase 13). Mirrors
* `public.server_settings.value WHERE key = 'default_sections'`. Loaded on
* sign-in and refreshed (no realtime — promote/revoke at this layer is rare
* and a reload is acceptable; a future migration can put server_settings in
* the realtime publication if needed).
*/
export const serverSectionDefaults = writable<FeatureFlags>({});
/**
* Derived map { section → boolean } applying the same precedence as
* `public.section_enabled()`. Layers, MOST restrictive wins:
* 1. collective.feature_flags[section] (admin override)
* 2. user.feature_flags[section] (per-user override)
* 3. default true
* 1. server.default_sections[section] (operator override)
* 2. collective.feature_flags[section] (admin override)
* 3. user.feature_flags[section] (per-user override)
* 4. default true
*
* Unknown section keys are not part of SECTION_KEYS so they are never queried
* here — that path is reserved for SQL-side callers (migrations 13+).
*/
export const enabledSections = derived(
[currentUserFeatures, currentCollective],
([$user, $collective]) => {
[currentUserFeatures, currentCollective, serverSectionDefaults],
([$user, $collective, $server]) => {
const userFlags = ($user?.feature_flags ?? {}) as FeatureFlags;
const collectiveFlags = ($collective?.feature_flags ?? {}) as FeatureFlags;
const serverFlags = ($server ?? {}) as FeatureFlags;
const out: Record<SectionKey, boolean> = {} as Record<SectionKey, boolean>;
for (const key of SECTION_KEYS) {
const serverVal = serverFlags[key];
const collectiveVal = collectiveFlags[key];
const userVal = userFlags[key];
out[key] = collectiveVal ?? userVal ?? true;
out[key] = serverVal ?? collectiveVal ?? userVal ?? true;
}
return out;
}
@@ -173,6 +185,20 @@ export async function loadCurrentUserFeatures(userId: string): Promise<void> {
feature_flags: ((data?.feature_flags ?? {}) as FeatureFlags) || {}
});
// Fase 13: also fetch the server-layer defaults. Read-allowed to everyone
// by `select_all` policy on server_settings. Failure is non-fatal — the
// derived store falls through to collective/user/default.
try {
const { data: ssRow } = await supabase
.from('server_settings')
.select('value')
.eq('key', 'default_sections')
.maybeSingle();
serverSectionDefaults.set(((ssRow?.value ?? {}) as FeatureFlags) || {});
} catch {
/* non-fatal */
}
if (userChannelUserId !== userId) {
await teardownUserChannel();
userChannelUserId = userId;
@@ -238,6 +264,7 @@ export async function teardownFeatureSubscriptions(): Promise<void> {
await teardownUserChannel();
await teardownCollectiveChannel();
currentUserFeatures.set(null);
serverSectionDefaults.set({});
}
async function teardownUserChannel(): Promise<void> {

View File

@@ -0,0 +1,57 @@
/**
* Server admin flag store (Fase 13.4).
*
* `is_server_admin()` is a cheap STABLE SECURITY DEFINER RPC; we cache its
* result client-side so the sidebar entry, route guards, and any other
* call-site can read `$isServerAdmin` synchronously without round-trip.
*
* Refresh hooks live in the root `+layout.svelte` — see the
* `onAuthStateChange` block where we re-run `refreshServerAdminFlag()` on
* INITIAL_SESSION / SIGNED_IN / TOKEN_REFRESHED. There is no realtime
* subscription: promote/revoke is rare and the flag stays accurate until the
* next token refresh (~1 hour) which is acceptable for an operator-facing
* surface. A user who is freshly granted can also just reload the page.
*/
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
export const isServerAdmin = writable<boolean>(false);
/**
* Has the flag been resolved at least once for the current session? Tracks
* the distinction between "definitely not admin" (false + loaded=true) and
* "we don't know yet" (false + loaded=false). The (admin) layout uses this
* to avoid redirecting away from /admin during the millisecond between the
* SIGNED_IN event firing and the RPC resolving. Without this guard, a hard
* navigation to /admin lands the user back at / every time.
*/
export const isServerAdminLoaded = writable<boolean>(false);
/**
* Re-read `is_server_admin()` and update the store. Call from the root layout
* after the auth listener fires so the flag follows the user's session.
*
* Defensive: any error (network, RLS, missing function on an older deploy)
* sets the flag to false rather than throwing — the admin area is gated by a
* second server-side check (the RPC guard), so a stale `false` is the safe
* default.
*/
export async function refreshServerAdminFlag(): Promise<void> {
try {
const { data, error } = await getSupabase().rpc('is_server_admin', {});
if (error) {
isServerAdmin.set(false);
return;
}
isServerAdmin.set(data === true);
} catch {
isServerAdmin.set(false);
} finally {
isServerAdminLoaded.set(true);
}
}
export function clearServerAdminFlag(): void {
isServerAdmin.set(false);
isServerAdminLoaded.set(false);
}

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { isAuthenticated, authLoading } from '$lib/stores/auth';
import { isServerAdmin, isServerAdminLoaded } from '$lib/stores/serverAdmin';
import { login, isLoggingOut } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
import { Shield, Database, Users, ScrollText, Settings } from 'lucide-svelte';
let { children }: { children: Snippet } = $props();
// Mirror the (app) group's auth-gate effect so an unauthenticated user
// hitting /admin/* gets bounced through Keycloak instead of seeing a
// blank screen. `isLoggingOut` suppresses re-login while logout is in
// flight (same gotcha documented in CLAUDE.md).
$effect(() => {
if (!$authLoading && !$isAuthenticated && !isLoggingOut) {
login();
}
});
// 13.3.1 client-side gate. The RPCs are also gated server-side
// (`if not is_server_admin() then raise 'forbidden'`), but redirecting
// non-admins to / instead of letting them see a half-rendered admin
// shell is the obvious UX choice.
//
// Wait for `isServerAdminLoaded` before judging — on a cold hit to
// /admin/* the auth listener fires SIGNED_IN, then refreshServerAdminFlag
// runs async. Without this gate, the effect read `$isServerAdmin = false`
// (the writable's initial value) and redirected away every time.
$effect(() => {
if (
!$authLoading &&
$isAuthenticated &&
$isServerAdminLoaded &&
!$isServerAdmin
) {
void goto('/');
}
});
const navItems = [
{ href: '/admin/collectives', label: () => m.admin_nav_collectives(), icon: Database },
{ href: '/admin/admins', label: () => m.admin_nav_admins(), icon: Users },
{ href: '/admin/audit', label: () => m.admin_nav_audit(), icon: ScrollText },
{ href: '/admin/server', label: () => m.admin_nav_server(), icon: Settings }
];
const activePath = $derived($page.url.pathname);
</script>
{#if $authLoading || ($isAuthenticated && !$isServerAdminLoaded)}
<div class="flex h-screen items-center justify-center bg-background">
<span class="text-sm text-text-secondary">{m.loading()}</span>
</div>
{:else if $isAuthenticated && $isServerAdmin}
<div class="flex h-screen flex-col overflow-hidden bg-background">
<!-- Red banner: this is the visual signal that you are NOT in the
normal user shell. Plain HTML, no animation — operator UI. -->
<div
data-testid="admin-banner"
class="flex h-10 flex-shrink-0 items-center justify-center bg-red-600 px-4 text-sm font-semibold text-white"
>
<Shield size={14} strokeWidth={2} class="mr-2" />
{m.admin_banner()}
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<aside
data-testid="admin-sidebar"
class="hidden w-56 flex-shrink-0 flex-col bg-surface-raised md:flex"
>
<div class="flex h-14 items-center px-4 text-sm font-semibold text-slate-900 dark:text-slate-50">
/admin
</div>
<nav class="flex-1 overflow-y-auto px-2 py-3">
{#each navItems as { href, label, icon: Icon } (href)}
<a
{href}
data-testid={`admin-nav-${href.split('/').pop()}`}
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath === href || activePath.startsWith(href + '/') ? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
>
<Icon size={16} strokeWidth={1.5} />
{label()}
</a>
{/each}
</nav>
<div class="p-3">
<a
href="/"
class="block rounded-md px-3 py-2 text-xs text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50"
>
{m.app_name()}
</a>
</div>
</aside>
<!-- Main -->
<main class="flex flex-1 flex-col overflow-y-auto bg-background">
{@render children()}
</main>
</div>
</div>
{/if}

View File

@@ -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 {};
}

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// Default landing — collectives is the most-used surface, no extra clicks.
onMount(() => {
void goto('/admin/collectives', { replaceState: true });
});
</script>
<div></div>

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import * as m from '$lib/paraglide/messages';
import { UserPlus, UserX } from 'lucide-svelte';
type AdminRow = {
user_id: string;
granted_at: string;
display_name: string;
email: string;
};
let admins = $state<AdminRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let showPromote = $state(false);
let promoteEmail = $state('');
let promoteError = $state<string | null>(null);
let busy = $state(false);
const totalAdmins = $derived(admins.length);
onMount(() => {
void load();
});
async function load() {
loading = true;
error = null;
// Disambiguate the FK: server_admins has TWO FKs to users (user_id +
// granted_by); PostgREST refuses to embed without an explicit fk name.
const { data, error: fetchErr } = await getSupabase()
.from('server_admins')
.select('user_id, granted_at, users!server_admins_user_id_fkey(display_name, email)')
.order('granted_at');
if (fetchErr) {
error = fetchErr.message;
loading = false;
return;
}
admins = ((data ?? []) as unknown as { user_id: string; granted_at: string; users: { display_name: string; email: string } | null }[]).map((r) => ({
user_id: r.user_id,
granted_at: r.granted_at,
display_name: r.users?.display_name ?? '',
email: r.users?.email ?? ''
}));
loading = false;
}
async function confirmPromote() {
promoteError = null;
const target = promoteEmail.trim().toLowerCase();
if (!target) return;
busy = true;
// Resolve the email to a user id via the public.users table. RLS on
// public.users (Fase 3) lets any authenticated read by id but not by
// email arbitrarily; admins can read everyone because the SELECT
// policy is `true` per migration 003. If that ever tightens this
// becomes a SECURITY DEFINER lookup.
const sb = getSupabase();
const { data: matches, error: lookupErr } = await sb
.from('users')
.select('id')
.eq('email', target)
.limit(1);
if (lookupErr) {
promoteError = lookupErr.message;
busy = false;
return;
}
const userId = matches?.[0]?.id;
if (!userId) {
promoteError = m.admin_admins_email_not_found();
busy = false;
return;
}
const { error: rpcErr } = await sb.rpc('grant_server_admin', { p_user: userId });
busy = false;
if (rpcErr) {
promoteError = rpcErr.message;
return;
}
showPromote = false;
promoteEmail = '';
await load();
}
async function revoke(userId: string) {
busy = true;
const { error: rpcErr } = await getSupabase().rpc('revoke_server_admin', { p_user: userId });
busy = false;
if (rpcErr) {
error = rpcErr.message;
return;
}
await load();
}
</script>
<section class="flex flex-1 flex-col p-6">
<header class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_admins_title()}</h1>
<button
type="button"
onclick={() => {
showPromote = true;
promoteEmail = '';
promoteError = null;
}}
class="ml-auto inline-flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
data-testid="admin-promote-open"
>
<UserPlus size={14} strokeWidth={1.5} />
{m.admin_admins_promote()}
</button>
</header>
{#if error}
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
{/if}
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else}
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-admins-list">
{#each admins as a (a.user_id)}
<li class="flex items-center gap-3 px-3 py-2.5 text-sm" data-testid={`admin-admin-row-${a.user_id}`}>
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-900 dark:text-slate-50">{a.display_name || a.email}</p>
<p class="text-xs text-text-secondary">{a.email}</p>
</div>
{#if a.user_id === $currentUser?.id && totalAdmins <= 1}
<span class="text-xs italic text-text-secondary">{m.admin_admins_revoke_self()}</span>
{:else}
<button
type="button"
disabled={busy}
onclick={() => revoke(a.user_id)}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-red-700 disabled:opacity-50 dark:hover:bg-white/5"
data-testid={`admin-revoke-${a.user_id}`}
>
<UserX size={12} strokeWidth={1.5} />
{m.admin_admins_revoke()}
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{#if showPromote}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-promote-modal">
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_admins_promote()}</h2>
<p class="mb-3 text-sm text-text-secondary">{m.admin_admins_promote_help()}</p>
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.admin_admins_email_label()}
<input
type="email"
bind:value={promoteEmail}
placeholder={m.admin_admins_email_placeholder()}
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
data-testid="admin-promote-email"
/>
</label>
{#if promoteError}
<p class="mb-3 text-xs text-red-700 dark:text-red-300" role="alert">{promoteError}</p>
{/if}
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (showPromote = false)} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
<button type="button" disabled={!promoteEmail.trim() || busy} onclick={confirmPromote} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import * as m from '$lib/paraglide/messages';
type Row = {
id: string;
actor_id: string;
action: string;
target_type: string;
target_id: string | null;
payload: Record<string, unknown>;
created_at: string;
actor_name: string;
actor_email: string;
};
let rows = $state<Row[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filterAction = $state('');
let limit = $state(50);
onMount(() => {
void load();
});
async function load() {
loading = true;
error = null;
let query = getSupabase()
.from('admin_actions')
.select('id, actor_id, action, target_type, target_id, payload, created_at, users!actor_id(display_name, email)')
.order('created_at', { ascending: false })
.limit(limit);
if (filterAction.trim()) {
query = query.ilike('action', `%${filterAction.trim()}%`);
}
const { data, error: fetchErr } = await query;
if (fetchErr) {
error = fetchErr.message;
loading = false;
return;
}
rows = ((data ?? []) as unknown as (Omit<Row, 'actor_name' | 'actor_email'> & { users: { display_name: string; email: string } | null })[]).map((r) => ({
id: r.id,
actor_id: r.actor_id,
action: r.action,
target_type: r.target_type,
target_id: r.target_id,
payload: (r.payload as Record<string, unknown>) ?? {},
created_at: r.created_at,
actor_name: r.users?.display_name ?? r.actor_id,
actor_email: r.users?.email ?? ''
}));
loading = false;
}
</script>
<section class="flex flex-1 flex-col p-6">
<header class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_audit_title()}</h1>
<input
type="search"
bind:value={filterAction}
oninput={() => 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"
/>
</header>
{#if error}
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
{/if}
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if rows.length === 0}
<p class="text-sm text-text-secondary" data-testid="admin-audit-empty">{m.admin_audit_empty()}</p>
{:else}
<div class="overflow-x-auto rounded-md border border-slate-200 dark:border-slate-800">
<table class="w-full text-left text-sm" data-testid="admin-audit-table">
<thead class="bg-slate-50 dark:bg-slate-900/30">
<tr>
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_when()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_actor()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_action()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_target()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_payload()}</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
<tr class="border-t border-slate-100 align-top dark:border-slate-800/50">
<td class="whitespace-nowrap px-3 py-2 text-xs text-text-secondary">{new Date(row.created_at).toLocaleString()}</td>
<td class="px-3 py-2 text-text-secondary">{row.actor_name}</td>
<td class="px-3 py-2 font-medium text-slate-900 dark:text-slate-50">{row.action}</td>
<td class="px-3 py-2 text-text-secondary">
<span class="text-xs">{row.target_type}</span>
{#if row.target_id}
<br /><span class="font-mono text-[10px] text-text-secondary">{row.target_id}</span>
{/if}
</td>
<td class="px-3 py-2">
<pre class="max-w-md overflow-x-auto rounded bg-slate-50 px-2 py-1 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">{JSON.stringify(row.payload, null, 2)}</pre>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import * as m from '$lib/paraglide/messages';
import { RotateCcw, Trash2 } from 'lucide-svelte';
type Row = {
id: string;
name: string;
emoji: string;
member_count: number;
created_at: string;
deleted_at: string | null;
};
let rows = $state<Row[]>([]);
let search = $state('');
let loading = $state(true);
let error = $state<string | null>(null);
// Modal state
let softDeleteTarget = $state<Row | null>(null);
let restoreTarget = $state<Row | null>(null);
let hardDeleteTarget = $state<Row | null>(null);
let modalReason = $state('');
let modalConfirm = $state(false);
let modalForce = $state(false);
let busy = $state(false);
onMount(() => {
void load();
});
async function load() {
loading = true;
error = null;
const { data, error: rpcErr } = await getSupabase().rpc('admin_list_collectives', {
p_search: search.trim() || null,
p_limit: 200
});
if (rpcErr) {
error = rpcErr.message;
loading = false;
return;
}
rows = ((data ?? []) as unknown as Row[]).map((r) => ({
...r,
member_count: Number(r.member_count)
}));
loading = false;
}
function startSoftDelete(row: Row) {
softDeleteTarget = row;
modalReason = '';
}
function startRestore(row: Row) {
restoreTarget = row;
}
function startHardDelete(row: Row) {
hardDeleteTarget = row;
modalConfirm = false;
modalForce = false;
}
function closeModal() {
softDeleteTarget = null;
restoreTarget = null;
hardDeleteTarget = null;
modalReason = '';
modalConfirm = false;
modalForce = false;
}
async function confirmSoftDelete() {
if (!softDeleteTarget || !modalReason.trim()) return;
busy = true;
const id = softDeleteTarget.id;
const reason = modalReason.trim();
const { error: rpcErr } = await getSupabase().rpc('admin_soft_delete_collective', {
p_collective_id: id,
p_reason: reason
});
busy = false;
if (rpcErr) {
error = rpcErr.message;
return;
}
closeModal();
await load();
}
async function confirmRestore() {
if (!restoreTarget) return;
busy = true;
const id = restoreTarget.id;
const { error: rpcErr } = await getSupabase().rpc('admin_restore_collective', {
p_collective_id: id
});
busy = false;
if (rpcErr) {
error = rpcErr.message;
return;
}
closeModal();
await load();
}
async function confirmHardDelete() {
if (!hardDeleteTarget || !modalConfirm) return;
busy = true;
const id = hardDeleteTarget.id;
const { error: rpcErr } = await getSupabase().rpc('admin_hard_delete_collective', {
p_collective_id: id,
p_force: modalForce
});
busy = false;
if (rpcErr) {
error = rpcErr.message;
return;
}
closeModal();
await load();
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
</script>
<section class="flex flex-1 flex-col p-6">
<header class="mb-4 flex flex-wrap items-center gap-4">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">
{m.admin_collectives_title()}
</h1>
<input
type="search"
bind:value={search}
oninput={() => 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"
/>
</header>
{#if error}
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">
{error}
</div>
{/if}
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if rows.length === 0}
<p class="text-sm text-text-secondary" data-testid="admin-collectives-empty">
{m.admin_collectives_empty()}
</p>
{:else}
<div class="overflow-x-auto rounded-md border border-slate-200 dark:border-slate-800">
<table class="w-full text-left text-sm" data-testid="admin-collectives-table">
<thead class="bg-slate-50 dark:bg-slate-900/30">
<tr>
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_name()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_members()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_created()}</th>
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_status()}</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
<tr
class="border-t border-slate-100 dark:border-slate-800/50"
data-testid={`admin-collective-row-${row.id}`}
>
<td class="px-3 py-2">
<span class="mr-1">{row.emoji}</span>
<a
href={`/admin/collectives/${row.id}`}
class="font-medium text-slate-900 hover:underline dark:text-slate-50"
>
{row.name}
</a>
</td>
<td class="px-3 py-2 text-text-secondary">{row.member_count}</td>
<td class="px-3 py-2 text-text-secondary">{formatDate(row.created_at)}</td>
<td class="px-3 py-2">
{#if row.deleted_at}
<span
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-950/40 dark:text-red-300"
data-testid={`admin-collective-status-${row.id}`}
>
{m.admin_collectives_status_deleted()}
</span>
{:else}
<span
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-950/40 dark:text-green-300"
data-testid={`admin-collective-status-${row.id}`}
>
{m.admin_collectives_status_active()}
</span>
{/if}
</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end gap-1">
{#if row.deleted_at}
<button
type="button"
onclick={() => startRestore(row)}
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-green-700 dark:hover:bg-white/5"
title={m.admin_action_restore()}
data-testid={`admin-restore-${row.id}`}
>
<RotateCcw size={14} strokeWidth={1.5} />
</button>
{:else}
<button
type="button"
onclick={() => startSoftDelete(row)}
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-amber-700 dark:hover:bg-white/5"
title={m.admin_action_soft_delete()}
data-testid={`admin-soft-delete-${row.id}`}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
{/if}
<button
type="button"
onclick={() => startHardDelete(row)}
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-red-700 dark:hover:bg-white/5"
title={m.admin_action_hard_delete()}
data-testid={`admin-hard-delete-${row.id}`}
>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{#if softDeleteTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-soft-delete-modal">
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_soft_delete_title()}</h2>
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_soft_delete_help()}</p>
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.admin_modal_reason_label()}
<input
type="text"
bind:value={modalReason}
placeholder={m.admin_modal_reason_placeholder()}
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
data-testid="admin-modal-reason"
/>
</label>
<div class="flex justify-end gap-2">
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
<button type="button" disabled={!modalReason.trim() || busy} onclick={confirmSoftDelete} class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
</div>
</div>
</div>
{/if}
{#if restoreTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-restore-modal">
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_action_restore()}</h2>
<p class="mb-4 text-sm text-text-secondary">{restoreTarget.name}</p>
<div class="flex justify-end gap-2">
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
<button type="button" disabled={busy} onclick={confirmRestore} class="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
</div>
</div>
</div>
{/if}
{#if hardDeleteTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-hard-delete-modal">
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_hard_delete_title()}</h2>
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_hard_delete_help()}</p>
<label class="mb-2 flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" bind:checked={modalConfirm} data-testid="admin-modal-confirm-checkbox" />
<span>{m.admin_modal_hard_delete_confirm()}</span>
</label>
<label class="mb-4 flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" bind:checked={modalForce} data-testid="admin-modal-force-checkbox" />
<span>{m.admin_modal_hard_delete_force_label()}</span>
</label>
<div class="flex justify-end gap-2">
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
<button type="button" disabled={!modalConfirm || busy} onclick={confirmHardDelete} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { getSupabase } from '$lib/supabase';
import Avatar from '$lib/components/Avatar.svelte';
import * as m from '$lib/paraglide/messages';
import { ArrowLeft, UserMinus } from 'lucide-svelte';
type Member = {
user_id: string;
role: 'admin' | 'member' | 'guest';
joined_at: string;
display_name: string;
email: string;
};
type Action = {
id: string;
actor_id: string;
action: string;
target_type: string;
target_id: string | null;
payload: Record<string, unknown>;
created_at: string;
};
type CollectiveSummary = {
id: string;
name: string;
emoji: string;
created_at: string;
deleted_at: string | null;
};
// SvelteKit types `params` as `Record<string, string>` but TS still flags
// `params.id` as possibly undefined when accessing dynamic keys. The
// route file name guarantees presence, so coerce.
const id = $derived($page.params.id as string);
let collective = $state<CollectiveSummary | null>(null);
let members = $state<Member[]>([]);
let actions = $state<Action[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let removeTarget = $state<Member | null>(null);
let removeReason = $state('');
let busy = $state(false);
onMount(() => {
void load();
});
async function load() {
loading = true;
error = null;
const sb = getSupabase();
const [{ data: cData, error: cErr }, { data: mData, error: mErr }, { data: aData, error: aErr }] = await Promise.all([
sb.from('collectives').select('id, name, emoji, created_at, deleted_at').eq('id', id).maybeSingle(),
sb
.from('collective_members')
.select('user_id, role, joined_at, users(display_name, email)')
.eq('collective_id', id)
.order('joined_at'),
sb
.from('admin_actions')
.select('id, actor_id, action, target_type, target_id, payload, created_at')
.eq('target_id', id)
.order('created_at', { ascending: false })
.limit(50)
]);
if (cErr) error = cErr.message;
else if (mErr) error = mErr.message;
else if (aErr) error = aErr.message;
collective = (cData as CollectiveSummary | null) ?? null;
members = ((mData ?? []) as unknown as { user_id: string; role: Member['role']; joined_at: string; users: { display_name: string; email: string } | null }[])
.map((row) => ({
user_id: row.user_id,
role: row.role,
joined_at: row.joined_at,
display_name: row.users?.display_name ?? '',
email: row.users?.email ?? ''
}));
actions = ((aData ?? []) as unknown as Action[]) ?? [];
loading = false;
}
function startRemove(member: Member) {
removeTarget = member;
removeReason = '';
}
function closeModal() {
removeTarget = null;
removeReason = '';
}
async function confirmRemove() {
if (!removeTarget || !removeReason.trim()) return;
busy = true;
const reason = removeReason.trim();
const userId = removeTarget.user_id;
const { error: rpcErr } = await getSupabase().rpc('admin_remove_member', {
p_collective_id: id,
p_user: userId,
p_reason: reason
});
busy = false;
if (rpcErr) {
error = rpcErr.message;
return;
}
closeModal();
await load();
}
</script>
<section class="flex flex-1 flex-col p-6">
<a
href="/admin/collectives"
class="mb-3 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-slate-900 dark:hover:text-slate-50"
>
<ArrowLeft size={14} strokeWidth={1.5} />
{m.admin_collective_detail_back()}
</a>
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if !collective}
<p class="text-sm text-text-secondary">{m.error_generic()}</p>
{:else}
<header class="mb-6">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">
<span class="mr-2">{collective.emoji}</span>
{collective.name}
</h1>
{#if collective.deleted_at}
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
{m.admin_collectives_status_deleted()}{new Date(collective.deleted_at).toLocaleString()}
</p>
{/if}
</header>
{#if error}
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
{/if}
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-secondary">
{m.admin_collective_detail_members()}
</h2>
<ul class="mb-8 divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-detail-members">
{#each members as member (member.user_id)}
<li class="flex items-center gap-3 px-3 py-2.5 text-sm">
<Avatar name={member.display_name} size={28} />
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-900 dark:text-slate-50">{member.display_name}</p>
<p class="truncate text-xs text-text-secondary">{member.email}</p>
</div>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">{member.role}</span>
<button
type="button"
onclick={() => startRemove(member)}
class="rounded-md p-1.5 text-slate-400 hover:bg-black/5 hover:text-red-700 dark:hover:bg-white/5"
title={m.admin_action_remove()}
data-testid={`admin-remove-member-${member.user_id}`}
>
<UserMinus size={14} strokeWidth={1.5} />
</button>
</li>
{/each}
</ul>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-secondary">
{m.admin_collective_detail_recent_actions()}
</h2>
{#if actions.length === 0}
<p class="text-sm text-text-secondary">{m.admin_audit_empty()}</p>
{:else}
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 text-sm dark:divide-slate-800 dark:border-slate-800" data-testid="admin-detail-actions">
{#each actions as action (action.id)}
<li class="px-3 py-2">
<p class="text-xs text-text-secondary">{new Date(action.created_at).toLocaleString()}</p>
<p class="font-medium text-slate-900 dark:text-slate-50">{action.action}</p>
{#if action.payload && Object.keys(action.payload).length > 0}
<pre class="mt-1 overflow-x-auto rounded bg-slate-50 px-2 py-1 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">{JSON.stringify(action.payload, null, 2)}</pre>
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</section>
{#if removeTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-remove-member-modal">
<h2 class="mb-1 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_remove_member_title()}</h2>
<p class="mb-2 text-sm text-text-secondary">{removeTarget.display_name} ({removeTarget.email})</p>
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_remove_member_help()}</p>
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.admin_modal_reason_label()}
<input
type="text"
bind:value={removeReason}
placeholder={m.admin_modal_reason_placeholder()}
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
data-testid="admin-modal-reason"
/>
</label>
<div class="flex justify-end gap-2">
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
<button type="button" disabled={!removeReason.trim() || busy} onclick={confirmRemove} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { SECTION_KEYS, type SectionKey } from '@colectivo/types';
import * as m from '$lib/paraglide/messages';
type SectionState = 'on' | 'off' | 'unset';
let defaultSections = $state<Record<SectionKey, SectionState>>({} as Record<SectionKey, SectionState>);
let loading = $state(true);
let error = $state<string | null>(null);
let busy = $state<SectionKey | null>(null);
function labelFor(section: SectionKey): string {
switch (section) {
case 'lists':
return m.section_label_lists();
case 'tasks':
return m.section_label_tasks();
case 'notes':
return m.section_label_notes();
case 'search':
return m.section_label_search();
}
}
onMount(() => {
void load();
});
async function load() {
loading = true;
error = null;
const { data, error: fetchErr } = await getSupabase()
.from('server_settings')
.select('value')
.eq('key', 'default_sections')
.maybeSingle();
if (fetchErr) {
error = fetchErr.message;
loading = false;
return;
}
const value = (data?.value as Record<string, boolean>) ?? {};
const next: Record<SectionKey, SectionState> = {} as Record<SectionKey, SectionState>;
for (const key of SECTION_KEYS) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
next[key] = value[key] === true ? 'on' : 'off';
} else {
next[key] = 'unset';
}
}
defaultSections = next;
loading = false;
}
async function setSection(section: SectionKey, enabled: boolean) {
busy = section;
const { error: rpcErr } = await getSupabase().rpc('admin_set_default_section', {
p_section: section,
p_enabled: enabled
});
busy = null;
if (rpcErr) {
error = rpcErr.message;
return;
}
await load();
}
async function clearSection(section: SectionKey) {
busy = section;
// "Unset" is a payload patch that removes the key. We need a separate
// RPC for delete; not in 025 — TODO. For now, set to true (the
// default) so the layer becomes harmless. The plan §13.3.7 explicitly
// says 4 toggles, not 3-state; the "unset" rendering is a read-only
// affordance to show whether the server has an opinion at all.
await setSection(section, true);
}
</script>
<section class="flex flex-1 flex-col p-6">
<h1 class="mb-4 text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_server_title()}</h1>
{#if error}
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
{/if}
<h2 class="mb-1 mt-4 text-sm font-semibold uppercase tracking-wide text-text-secondary">
{m.admin_server_default_sections()}
</h2>
<p class="mb-3 text-xs text-text-secondary">{m.admin_server_default_sections_help()}</p>
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else}
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-server-sections">
{#each SECTION_KEYS as section (section)}
{@const state = defaultSections[section]}
<li class="flex items-center gap-3 px-3 py-2.5 text-sm" data-testid={`admin-server-section-${section}`}>
<span class="min-w-0 flex-1 font-medium text-slate-900 dark:text-slate-50">{labelFor(section)}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {state === 'on' ? 'bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-300' : state === 'off' ? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'}"
data-testid={`admin-server-section-state-${section}`}
>
{state === 'on' ? m.admin_server_section_state_on() : state === 'off' ? m.admin_server_section_state_off() : m.admin_server_section_state_unset()}
</span>
<button
type="button"
disabled={busy === section}
onclick={() => setSection(section, true)}
class="rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-green-700 disabled:opacity-50 dark:hover:bg-white/5"
data-testid={`admin-server-on-${section}`}
>
{m.admin_server_section_state_on()}
</button>
<button
type="button"
disabled={busy === section}
onclick={() => setSection(section, false)}
class="rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-red-700 disabled:opacity-50 dark:hover:bg-white/5"
data-testid={`admin-server-off-${section}`}
>
{m.admin_server_section_state_off()}
</button>
</li>
{/each}
</ul>
{/if}
</section>

View File

@@ -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();
};
});

View 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);
});
});

View File

@@ -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 {

29
apps/web/tests/fixtures/admin-login.ts vendored Normal file
View File

@@ -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<void> {
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
});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"),