diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 0154a3b..ce22b11 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -190,5 +190,35 @@
"settings_theme_system": "System",
"settings_sync_conflicts": "Sync conflicts",
"settings_sync_conflicts_empty": "No conflicts logged.",
- "settings_sync_conflicts_export": "Export JSON"
+ "settings_sync_conflicts_export": "Export JSON",
+ "settings_collectives": "Your collectives",
+ "settings_collectives_empty": "You don't belong to any collective.",
+ "settings_collectives_leave": "Leave",
+ "settings_collectives_confirm_leave_title": "Leave {name}?",
+ "settings_collectives_confirm_leave_body": "You will lose access to this collective. The content stays for the other members.",
+ "settings_collectives_confirm_leave_button": "Leave collective",
+ "settings_collectives_dissolve_hint": "You are the only member — use 'Dissolve' on the collective page instead.",
+ "settings_danger_zone": "Danger zone",
+ "settings_delete_account": "Delete my account",
+ "settings_delete_account_blurb": "Permanently delete your account and remove your memberships. Content you created stays in the collectives, attributed to a deleted user.",
+ "settings_delete_account_modal_title": "Delete your account?",
+ "settings_delete_account_modal_body": "This is permanent. What gets deleted: your account, your memberships, your saved language and theme. What stays: any lists, tasks or notes you created — they remain in their collectives, attributed to a deleted user. If you are the only admin of a collective, the longest-standing member is promoted automatically. Note: your sign-in account at the identity provider is not deleted; you can register again with the same email and a new profile will be created.",
+ "settings_delete_account_confirm_label": "Type {word} to confirm",
+ "settings_delete_account_confirm_word": "DELETE",
+ "settings_delete_account_button": "Delete account permanently",
+ "settings_delete_account_blocked": "You are the only admin of a collective with members who cannot be promoted. Promote someone first, or remove the other members.",
+ "common_cancel": "Cancel",
+ "manage_collective_name_label": "Collective name",
+ "manage_collective_emoji_label": "Icon",
+ "manage_collective_save": "Save",
+ "manage_dissolve_section": "Danger zone",
+ "manage_dissolve_button": "Dissolve collective",
+ "manage_dissolve_blurb": "Permanently delete this collective and all its content. This cannot be undone.",
+ "manage_dissolve_title": "Dissolve {name}?",
+ "manage_dissolve_warning": "{lists} lists, {tasks} tasks and {notes} notes will be permanently deleted. This is irreversible.",
+ "manage_dissolve_confirm_label": "Type {name} to confirm",
+ "manage_dissolve_confirm_button": "Dissolve permanently",
+ "sidebar_create_collective": "+ New collective",
+ "create_collective_modal_title": "New collective",
+ "create_collective_modal_button": "Create"
}
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index c625734..a601bd0 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -190,5 +190,35 @@
"settings_theme_system": "Sistema",
"settings_sync_conflicts": "Conflictos de sincronización",
"settings_sync_conflicts_empty": "Sin conflictos registrados.",
- "settings_sync_conflicts_export": "Exportar JSON"
+ "settings_sync_conflicts_export": "Exportar JSON",
+ "settings_collectives": "Tus colectivos",
+ "settings_collectives_empty": "No perteneces a ningún colectivo.",
+ "settings_collectives_leave": "Abandonar",
+ "settings_collectives_confirm_leave_title": "¿Abandonar {name}?",
+ "settings_collectives_confirm_leave_body": "Perderás acceso a este colectivo. El contenido se queda para los demás miembros.",
+ "settings_collectives_confirm_leave_button": "Abandonar colectivo",
+ "settings_collectives_dissolve_hint": "Eres el único miembro — usa 'Disolver' en la página del colectivo en su lugar.",
+ "settings_danger_zone": "Zona peligrosa",
+ "settings_delete_account": "Eliminar mi cuenta",
+ "settings_delete_account_blurb": "Elimina permanentemente tu cuenta y todas tus membresías. El contenido que hayas creado se queda en los colectivos, atribuido a un usuario eliminado.",
+ "settings_delete_account_modal_title": "¿Eliminar tu cuenta?",
+ "settings_delete_account_modal_body": "Esto es permanente. Lo que se borra: tu cuenta, tus membresías, tu idioma y tema guardados. Lo que se queda: las listas, tareas o notas que hayas creado — se quedan en sus colectivos, atribuidas a un usuario eliminado. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente. Nota: tu cuenta en el proveedor de identidad NO se elimina; puedes volver a registrarte con el mismo email y se creará un perfil nuevo.",
+ "settings_delete_account_confirm_label": "Escribe {word} para confirmar",
+ "settings_delete_account_confirm_word": "ELIMINAR",
+ "settings_delete_account_button": "Eliminar cuenta permanentemente",
+ "settings_delete_account_blocked": "Eres el único admin de un colectivo con miembros que no pueden ser promovidos. Promueve a alguien primero o elimina a los demás miembros.",
+ "common_cancel": "Cancelar",
+ "manage_collective_name_label": "Nombre del colectivo",
+ "manage_collective_emoji_label": "Icono",
+ "manage_collective_save": "Guardar",
+ "manage_dissolve_section": "Zona peligrosa",
+ "manage_dissolve_button": "Disolver colectivo",
+ "manage_dissolve_blurb": "Borra permanentemente este colectivo y todo su contenido. No se puede deshacer.",
+ "manage_dissolve_title": "¿Disolver {name}?",
+ "manage_dissolve_warning": "Se borrarán {lists} listas, {tasks} tareas y {notes} notas. Esta acción es irreversible.",
+ "manage_dissolve_confirm_label": "Escribe {name} para confirmar",
+ "manage_dissolve_confirm_button": "Disolver permanentemente",
+ "sidebar_create_collective": "+ Nuevo colectivo",
+ "create_collective_modal_title": "Nuevo colectivo",
+ "create_collective_modal_button": "Crear"
}
diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte
index 9353de7..37bc2e0 100644
--- a/apps/web/src/routes/(app)/settings/+page.svelte
+++ b/apps/web/src/routes/(app)/settings/+page.svelte
@@ -1,7 +1,9 @@
+{/if}
diff --git a/apps/web/tests/e2e/leave-collective.test.ts b/apps/web/tests/e2e/leave-collective.test.ts
new file mode 100644
index 0000000..2092e0e
--- /dev/null
+++ b/apps/web/tests/e2e/leave-collective.test.ts
@@ -0,0 +1,62 @@
+/**
+ * L-series: /settings → leave collective (Fase 10.1, CU-H06).
+ *
+ * Borja (member of the seed collective) abandons it. The collective and its
+ * content stay; Borja's row in collective_members disappears; Ana — viewing
+ * /collective/manage — no longer sees Borja in the roster.
+ *
+ * Borja is then a no-collective user and is auto-redirected to /onboarding.
+ *
+ * Cleanup: re-insert Borja's seed membership in afterAll so downstream suites
+ * that rely on Borja being a member don't blow up.
+ */
+import { test, expect } from '@playwright/test';
+import { USERS } from '../fixtures/users.js';
+import { loginAs } from '../fixtures/login.js';
+import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';
+
+test.describe.configure({ mode: 'serial' });
+
+test.describe('Leave collective (CU-H06)', () => {
+ test.afterAll(async () => {
+ // Restore Borja's seed membership so other suites pass.
+ await sql(
+ `INSERT INTO public.collective_members (collective_id, user_id, role)
+ VALUES ($1, $2, 'member')
+ ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
+ [COLLECTIVE_ID, USERS.borja.id]
+ );
+ await closePool();
+ });
+
+ test('L-01: Borja leaves the seed collective → row gone, lands on onboarding', async ({
+ page
+ }) => {
+ await loginAs(page, USERS.borja);
+ await page.goto('/settings');
+
+ // Settings page renders the collectives section; Borja's seed collective
+ // has a leave button next to it.
+ const leaveButton = page.getByTestId(`leave-collective-${COLLECTIVE_ID}`);
+ await expect(leaveButton).toBeVisible({ timeout: 15_000 });
+ await leaveButton.click();
+
+ // Confirm in the modal.
+ await page.getByTestId('confirm-leave-collective').click();
+
+ // Borja had only one collective; he gets bounced to /onboarding.
+ await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
+
+ // DB-side: membership row is gone, but the collective row + content survive.
+ const m = await sql(
+ 'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
+ [COLLECTIVE_ID, USERS.borja.id]
+ );
+ expect(m.rows[0].n).toBe(0);
+
+ const c = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [
+ COLLECTIVE_ID
+ ]);
+ expect(c.rows[0].n).toBe(1);
+ });
+});
diff --git a/supabase/migrations/017_leave_collective_rpc.sql b/supabase/migrations/017_leave_collective_rpc.sql
new file mode 100644
index 0000000..754a759
--- /dev/null
+++ b/supabase/migrations/017_leave_collective_rpc.sql
@@ -0,0 +1,84 @@
+-- Migration 017: leave_collective(uuid) RPC — Fase 10.1 (CU-H06).
+--
+-- A user abandons one of their collectives. The content stays.
+--
+-- If the user is the only admin and other members exist, the existing
+-- `promote_oldest_member_on_admin_leave` trigger fires on DELETE FROM
+-- public.users — but that trigger only runs on user-account delete, not
+-- on collective_members delete. So we replicate the auto-promote logic
+-- inline before removing the membership row.
+--
+-- If the user is the sole member of the collective, leaving is disallowed:
+-- they must dissolve instead (CU-H08, migration 018). Errcode P0001 lets
+-- the UI distinguish this case from generic errors and link to the
+-- dissolve flow.
+
+CREATE OR REPLACE FUNCTION public.leave_collective(p_collective_id uuid)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, auth
+AS $$
+DECLARE
+ v_user uuid := auth.uid();
+ v_member_count int;
+ v_is_admin boolean;
+ v_other_admins int;
+BEGIN
+ IF v_user IS NULL THEN
+ RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
+ END IF;
+
+ -- Guard: caller must actually be a member of the collective.
+ IF NOT EXISTS (
+ SELECT 1 FROM public.collective_members
+ WHERE collective_id = p_collective_id AND user_id = v_user
+ ) THEN
+ RAISE EXCEPTION 'not a member' USING ERRCODE = '42501';
+ END IF;
+
+ -- How many members in the collective?
+ SELECT count(*) INTO v_member_count
+ FROM public.collective_members
+ WHERE collective_id = p_collective_id;
+
+ IF v_member_count = 1 THEN
+ -- Sole member — must dissolve, not leave.
+ RAISE EXCEPTION 'cannot leave as sole member; dissolve instead'
+ USING ERRCODE = 'P0001';
+ END IF;
+
+ -- Check if the departing user is the only admin.
+ SELECT role = 'admin' INTO v_is_admin
+ FROM public.collective_members
+ WHERE collective_id = p_collective_id AND user_id = v_user;
+
+ IF v_is_admin THEN
+ SELECT count(*) INTO v_other_admins
+ FROM public.collective_members
+ WHERE collective_id = p_collective_id
+ AND user_id <> v_user
+ AND role = 'admin';
+
+ -- If sole admin, promote the oldest remaining (non-departing) member.
+ IF v_other_admins = 0 THEN
+ UPDATE public.collective_members
+ SET role = 'admin'
+ WHERE (collective_id, user_id) = (
+ SELECT collective_id, user_id
+ FROM public.collective_members
+ WHERE collective_id = p_collective_id
+ AND user_id <> v_user
+ ORDER BY joined_at ASC
+ LIMIT 1
+ );
+ END IF;
+ END IF;
+
+ DELETE FROM public.collective_members
+ WHERE collective_id = p_collective_id AND user_id = v_user;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.leave_collective(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.leave_collective(uuid) TO authenticated;
diff --git a/supabase/tests/010_leave_collective.sql b/supabase/tests/010_leave_collective.sql
new file mode 100644
index 0000000..c5c3c34
--- /dev/null
+++ b/supabase/tests/010_leave_collective.sql
@@ -0,0 +1,111 @@
+-- pgTAP: leave_collective() RPC — Fase 10.1
+-- Run with: psql -U postgres -d postgres -f supabase/tests/010_leave_collective.sql
+--
+-- Covers CU-H06:
+-- * normal member abandons OK (membership row gone)
+-- * sole admin with other members abandons OK + oldest member auto-promoted
+-- * sole member rejected with errcode P0001 (must use dissolve_collective)
+
+CREATE EXTENSION IF NOT EXISTS pgtap;
+
+BEGIN;
+SELECT plan(7);
+
+-- ── Existence ────────────────────────────────────────────────────────────────
+SELECT has_function(
+ 'public', 'leave_collective', ARRAY['uuid'],
+ 'public.leave_collective(uuid) RPC exists'
+);
+
+-- ── Setup: three test users + two collectives outside seed range ──────────────
+INSERT INTO public.users (id, email, display_name, language, avatar_type)
+VALUES
+ ('e1000000-0000-0000-0000-000000000001', 'leave-admin@local', 'Leave Admin', 'es', 'initials'),
+ ('e1000000-0000-0000-0000-000000000002', 'leave-member@local', 'Leave Member', 'es', 'initials'),
+ ('e1000000-0000-0000-0000-000000000003', 'leave-solo@local', 'Leave Solo', 'es', 'initials')
+ON CONFLICT (id) DO NOTHING;
+
+-- Collective A: admin + member. created_by = Borja (so deleting admin row is safe).
+INSERT INTO public.collectives (id, name, emoji, created_by)
+VALUES ('cccccccc-1111-1111-1111-111111111111', 'Leave test A', '🚪',
+ '22222222-2222-2222-2222-222222222222')
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
+VALUES
+ ('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000001', 'admin', now() - INTERVAL '10 days'),
+ ('cccccccc-1111-1111-1111-111111111111', 'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
+ON CONFLICT (collective_id, user_id) DO NOTHING;
+
+-- Collective B: sole member only.
+INSERT INTO public.collectives (id, name, emoji, created_by)
+VALUES ('cccccccc-2222-2222-2222-222222222222', 'Leave test B (solo)', '🚪',
+ '22222222-2222-2222-2222-222222222222')
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO public.collective_members (collective_id, user_id, role)
+VALUES
+ ('cccccccc-2222-2222-2222-222222222222', 'e1000000-0000-0000-0000-000000000003', 'admin')
+ON CONFLICT (collective_id, user_id) DO NOTHING;
+
+-- ── Case 1: normal member leaves ──────────────────────────────────────────────
+-- Authenticate as leave-member
+SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000002', true);
+
+SELECT lives_ok(
+ $$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
+ 'L-01a: member can leave without error'
+);
+
+SELECT is(
+ (SELECT count(*)::int FROM public.collective_members
+ WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
+ AND user_id = 'e1000000-0000-0000-0000-000000000002'),
+ 0,
+ 'L-01a: membership row removed after leave'
+);
+
+-- ── Case 2: sole admin with other members ─────────────────────────────────────
+-- Re-add the member to set up sole-admin scenario again.
+INSERT INTO public.collective_members (collective_id, user_id, role, joined_at)
+VALUES ('cccccccc-1111-1111-1111-111111111111',
+ 'e1000000-0000-0000-0000-000000000002', 'member', now() - INTERVAL '5 days')
+ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role;
+
+-- Authenticate as the admin
+SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000001', true);
+
+SELECT lives_ok(
+ $$ SELECT public.leave_collective('cccccccc-1111-1111-1111-111111111111') $$,
+ 'L-01b: sole admin can leave when other members exist'
+);
+
+SELECT is(
+ (SELECT role::text FROM public.collective_members
+ WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
+ AND user_id = 'e1000000-0000-0000-0000-000000000002'),
+ 'admin',
+ 'L-01b: remaining member auto-promoted to admin'
+);
+
+SELECT is(
+ (SELECT count(*)::int FROM public.collective_members
+ WHERE collective_id = 'cccccccc-1111-1111-1111-111111111111'
+ AND user_id = 'e1000000-0000-0000-0000-000000000001'),
+ 0,
+ 'L-01b: departing admin row removed'
+);
+
+-- ── Case 3: sole member tries to leave ────────────────────────────────────────
+-- Authenticate as leave-solo
+SELECT set_config('request.jwt.claim.sub', 'e1000000-0000-0000-0000-000000000003', true);
+
+SELECT throws_ok(
+ $$ SELECT public.leave_collective('cccccccc-2222-2222-2222-222222222222') $$,
+ 'P0001',
+ NULL,
+ 'L-01c: sole member rejected with errcode P0001'
+);
+
+SELECT * FROM finish();
+ROLLBACK;