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();
|
||||
}
|
||||
|
||||
75
apps/web/src/lib/components/Avatar.svelte
Normal file
75
apps/web/src/lib/components/Avatar.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Display name — used to generate initials and background colour. */
|
||||
name: string;
|
||||
/** Avatar type. Defaults to initials. */
|
||||
type?: 'initials' | 'emoji' | 'upload';
|
||||
/** Emoji character when type = 'emoji'. */
|
||||
emoji?: string | null;
|
||||
/** Image URL when type = 'upload'. */
|
||||
src?: string | null;
|
||||
/** Diameter in px. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, type = 'initials', emoji = null, src = null, size = 36 }: Props = $props();
|
||||
|
||||
let imgError = $state(false);
|
||||
|
||||
// Two initials from display name
|
||||
const initials = $derived(
|
||||
name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
);
|
||||
|
||||
// Deterministic background colour from name (one of 8 slate-adjacent tones)
|
||||
const COLOURS = [
|
||||
'bg-slate-400',
|
||||
'bg-blue-400',
|
||||
'bg-violet-400',
|
||||
'bg-emerald-400',
|
||||
'bg-amber-400',
|
||||
'bg-rose-400',
|
||||
'bg-cyan-400',
|
||||
'bg-pink-400'
|
||||
];
|
||||
const colourClass = $derived(
|
||||
COLOURS[
|
||||
name.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % COLOURS.length
|
||||
]
|
||||
);
|
||||
|
||||
const fontSize = $derived(Math.round(size * 0.38));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
>
|
||||
{#if type === 'upload' && src && !imgError}
|
||||
<img
|
||||
{src}
|
||||
alt={name}
|
||||
class="h-full w-full object-cover"
|
||||
onerror={() => (imgError = true)}
|
||||
/>
|
||||
{:else if type === 'emoji' && emoji}
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center bg-slate-100 dark:bg-slate-700"
|
||||
style="font-size: {fontSize * 1.4}px;"
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Initials fallback (also used when upload fails) -->
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center font-semibold text-white {colourClass}"
|
||||
style="font-size: {fontSize}px;"
|
||||
>
|
||||
{initials || '?'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
92
apps/web/src/lib/components/ImageCropper.svelte
Normal file
92
apps/web/src/lib/components/ImageCropper.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCrop: (blob: Blob) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { file, onCrop, onCancel }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let imgEl: HTMLImageElement;
|
||||
let cropperInstance: unknown = null;
|
||||
let objectUrl = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Lazy-load Cropper.js only when the component mounts
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CropperModule = await import('cropperjs') as any;
|
||||
const Cropper = CropperModule.default ?? CropperModule;
|
||||
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
imgEl.onload = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
cropperInstance = new (Cropper as any)(imgEl, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
autoCropArea: 0.8,
|
||||
movable: true,
|
||||
zoomable: true,
|
||||
rotatable: false,
|
||||
scalable: false
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(cropperInstance as any)?.destroy();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
function crop() {
|
||||
if (!cropperInstance) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const canvas: HTMLCanvasElement = (cropperInstance as any).getCroppedCanvas({ width: 256, height: 256 });
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) onCrop(blob);
|
||||
},
|
||||
'image/webp',
|
||||
0.85
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Import Cropper.js CSS via cdn-style bundled import -->
|
||||
<svelte:head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-4 dark:bg-slate-900">
|
||||
<p class="mb-3 text-sm font-semibold text-slate-900 dark:text-slate-50">Crop photo</p>
|
||||
<div class="mb-4 max-h-72 overflow-hidden rounded-lg">
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={imgEl} class="block max-w-full" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="flex-1 rounded-lg border border-slate-300 px-4 py-2 text-sm text-slate-700
|
||||
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={crop}
|
||||
class="flex-1 rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
|
||||
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
Use photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +1,19 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type Keycloak from 'keycloak-js';
|
||||
import type { KeycloakUser } from '$lib/auth';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
export const keycloak = writable<Keycloak | null>(null);
|
||||
export const isAuthenticated = writable<boolean>(false);
|
||||
export const currentUser = writable<KeycloakUser | null>(null);
|
||||
export const currentUser = writable<User | null>(null);
|
||||
export const authLoading = writable<boolean>(true);
|
||||
|
||||
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);
|
||||
export const isAuthenticated = derived(currentUser, ($user) => $user !== null);
|
||||
|
||||
/** Display name derived from Supabase user metadata, with email fallback. */
|
||||
export const displayName = derived(currentUser, ($user) => {
|
||||
if (!$user) return '';
|
||||
const meta = $user.user_metadata as Record<string, unknown>;
|
||||
return (
|
||||
(meta['full_name'] as string) ??
|
||||
(meta['name'] as string) ??
|
||||
$user.email?.split('@')[0] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { Database } from '@colectivo/types';
|
||||
|
||||
@@ -9,36 +8,13 @@ export function getSupabase(): SupabaseClient<Database> {
|
||||
if (!supabase) {
|
||||
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
// Auth is handled by Keycloak — disable Supabase GoTrue auth flows
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
global: {
|
||||
headers: {}
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true,
|
||||
// PKCE flow — required for SPA/PWA OAuth
|
||||
flowType: 'pkce'
|
||||
}
|
||||
});
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Keycloak access token on the Supabase client so RLS policies
|
||||
* resolve auth.uid() to the Keycloak sub claim.
|
||||
*/
|
||||
export function setSupabaseToken(accessToken: string): void {
|
||||
const client = getSupabase();
|
||||
// @ts-expect-error — internal API to inject a custom JWT
|
||||
client.rest.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
if (browser) {
|
||||
// Also set on the realtime client
|
||||
// @ts-expect-error
|
||||
client.realtime.setAuth(accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSupabaseToken(): void {
|
||||
const client = getSupabase();
|
||||
// @ts-expect-error
|
||||
delete client.rest.headers['Authorization'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user