feat(fase-1): auth, collective management, and DB migrations

- SQL migrations: users, collectives, collective_members, collective_invitations,
  full RLS policies, is_active_member/is_member/is_admin helpers,
  accept_invitation SECURITY DEFINER function, admin auto-promote trigger,
  avatars storage bucket
- Auth refactor: replace keycloak-js with Supabase OAuth (GoTrue OIDC proxy,
  Option B). signInWithOAuth({ provider: 'keycloak' }) + PKCE flow
- GoTrue config: GOTRUE_EXTERNAL_KEYCLOAK_URL + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI
  added to docker-compose.dev.yml; Keycloak realm updated with GoTrue callback URI
- New routes: /auth/callback, /invitation/[token], /(app)/collective/manage
- Functional onboarding: create collective or join via invite link
- Full settings page: display name (debounced), avatar (initials/emoji/upload),
  language switcher, Keycloak account console link
- Components: Avatar.svelte (initials/emoji/upload with fallback),
  ImageCropper.svelte (cropperjs, 1:1 crop, lazy-loaded)
- i18n: added all Fase 1 message keys (en + es); renamed 'delete' → 'action_delete'
  (JS reserved word); fixed plugin-m-function-matcher major-version URL format
- Database types: manually seeded packages/types/src/database.ts from migrations
- .env.development: committed public dev defaults for SvelteKit type checking
- CLAUDE.md: documented /etc/hosts requirement for keycloak hostname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:29:29 +02:00
parent f197a81a42
commit 7d91705fc2
31 changed files with 8770 additions and 199 deletions

View File

@@ -1,25 +1,186 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { currentCollective, userCollectives } from '$lib/stores/collective';
import * as m from '$lib/paraglide/messages';
type Tab = 'create' | 'join';
let activeTab = $state<Tab>('create');
// Create collective form
let collectiveName = $state('');
let collectiveEmoji = $state('🏠');
let creating = $state(false);
let createError = $state<string | null>(null);
const EMOJI_OPTIONS = [
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
];
// Join via invite link
let inviteLink = $state('');
let joining = $state(false);
let joinError = $state<string | null>(null);
async function createCollective() {
if (!collectiveName.trim()) return;
creating = true;
createError = null;
const supabase = getSupabase();
const userId = $currentUser?.id;
if (!userId) return;
const { data: collective, error: collectiveError } = await supabase
.from('collectives')
.insert({ name: collectiveName.trim(), emoji: collectiveEmoji, created_by: userId })
.select()
.single();
if (collectiveError || !collective) {
createError = collectiveError?.message ?? 'Failed to create collective.';
creating = false;
return;
}
const { error: memberError } = await supabase
.from('collective_members')
.insert({ collective_id: collective.id, user_id: userId, role: 'admin' });
if (memberError) {
createError = memberError.message;
creating = false;
return;
}
userCollectives.update((list) => [...list, collective]);
currentCollective.set(collective);
localStorage.setItem('activeCollectiveId', collective.id);
goto('/lists');
}
async function joinViaLink() {
joinError = null;
const tokenMatch = inviteLink.trim().match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
if (!tokenMatch) {
joinError = m.invitation_invalid_link();
return;
}
goto(`/invitation/${tokenMatch[1]}`);
}
</script>
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
<div class="w-full max-w-sm space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<h1 class="mb-2 text-2xl font-bold text-slate-900 dark:text-slate-50">
{m.onboarding_title()}
</h1>
<p class="mb-8 text-sm text-text-secondary">{m.onboarding_subtitle()}</p>
<!-- Tab switcher -->
<div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700">
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'create'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
onclick={() => (activeTab = 'create')}
>
{m.onboarding_create_tab()}
</button>
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'join'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
onclick={() => (activeTab = 'join')}
>
{m.onboarding_join_tab()}
</button>
</div>
<!-- Fase 1: collective creation / join form -->
<div class="space-y-3">
<button
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.onboarding_create_collective()}
</button>
<button
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.onboarding_join_collective()}
</button>
</div>
{#if activeTab === 'create'}
<form onsubmit={(e) => { e.preventDefault(); createCollective(); }} class="space-y-4">
<div>
<label for="collective-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_collective_name()}
</label>
<input
id="collective-name"
type="text"
bind:value={collectiveName}
placeholder={m.onboarding_collective_name_placeholder()}
maxlength="60"
class="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"
/>
</div>
<div>
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_collective_emoji()}
</p>
<div class="grid grid-cols-8 gap-1">
{#each EMOJI_OPTIONS as e}
<button
type="button"
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
{collectiveEmoji === e
? 'bg-slate-900 dark:bg-slate-100'
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
onclick={() => (collectiveEmoji = e)}
aria-label={e}
>{e}</button>
{/each}
</div>
</div>
{#if createError}
<p class="text-sm text-red-600">{createError}</p>
{/if}
<button
type="submit"
disabled={!collectiveName.trim() || creating}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
>
{creating ? m.loading() : m.onboarding_create_btn()}
</button>
</form>
{:else}
<form onsubmit={(e) => { e.preventDefault(); joinViaLink(); }} class="space-y-4">
<div>
<label for="invite-link" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_invite_link_label()}
</label>
<input
id="invite-link"
type="text"
bind:value={inviteLink}
placeholder={m.onboarding_invite_link_placeholder()}
class="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"
/>
</div>
{#if joinError}
<p class="text-sm text-red-600">{joinError}</p>
{/if}
<button
type="submit"
disabled={!inviteLink.trim() || joining}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
>
{joining ? m.loading() : m.onboarding_join_btn()}
</button>
</form>
{/if}
</div>
</div>