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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user