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:
106
apps/web/src/routes/(admin)/+layout.svelte
Normal file
106
apps/web/src/routes/(admin)/+layout.svelte
Normal 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}
|
||||
9
apps/web/src/routes/(admin)/+layout.ts
Normal file
9
apps/web/src/routes/(admin)/+layout.ts
Normal 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 {};
|
||||
}
|
||||
11
apps/web/src/routes/(admin)/admin/+page.svelte
Normal file
11
apps/web/src/routes/(admin)/admin/+page.svelte
Normal 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>
|
||||
183
apps/web/src/routes/(admin)/admin/admins/+page.svelte
Normal file
183
apps/web/src/routes/(admin)/admin/admins/+page.svelte
Normal 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}
|
||||
118
apps/web/src/routes/(admin)/admin/audit/+page.svelte
Normal file
118
apps/web/src/routes/(admin)/admin/audit/+page.svelte
Normal 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>
|
||||
300
apps/web/src/routes/(admin)/admin/collectives/+page.svelte
Normal file
300
apps/web/src/routes/(admin)/admin/collectives/+page.svelte
Normal 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}
|
||||
219
apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte
Normal file
219
apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte
Normal 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}
|
||||
130
apps/web/src/routes/(admin)/admin/server/+page.svelte
Normal file
130
apps/web/src/routes/(admin)/admin/server/+page.svelte
Normal 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>
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user