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

@@ -3,47 +3,76 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { initAuth, getKeycloak, getUser } from '$lib/auth';
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { getSupabase } from '$lib/supabase';
import { login } from '$lib/auth';
import { currentUser, authLoading } from '$lib/stores/auth';
import { userCollectives, currentCollective } from '$lib/stores/collective';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
const PUBLIC_ROUTES = ['/invitation'];
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth', '/invitation'];
onMount(async () => {
const kc = getKeycloak();
keycloak.set(kc);
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some((r) => pathname.startsWith(r));
}
const authenticated = await initAuth();
isAuthenticated.set(authenticated);
onMount(() => {
const supabase = getSupabase();
if (authenticated) {
const user = getUser(kc);
currentUser.set(user);
if (kc.token) setSupabaseToken(kc.token);
const {
data: { subscription }
} = supabase.auth.onAuthStateChange(async (event, session) => {
currentUser.set(session?.user ?? null);
authLoading.set(false);
// Keep token in sync with Supabase client
kc.onTokenRefreshed = () => {
if (kc.token) setSupabaseToken(kc.token);
};
kc.onAuthLogout = () => {
clearSupabaseToken();
isAuthenticated.set(false);
currentUser.set(null);
};
}
authLoading.set(false);
// Redirect unauthenticated users unless on a public route
if (!authenticated) {
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
if (!isPublic) {
kc.login();
if (!session && !isPublicRoute($page.url.pathname)) {
// No session on a protected route → redirect to Keycloak
await login();
return;
}
}
if (session && event === 'SIGNED_IN') {
await loadUserCollectives(session.user.id);
// Post-login routing: onboarding if user has no collective
if ($userCollectives.length === 0 && $page.url.pathname === '/') {
goto('/onboarding');
} else if ($page.url.pathname === '/') {
goto('/lists');
}
}
});
return () => subscription.unsubscribe();
});
async function loadUserCollectives(userId: string): Promise<void> {
const supabase = getSupabase();
const { data } = await supabase
.from('collective_members')
.select('collective_id, role, collectives(id, name, emoji, created_at)')
.eq('user_id', userId);
if (!data) return;
type CollectiveRow = { id: string; name: string; emoji: string; created_at: string };
const collectives = data
.map((row) => {
const c = row.collectives as CollectiveRow | null;
return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null;
})
.filter((c): c is CollectiveRow => c !== null);
userCollectives.set(collectives);
// Restore last active collective from localStorage, or default to first
const savedId = localStorage.getItem('activeCollectiveId');
const active = collectives.find((c) => c.id === savedId) ?? collectives[0] ?? null;
currentCollective.set(active);
if (active) localStorage.setItem('activeCollectiveId', active.id);
}
</script>
<ParaglideJS {i18n}>