feat(fase-10): CU-H06 leave collective — RPC + settings UI

Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
  to admin (inline; the existing user-delete trigger does not cover the
  membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
  to the dissolve flow (CU-H08)

Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.

pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 02:04:43 +02:00
parent 6cb13146f5
commit 2f0847a5a3
6 changed files with 529 additions and 2 deletions

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { userCollectives, currentCollective } from '$lib/stores/collective';
import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte';
@@ -156,6 +158,93 @@
.update({ language: lang })
.eq('id', $currentUser!.id);
}
// ── Fase 10.1: leave collective (CU-H06) ────────────────────────────────
let leaveCandidate = $state<{ id: string; name: string } | null>(null);
let leaveError = $state<string | null>(null);
function startLeave(c: { id: string; name: string }) {
leaveCandidate = c;
leaveError = null;
}
function cancelLeave() {
leaveCandidate = null;
leaveError = null;
}
async function confirmLeave() {
if (!leaveCandidate) return;
const target = leaveCandidate;
const supabase = getSupabase();
const { error } = await supabase.rpc('leave_collective', { p_collective_id: target.id });
if (error) {
// P0001 = sole member → instruct to dissolve instead.
leaveError = (error as { code?: string }).code === 'P0001'
? m.settings_collectives_dissolve_hint()
: error.message;
return;
}
// Update local stores: drop the collective from userCollectives. If it
// was the active one, switch to the next or kick to /onboarding.
const remaining = $userCollectives.filter((c) => c.id !== target.id);
userCollectives.set(remaining);
if ($currentCollective?.id === target.id) {
if (remaining.length > 0) {
const next = remaining[0];
currentCollective.set(next);
localStorage.setItem('activeCollectiveId', next.id);
} else {
currentCollective.set(null);
localStorage.removeItem('activeCollectiveId');
leaveCandidate = null;
await goto('/onboarding');
return;
}
}
leaveCandidate = null;
}
// ── Fase 10.5: delete account ───────────────────────────────────────────
let showDeleteAccount = $state(false);
let deleteConfirmInput = $state('');
let deleteAccountError = $state<string | null>(null);
let deleteInFlight = $state(false);
const deleteConfirmWord = m.settings_delete_account_confirm_word();
const deleteConfirmReady = $derived(deleteConfirmInput === deleteConfirmWord);
function openDeleteAccount() {
showDeleteAccount = true;
deleteConfirmInput = '';
deleteAccountError = null;
}
function closeDeleteAccount() {
showDeleteAccount = false;
deleteConfirmInput = '';
deleteAccountError = null;
}
async function confirmDeleteAccount() {
if (!deleteConfirmReady || deleteInFlight) return;
deleteInFlight = true;
deleteAccountError = null;
const { error } = await getSupabase().rpc('delete_account');
if (error) {
deleteInFlight = false;
deleteAccountError = (error as { code?: string }).code === 'P0003'
? m.settings_delete_account_blocked()
: error.message;
return;
}
// GoTrue row gone; clear local + start logout (RP-initiated) so Keycloak
// session is also ended. /logged-out is the post-logout landing.
await logout();
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
@@ -280,6 +369,34 @@
import.meta.env.DEV inside the component itself. -->
<SyncConflictsPanel />
<!-- Collectives (Fase 10.1) -->
<section>
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.settings_collectives()}
</p>
{#if $userCollectives.length === 0}
<p class="text-sm text-text-secondary">{m.settings_collectives_empty()}</p>
{:else}
<ul>
{#each $userCollectives as c (c.id)}
<li data-testid="collective-row-{c.id}" class="flex items-center gap-3 py-2">
<span class="text-lg">{c.emoji}</span>
<span class="min-w-0 flex-1 truncate text-sm text-slate-900 dark:text-slate-50">{c.name}</span>
<button
type="button"
data-testid="leave-collective-{c.id}"
onclick={() => startLeave(c)}
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.settings_collectives_leave()}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Account — spacing creates the separation, not a border line -->
<section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
@@ -300,6 +417,23 @@
{m.settings_logout()}
</button>
</section>
<!-- Danger zone (Fase 10.5) -->
<section class="pt-8">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
{m.settings_danger_zone()}
</p>
<p class="mb-3 text-sm text-text-secondary">{m.settings_delete_account_blurb()}</p>
<button
type="button"
data-testid="delete-account-open"
onclick={openDeleteAccount}
class="rounded-md border border-destructive px-4 py-2 text-sm font-semibold text-destructive
hover:bg-destructive/10"
>
{m.settings_delete_account()}
</button>
</section>
</div>
</div>
</div>
@@ -311,3 +445,79 @@
onCancel={() => (cropperFile = null)}
/>
{/if}
<!-- Leave-collective confirmation modal (Fase 10.1) -->
{#if leaveCandidate}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="leave-modal">
<div class="w-full max-w-sm rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.settings_collectives_confirm_leave_title({ name: leaveCandidate.name })}
</h2>
<p class="mb-4 text-sm text-text-secondary">{m.settings_collectives_confirm_leave_body()}</p>
{#if leaveError}
<p class="mb-3 text-sm text-destructive" data-testid="leave-error">{leaveError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={cancelLeave}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="confirm-leave-collective"
onclick={confirmLeave}
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
>
{m.settings_collectives_confirm_leave_button()}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete-account modal (Fase 10.5) -->
{#if showDeleteAccount}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="delete-account-modal">
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.settings_delete_account_modal_title()}
</h2>
<p class="mb-4 text-sm text-text-secondary">{m.settings_delete_account_modal_body()}</p>
<label for="delete-account-input" class="mb-1 block text-xs font-medium text-text-secondary">
{m.settings_delete_account_confirm_label({ word: deleteConfirmWord })}
</label>
<input
id="delete-account-input"
data-testid="delete-account-confirm-input"
type="text"
bind:value={deleteConfirmInput}
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
{#if deleteAccountError}
<p class="mb-3 text-sm text-destructive" data-testid="delete-account-error">{deleteAccountError}</p>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closeDeleteAccount}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="confirm-delete-account"
disabled={!deleteConfirmReady || deleteInFlight}
onclick={confirmDeleteAccount}
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{m.settings_delete_account_button()}
</button>
</div>
</div>
</div>
{/if}