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

@@ -0,0 +1,102 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { isAuthenticated } from '$lib/stores/auth';
import { login } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success';
let status = $state<Status>('loading');
let errorKey = $state<string>('');
const token = $page.params.token ?? '';
onMount(async () => {
if (!token) {
errorKey = 'not_found';
status = 'error';
return;
}
if (!$isAuthenticated) {
// After login, GoTrue redirects to /auth/callback which goes to /.
// We store the invitation token so we can resume after login.
sessionStorage.setItem('pendingInvitationToken', token);
await login(`${window.location.origin}/auth/callback`);
return;
}
status = 'ready';
});
async function accept() {
status = 'accepting';
const { data, error } = await getSupabase().rpc('accept_invitation', { p_token: token });
if (error || !data) {
errorKey = error?.message ?? 'unknown';
status = 'error';
return;
}
const result = data as { ok?: boolean; error?: string; collective_id?: string };
if (result.error) {
errorKey = result.error;
status = 'error';
return;
}
status = 'success';
setTimeout(() => goto('/lists'), 1500);
}
function errorMessage(key: string): string {
const map: Record<string, string> = {
not_found: m.invitation_error_not_found(),
already_used: m.invitation_error_already_used(),
expired: m.invitation_error_expired(),
already_member: m.invitation_error_already_member(),
unauthenticated: m.invitation_error_unauthenticated()
};
return map[key] ?? m.invitation_error_unknown();
}
</script>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-sm text-center">
{#if status === 'loading'}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if status === 'ready'}
<div class="mb-6 text-5xl">🏠</div>
<h1 class="mb-2 text-xl font-bold text-slate-900 dark:text-slate-50">
{m.invitation_title()}
</h1>
<p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p>
<button
onclick={accept}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.invitation_accept_btn()}
</button>
{:else if status === 'accepting'}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if status === 'success'}
<div class="mb-4 text-4xl"></div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">
{m.invitation_success()}
</p>
{:else if status === 'error'}
<div class="mb-4 text-4xl"></div>
<p class="mb-4 text-sm text-red-600">{errorMessage(errorKey)}</p>
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
{/if}
</div>
</div>