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

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