From 7d91705fc23f67d2d194c77e1df9f6034ad1cdf9 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Sun, 12 Apr 2026 15:29:29 +0200 Subject: [PATCH] feat(fase-1): auth, collective management, and DB migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 13 +- apps/web/.env.development | 8 + apps/web/messages/en.json | 45 +- apps/web/messages/es.json | 45 +- apps/web/package.json | 4 +- apps/web/project.inlang/.gitignore | 1 + apps/web/project.inlang/project_id | 1 + apps/web/project.inlang/settings.json | 2 +- apps/web/src/app.d.ts | 5 +- apps/web/src/lib/auth.ts | 89 +- apps/web/src/lib/components/Avatar.svelte | 75 + .../src/lib/components/ImageCropper.svelte | 92 + apps/web/src/lib/stores/auth.ts | 21 +- apps/web/src/lib/supabase.ts | 34 +- apps/web/src/routes/(app)/+layout.svelte | 84 +- apps/web/src/routes/(app)/+layout.ts | 21 + .../(app)/collective/manage/+page.svelte | 252 + .../src/routes/(app)/settings/+page.svelte | 271 +- apps/web/src/routes/+layout.svelte | 93 +- .../web/src/routes/auth/callback/+page.svelte | 39 + .../routes/invitation/[token]/+page.svelte | 102 + apps/web/src/routes/onboarding/+page.svelte | 195 +- infra/docker-compose.dev.yml | 6 +- keycloak/realm-export.json | 7 +- packages/types/src/database.ts | 184 +- pnpm-lock.yaml | 6814 +++++++++++++++++ supabase/config.toml | 10 +- supabase/migrations/001_users.sql | 87 + supabase/migrations/002_collectives.sql | 99 + supabase/migrations/003_rls.sql | 229 + supabase/migrations/004_storage.sql | 41 + 31 files changed, 8770 insertions(+), 199 deletions(-) create mode 100644 apps/web/.env.development create mode 100644 apps/web/project.inlang/.gitignore create mode 100644 apps/web/project.inlang/project_id create mode 100644 apps/web/src/lib/components/Avatar.svelte create mode 100644 apps/web/src/lib/components/ImageCropper.svelte create mode 100644 apps/web/src/routes/(app)/+layout.ts create mode 100644 apps/web/src/routes/(app)/collective/manage/+page.svelte create mode 100644 apps/web/src/routes/auth/callback/+page.svelte create mode 100644 apps/web/src/routes/invitation/[token]/+page.svelte create mode 100644 pnpm-lock.yaml create mode 100644 supabase/migrations/001_users.sql create mode 100644 supabase/migrations/002_collectives.sql create mode 100644 supabase/migrations/003_rls.sql create mode 100644 supabase/migrations/004_storage.sql diff --git a/CLAUDE.md b/CLAUDE.md index b62645e..838cf96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -This repository is in the **planning phase**. No application code exists yet. The repo contains: +**Fase 0 complete. Fase 1 in progress.** + - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 4) -Start by reading these documents before implementing anything. +## Local Dev: Required /etc/hosts Entry + +GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine: + +``` +127.0.0.1 keycloak +``` + +Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycloak's discovery document is unreachable from the browser. ## Planned Stack diff --git a/apps/web/.env.development b/apps/web/.env.development new file mode 100644 index 0000000..268c789 --- /dev/null +++ b/apps/web/.env.development @@ -0,0 +1,8 @@ +# Default public env vars for local development. +# Committed — these are not secrets. Secret vars (.env) remain gitignored. +PUBLIC_SUPABASE_URL=http://localhost:8001 +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNDc0MDAwMCwiZXhwIjo0NzkwNDEzNjAwfQ.F70ylwDTR13K6X_jOWkGFdYNdXrQlzIFz6gbN4aqaMM +PUBLIC_KEYCLOAK_URL=http://keycloak:8080 +PUBLIC_KEYCLOAK_REALM=colectivo +PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web +PUBLIC_APP_URL=http://localhost:5173 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9f8bd06..0e41323 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -11,15 +11,58 @@ "login_title": "Sign in to Colectivo", "login_button": "Sign in", "logout_button": "Sign out", + "auth_back_to_home": "Back to home", "onboarding_title": "Welcome to Colectivo", + "onboarding_subtitle": "Get started by creating a new collective or joining an existing one.", + "onboarding_create_tab": "Create", + "onboarding_join_tab": "Join", "onboarding_create_collective": "Create a collective", "onboarding_join_collective": "Join with an invitation link", + "onboarding_collective_name": "Collective name", + "onboarding_collective_name_placeholder": "e.g. Our Home", + "onboarding_collective_emoji": "Icon", + "onboarding_invite_link_label": "Invitation link", + "onboarding_invite_link_placeholder": "Paste the invite link here…", + "onboarding_create_btn": "Create collective", + "onboarding_join_btn": "Join collective", "collective_name_label": "Collective name", "collective_name_placeholder": "e.g. Our Home", + "invitation_title": "You're invited", + "invitation_subtitle": "You've been invited to join a collective.", + "invitation_accept_btn": "Accept invitation", + "invitation_success": "You've joined the collective!", + "invitation_invalid_link": "That doesn't look like a valid invitation link.", + "invitation_error_not_found": "This invitation link is not valid.", + "invitation_error_already_used": "This invitation has already been used.", + "invitation_error_expired": "This invitation has expired.", + "invitation_error_already_member": "You are already a member of this collective.", + "invitation_error_unauthenticated": "You must be signed in to accept an invitation.", + "invitation_error_unknown": "Something went wrong. Please try again.", + "manage_title": "Manage collective", + "manage_members": "Members", + "manage_you": "you", + "manage_remove": "Remove member", + "manage_invite_title": "Invite someone", + "manage_invite_role": "Role", + "manage_generate_link": "Generate invite link", + "manage_copy": "Copy", + "manage_copied": "Copied!", + "manage_link_expires": "Link expires in 7 days.", + "role_admin": "Admin", + "role_member": "Member", + "role_guest": "Guest", "settings_title": "Settings", "settings_display_name": "Display name", + "settings_saving": "Saving…", "settings_language": "Language", "settings_avatar": "Avatar", + "settings_avatar_initials": "Initials", + "settings_avatar_emoji": "Emoji", + "settings_avatar_upload": "Photo", + "settings_avatar_upload_btn": "Upload photo…", + "settings_account": "Account", + "settings_keycloak_link": "Change email or password", + "settings_logout": "Sign out", "settings_save": "Save changes", "settings_saved": "Changes saved", "invite_member": "Invite member", @@ -44,7 +87,7 @@ "confirm_delete": "Delete?", "cancel": "Cancel", "save": "Save", - "delete": "Delete", + "action_delete": "Delete", "edit": "Edit", "add": "Add", "done": "Done" diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 55bb88f..9ee7ea5 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -11,15 +11,58 @@ "login_title": "Accede a Colectivo", "login_button": "Iniciar sesión", "logout_button": "Cerrar sesión", + "auth_back_to_home": "Volver al inicio", "onboarding_title": "Bienvenido a Colectivo", + "onboarding_subtitle": "Crea un nuevo colectivo o únete a uno existente.", + "onboarding_create_tab": "Crear", + "onboarding_join_tab": "Unirse", "onboarding_create_collective": "Crear un colectivo", "onboarding_join_collective": "Unirme con un enlace de invitación", + "onboarding_collective_name": "Nombre del colectivo", + "onboarding_collective_name_placeholder": "p. ej. Nuestro Hogar", + "onboarding_collective_emoji": "Icono", + "onboarding_invite_link_label": "Enlace de invitación", + "onboarding_invite_link_placeholder": "Pega aquí el enlace de invitación…", + "onboarding_create_btn": "Crear colectivo", + "onboarding_join_btn": "Unirse al colectivo", "collective_name_label": "Nombre del colectivo", "collective_name_placeholder": "p. ej. Nuestro Hogar", + "invitation_title": "Tienes una invitación", + "invitation_subtitle": "Te han invitado a unirte a un colectivo.", + "invitation_accept_btn": "Aceptar invitación", + "invitation_success": "¡Te has unido al colectivo!", + "invitation_invalid_link": "Ese no parece un enlace de invitación válido.", + "invitation_error_not_found": "Este enlace de invitación no es válido.", + "invitation_error_already_used": "Esta invitación ya ha sido utilizada.", + "invitation_error_expired": "Esta invitación ha caducado.", + "invitation_error_already_member": "Ya eres miembro de este colectivo.", + "invitation_error_unauthenticated": "Debes iniciar sesión para aceptar una invitación.", + "invitation_error_unknown": "Algo salió mal. Por favor, inténtalo de nuevo.", + "manage_title": "Gestionar colectivo", + "manage_members": "Miembros", + "manage_you": "tú", + "manage_remove": "Eliminar miembro", + "manage_invite_title": "Invitar a alguien", + "manage_invite_role": "Rol", + "manage_generate_link": "Generar enlace de invitación", + "manage_copy": "Copiar", + "manage_copied": "¡Copiado!", + "manage_link_expires": "El enlace caduca en 7 días.", + "role_admin": "Administrador", + "role_member": "Miembro", + "role_guest": "Invitado", "settings_title": "Ajustes", "settings_display_name": "Nombre visible", + "settings_saving": "Guardando…", "settings_language": "Idioma", "settings_avatar": "Avatar", + "settings_avatar_initials": "Iniciales", + "settings_avatar_emoji": "Emoji", + "settings_avatar_upload": "Foto", + "settings_avatar_upload_btn": "Subir foto…", + "settings_account": "Cuenta", + "settings_keycloak_link": "Cambiar correo o contraseña", + "settings_logout": "Cerrar sesión", "settings_save": "Guardar cambios", "settings_saved": "Cambios guardados", "invite_member": "Invitar miembro", @@ -44,7 +87,7 @@ "confirm_delete": "¿Eliminar?", "cancel": "Cancelar", "save": "Guardar", - "delete": "Eliminar", + "action_delete": "Eliminar", "edit": "Editar", "add": "Añadir", "done": "Listo" diff --git a/apps/web/package.json b/apps/web/package.json index 4753ebb..af23fbf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,10 +14,10 @@ }, "dependencies": { "@colectivo/types": "workspace:*", - "@inlang/paraglide-sveltekit": "^0.12.3", + "@inlang/paraglide-sveltekit": "^0.16.1", "@supabase/supabase-js": "^2.46.2", + "cropperjs": "^1.6.2", "idb": "^8.0.1", - "keycloak-js": "^26.0.7", "svelte-dnd-action": "^0.9.51" }, "devDependencies": { diff --git a/apps/web/project.inlang/.gitignore b/apps/web/project.inlang/.gitignore new file mode 100644 index 0000000..5e46596 --- /dev/null +++ b/apps/web/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/apps/web/project.inlang/project_id b/apps/web/project.inlang/project_id new file mode 100644 index 0000000..8ea828b --- /dev/null +++ b/apps/web/project.inlang/project_id @@ -0,0 +1 @@ +55d2634950b110a0d186a22dcc078940aa1367335dd97dc5a9bb96944f5a40a4 \ No newline at end of file diff --git a/apps/web/project.inlang/settings.json b/apps/web/project.inlang/settings.json index 10aae03..d8a354b 100644 --- a/apps/web/project.inlang/settings.json +++ b/apps/web/project.inlang/settings.json @@ -4,7 +4,7 @@ "languageTags": ["en", "es"], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0.9.5/dist/index.js" + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@1/dist/index.js" ], "plugin.inlang.messageFormat": { "pathPattern": "./messages/{languageTag}.json" diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index b380675..dcfa99b 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,12 +1,9 @@ // See https://svelte.dev/docs/kit/types#app.d.ts -import type { KeycloakUser } from '$lib/auth'; declare global { namespace App { - interface Locals { - user: KeycloakUser | null; - } // interface Error {} + // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 699debd..ebd57a5 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -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 { + 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 { - 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; - 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 { - 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 { - getKeycloak().logout({ redirectUri: window.location.origin }); + await getSupabase().auth.signOut(); } diff --git a/apps/web/src/lib/components/Avatar.svelte b/apps/web/src/lib/components/Avatar.svelte new file mode 100644 index 0000000..679c18f --- /dev/null +++ b/apps/web/src/lib/components/Avatar.svelte @@ -0,0 +1,75 @@ + + +
+ {#if type === 'upload' && src && !imgError} + {name} (imgError = true)} + /> + {:else if type === 'emoji' && emoji} + + {emoji} + + {:else} + + + {initials || '?'} + + {/if} +
diff --git a/apps/web/src/lib/components/ImageCropper.svelte b/apps/web/src/lib/components/ImageCropper.svelte new file mode 100644 index 0000000..f58b38e --- /dev/null +++ b/apps/web/src/lib/components/ImageCropper.svelte @@ -0,0 +1,92 @@ + + + + + + + +
+
+

Crop photo

+
+ + +
+
+ + +
+
+
diff --git a/apps/web/src/lib/stores/auth.ts b/apps/web/src/lib/stores/auth.ts index 5928d3d..423361b 100644 --- a/apps/web/src/lib/stores/auth.ts +++ b/apps/web/src/lib/stores/auth.ts @@ -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(null); -export const isAuthenticated = writable(false); -export const currentUser = writable(null); +export const currentUser = writable(null); export const authLoading = writable(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; + return ( + (meta['full_name'] as string) ?? + (meta['name'] as string) ?? + $user.email?.split('@')[0] ?? + '' + ); +}); diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts index 879b7d4..a1943df 100644 --- a/apps/web/src/lib/supabase.ts +++ b/apps/web/src/lib/supabase.ts @@ -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 { if (!supabase) { supabase = createClient(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']; -} diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index 75a10d9..4e06a31 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -1,9 +1,12 @@ {#if $authLoading}
- {m.loading()} + {m.loading()}
-{:else if !$isAuthenticated} - -{:else} +{:else if $isAuthenticated}