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,78 +1,29 @@
import Keycloak from 'keycloak-js';
import { browser } from '$app/environment';
import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public';
import { getSupabase } from '$lib/supabase';
export interface KeycloakUser {
id: string;
email: string;
displayName: string;
preferredUsername: string;
}
/**
* Redirect to Keycloak login via GoTrue OAuth proxy.
* GoTrue handles the PKCE exchange; the browser lands at /auth/callback.
*/
export async function login(redirectTo?: string): Promise<void> {
if (!browser) return;
let keycloak: Keycloak | null = null;
export function getKeycloak(): Keycloak {
if (!keycloak) {
keycloak = new Keycloak({
url: PUBLIC_KEYCLOAK_URL,
realm: PUBLIC_KEYCLOAK_REALM,
clientId: PUBLIC_KEYCLOAK_CLIENT_ID
});
}
return keycloak;
}
export async function initAuth(): Promise<boolean> {
if (!browser) return false;
const kc = getKeycloak();
const authenticated = await kc.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
// Safari blocks third-party cookies in iframes — must be false
checkLoginIframe: false,
pkceMethod: 'S256'
const { error } = await getSupabase().auth.signInWithOAuth({
provider: 'keycloak',
options: {
redirectTo: redirectTo ?? `${window.location.origin}/auth/callback`,
scopes: 'openid profile email'
}
});
if (authenticated) {
// Schedule proactive token refresh (60s before expiry)
scheduleTokenRefresh(kc);
}
return authenticated;
}
function scheduleTokenRefresh(kc: Keycloak): void {
setInterval(
async () => {
try {
await kc.updateToken(60);
} catch {
// Token refresh failed — redirect to login
kc.login();
}
},
30 * 1000 // check every 30s
);
}
export function getUser(kc: Keycloak): KeycloakUser | null {
if (!kc.authenticated || !kc.tokenParsed) return null;
const token = kc.tokenParsed as Record<string, unknown>;
return {
id: token['sub'] as string,
email: token['email'] as string,
displayName: (token['name'] as string) ?? (token['preferred_username'] as string),
preferredUsername: token['preferred_username'] as string
};
}
export async function login(): Promise<void> {
getKeycloak().login();
if (error) throw error;
}
/**
* Sign out from Supabase (clears local session).
* The Keycloak session remains active — the user won't be asked for credentials
* again on next login unless they also log out from the Keycloak Account Console.
*/
export async function logout(): Promise<void> {
getKeycloak().logout({ redirectUri: window.location.origin });
await getSupabase().auth.signOut();
}