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:
@@ -240,5 +240,64 @@
|
|||||||
"section_label_lists": "Lists",
|
"section_label_lists": "Lists",
|
||||||
"section_label_tasks": "Tasks",
|
"section_label_tasks": "Tasks",
|
||||||
"section_label_notes": "Notes",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,5 +240,64 @@
|
|||||||
"section_label_lists": "Listas",
|
"section_label_lists": "Listas",
|
||||||
"section_label_tasks": "Tareas",
|
"section_label_tasks": "Tareas",
|
||||||
"section_label_notes": "Notas",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import { displayName } from '$lib/stores/auth';
|
import { displayName } from '$lib/stores/auth';
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
import { enabledSections } from '$lib/stores/features';
|
import { enabledSections } from '$lib/stores/features';
|
||||||
|
import { isServerAdmin } from '$lib/stores/serverAdmin';
|
||||||
import type { SectionKey } from '@colectivo/types';
|
import type { SectionKey } from '@colectivo/types';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
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
|
// Fase 12.3.1: nav items declared with their section key so we can filter
|
||||||
// by $enabledSections (precedence resolved client-side; see features.ts).
|
// by $enabledSections (precedence resolved client-side; see features.ts).
|
||||||
@@ -100,6 +101,18 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="p-3 pt-2">
|
<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 items-center justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<Avatar name={$displayName} size={32} />
|
<Avatar name={$displayName} size={32} />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
import { displayName } from '$lib/stores/auth';
|
import { displayName } from '$lib/stores/auth';
|
||||||
|
import { isServerAdmin } from '$lib/stores/serverAdmin';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||||
import { logout } from '$lib/auth';
|
import { logout } from '$lib/auth';
|
||||||
import * as m from '$lib/paraglide/messages';
|
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);
|
let showCreate = $state(false);
|
||||||
|
|
||||||
@@ -98,6 +99,18 @@
|
|||||||
<Avatar name={$displayName} size={28} />
|
<Avatar name={$displayName} size={28} />
|
||||||
<span class="truncate text-sm font-medium text-text-primary">{$displayName}</span>
|
<span class="truncate text-sm font-medium text-text-primary">{$displayName}</span>
|
||||||
</div>
|
</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
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onclick={close}
|
onclick={close}
|
||||||
|
|||||||
@@ -42,26 +42,38 @@ import { currentCollective } from '$lib/stores/collective';
|
|||||||
*/
|
*/
|
||||||
export const currentUserFeatures = writable<{ feature_flags: FeatureFlags } | null>(null);
|
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
|
* Derived map { section → boolean } applying the same precedence as
|
||||||
* `public.section_enabled()`. Layers, MOST restrictive wins:
|
* `public.section_enabled()`. Layers, MOST restrictive wins:
|
||||||
* 1. collective.feature_flags[section] (admin override)
|
* 1. server.default_sections[section] (operator override)
|
||||||
* 2. user.feature_flags[section] (per-user override)
|
* 2. collective.feature_flags[section] (admin override)
|
||||||
* 3. default true
|
* 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
|
* 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+).
|
* here — that path is reserved for SQL-side callers (migrations 13+).
|
||||||
*/
|
*/
|
||||||
export const enabledSections = derived(
|
export const enabledSections = derived(
|
||||||
[currentUserFeatures, currentCollective],
|
[currentUserFeatures, currentCollective, serverSectionDefaults],
|
||||||
([$user, $collective]) => {
|
([$user, $collective, $server]) => {
|
||||||
const userFlags = ($user?.feature_flags ?? {}) as FeatureFlags;
|
const userFlags = ($user?.feature_flags ?? {}) as FeatureFlags;
|
||||||
const collectiveFlags = ($collective?.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>;
|
const out: Record<SectionKey, boolean> = {} as Record<SectionKey, boolean>;
|
||||||
for (const key of SECTION_KEYS) {
|
for (const key of SECTION_KEYS) {
|
||||||
|
const serverVal = serverFlags[key];
|
||||||
const collectiveVal = collectiveFlags[key];
|
const collectiveVal = collectiveFlags[key];
|
||||||
const userVal = userFlags[key];
|
const userVal = userFlags[key];
|
||||||
out[key] = collectiveVal ?? userVal ?? true;
|
out[key] = serverVal ?? collectiveVal ?? userVal ?? true;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -173,6 +185,20 @@ export async function loadCurrentUserFeatures(userId: string): Promise<void> {
|
|||||||
feature_flags: ((data?.feature_flags ?? {}) as FeatureFlags) || {}
|
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) {
|
if (userChannelUserId !== userId) {
|
||||||
await teardownUserChannel();
|
await teardownUserChannel();
|
||||||
userChannelUserId = userId;
|
userChannelUserId = userId;
|
||||||
@@ -238,6 +264,7 @@ export async function teardownFeatureSubscriptions(): Promise<void> {
|
|||||||
await teardownUserChannel();
|
await teardownUserChannel();
|
||||||
await teardownCollectiveChannel();
|
await teardownCollectiveChannel();
|
||||||
currentUserFeatures.set(null);
|
currentUserFeatures.set(null);
|
||||||
|
serverSectionDefaults.set({});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function teardownUserChannel(): Promise<void> {
|
async function teardownUserChannel(): Promise<void> {
|
||||||
|
|||||||
57
apps/web/src/lib/stores/serverAdmin.ts
Normal file
57
apps/web/src/lib/stores/serverAdmin.ts
Normal 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);
|
||||||
|
}
|
||||||
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,
|
subscribeCollectiveFeatures,
|
||||||
teardownFeatureSubscriptions
|
teardownFeatureSubscriptions
|
||||||
} from '$lib/stores/features';
|
} from '$lib/stores/features';
|
||||||
|
import { refreshServerAdminFlag, clearServerAdminFlag } from '$lib/stores/serverAdmin';
|
||||||
import type { FeatureFlags } from '@colectivo/types';
|
import type { FeatureFlags } from '@colectivo/types';
|
||||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||||
import { i18n } from '$lib/i18n';
|
import { i18n } from '$lib/i18n';
|
||||||
@@ -47,6 +48,10 @@
|
|||||||
currentUser.set(session?.user ?? null);
|
currentUser.set(session?.user ?? null);
|
||||||
authLoading.set(false);
|
authLoading.set(false);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
clearServerAdminFlag();
|
||||||
|
}
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
// Defer out of the onAuthStateChange callback so the internal auth
|
// Defer out of the onAuthStateChange callback so the internal auth
|
||||||
// lock is fully released before we make any PostgREST query.
|
// lock is fully released before we make any PostgREST query.
|
||||||
@@ -64,6 +69,11 @@
|
|||||||
/* non-fatal */
|
/* 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.
|
// Fase 10.8: auto-detect language for genuinely new users.
|
||||||
// Best-effort, runs after every auth event so newly-seeded
|
// Best-effort, runs after every auth event so newly-seeded
|
||||||
// (post-Keycloak-self-registration) rows pick up the user's
|
// (post-Keycloak-self-registration) rows pick up the user's
|
||||||
@@ -106,6 +116,7 @@
|
|||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
collectiveUnsub();
|
collectiveUnsub();
|
||||||
void teardownFeatureSubscriptions();
|
void teardownFeatureSubscriptions();
|
||||||
|
clearServerAdminFlag();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
300
apps/web/tests/e2e/admin.test.ts
Normal file
300
apps/web/tests/e2e/admin.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,22 @@ async function resetAllFlags(browser: import('@playwright/test').Browser): Promi
|
|||||||
await loginAs(anaPage, USERS.ana);
|
await loginAs(anaPage, USERS.ana);
|
||||||
await patchRow(anaPage, 'users', USERS.ana.id, { feature_flags: {} });
|
await patchRow(anaPage, 'users', USERS.ana.id, { feature_flags: {} });
|
||||||
await patchRow(anaPage, 'collectives', SEED_COLLECTIVE_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 {
|
} catch {
|
||||||
/* don't mask original failure */
|
/* don't mask original failure */
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
29
apps/web/tests/fixtures/admin-login.ts
vendored
Normal file
29
apps/web/tests/fixtures/admin-login.ts
vendored
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -614,6 +614,10 @@ export interface Database {
|
|||||||
Args: { p_section: string; p_enabled: boolean };
|
Args: { p_section: string; p_enabled: boolean };
|
||||||
Returns: void;
|
Returns: void;
|
||||||
};
|
};
|
||||||
|
admin_clear_default_section: {
|
||||||
|
Args: { p_section: string };
|
||||||
|
Returns: void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
Enums: {
|
Enums: {
|
||||||
language_code: LanguageCode;
|
language_code: LanguageCode;
|
||||||
|
|||||||
@@ -511,3 +511,48 @@ $$;
|
|||||||
|
|
||||||
REVOKE ALL ON FUNCTION public.admin_set_default_section(text, boolean) FROM PUBLIC;
|
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;
|
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;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
SELECT plan(22);
|
SELECT plan(23);
|
||||||
|
|
||||||
-- ── server_settings table ──────────────────────────────────────────────────
|
-- ── server_settings table ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,6 +87,11 @@ SELECT has_function(
|
|||||||
'AR-T13: admin_set_default_section(text, boolean) exists'
|
'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
|
-- 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.
|
-- `if not is_server_admin() then raise` guard is the gate, not RLS.
|
||||||
SELECT results_eq(
|
SELECT results_eq(
|
||||||
@@ -96,6 +101,7 @@ SELECT results_eq(
|
|||||||
AND prosecdef = true
|
AND prosecdef = true
|
||||||
ORDER BY proname COLLATE "C" $$,
|
ORDER BY proname COLLATE "C" $$,
|
||||||
$$ VALUES
|
$$ VALUES
|
||||||
|
('admin_clear_default_section'::text COLLATE "C"),
|
||||||
('admin_hard_delete_collective'::text COLLATE "C"),
|
('admin_hard_delete_collective'::text COLLATE "C"),
|
||||||
('admin_list_collectives'::text COLLATE "C"),
|
('admin_list_collectives'::text COLLATE "C"),
|
||||||
('admin_remove_member'::text COLLATE "C"),
|
('admin_remove_member'::text COLLATE "C"),
|
||||||
|
|||||||
Reference in New Issue
Block a user