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