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