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

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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": {

1
apps/web/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
55d2634950b110a0d186a22dcc078940aa1367335dd97dc5a9bb96944f5a40a4

View File

@@ -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"

View File

@@ -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 {}

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;
const { error } = await getSupabase().auth.signInWithOAuth({
provider: 'keycloak',
options: {
redirectTo: redirectTo ?? `${window.location.origin}/auth/callback`,
scopes: 'openid profile email'
}
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'
});
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();
}

View 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>

View 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>

View File

@@ -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] ??
''
);
});

View File

@@ -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'];
}

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { currentCollective } from '$lib/stores/collective';
import { isAuthenticated, authLoading, displayName } from '$lib/stores/auth';
let { children }: { children: Snippet } = $props();
import { currentCollective, userCollectives } from '$lib/stores/collective';
import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte';
import * as m from '$lib/paraglide/messages';
const navItems = [
@@ -13,25 +16,63 @@
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
];
$: activePath = $page.url.pathname;
let collectiveSwitcherOpen = $state(false);
const activePath = $derived($page.url.pathname);
function switchCollective(id: string) {
const c = $userCollectives.find((col) => col.id === id);
if (c) {
currentCollective.set(c);
localStorage.setItem('activeCollectiveId', c.id);
collectiveSwitcherOpen = false;
}
}
</script>
{#if $authLoading}
<div class="flex h-screen items-center justify-center bg-background">
<span class="text-text-secondary text-sm">{m.loading()}</span>
<span class="text-sm text-text-secondary">{m.loading()}</span>
</div>
{:else if !$isAuthenticated}
<!-- Keycloak redirect is triggered in root layout; show nothing -->
{:else}
{:else if $isAuthenticated}
<div class="flex h-screen overflow-hidden bg-background">
<!-- Sidebar -->
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
<!-- Collective name -->
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
<!-- Collective header + switcher -->
<div class="relative">
<button
class="flex h-14 w-full items-center gap-2 border-b border-slate-200 px-4 text-left
hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
onclick={() => (collectiveSwitcherOpen = !collectiveSwitcherOpen)}
aria-expanded={collectiveSwitcherOpen}
>
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{$currentCollective?.name ?? m.app_name()}
</span>
{#if $userCollectives.length > 1}
<span class="text-xs text-slate-400"></span>
{/if}
</button>
{#if collectiveSwitcherOpen && $userCollectives.length > 1}
<div
class="absolute left-0 right-0 top-full z-50 border-b border-slate-200 bg-surface shadow-md dark:border-slate-700"
>
{#each $userCollectives as c}
<button
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm
hover:bg-slate-50 dark:hover:bg-slate-800
{c.id === $currentCollective?.id ? 'font-semibold text-slate-900 dark:text-slate-50' : 'text-slate-600 dark:text-slate-400'}"
onclick={() => switchCollective(c.id)}
>
<span class="text-base">{c.emoji}</span>
<span class="truncate">{c.name}</span>
</button>
{/each}
</div>
{/if}
</div>
<!-- Navigation -->
@@ -50,18 +91,13 @@
{/each}
</nav>
<!-- User + settings -->
<!-- User footer -->
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<!-- Avatar -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
>
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
</div>
<Avatar name={$displayName} size={32} />
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
{$currentUser?.displayName ?? ''}
{$displayName}
</span>
</div>
<a
@@ -77,7 +113,7 @@
<!-- Main content -->
<main class="flex flex-1 flex-col overflow-hidden">
<slot />
{@render children()}
</main>
</div>
{/if}

View File

@@ -0,0 +1,21 @@
import { browser } from '$app/environment';
import { getSupabase } from '$lib/supabase';
import { login } from '$lib/auth';
// No SSR — this is a client-rendered PWA
export const ssr = false;
export async function load() {
if (!browser) return {};
const {
data: { session }
} = await getSupabase().auth.getSession();
if (!session) {
// Not authenticated — redirect to Keycloak
await login();
}
return {};
}

View File

@@ -0,0 +1,252 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { currentCollective, collectiveMembers } from '$lib/stores/collective';
import Avatar from '$lib/components/Avatar.svelte';
import * as m from '$lib/paraglide/messages';
type Member = {
user_id: string;
role: 'admin' | 'member' | 'guest';
display_name: string;
avatar_type: 'initials' | 'emoji' | 'upload';
avatar_emoji: string | null;
avatar_url: string | null;
};
let members = $state<Member[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let inviteRole = $state<'member' | 'guest'>('member');
let generatedLink = $state<string | null>(null);
let generating = $state(false);
let copied = $state(false);
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
const isAdmin = $derived(myRole === 'admin');
onMount(async () => {
await loadMembers();
});
async function loadMembers() {
if (!$currentCollective) return;
loading = true;
const { data, error: fetchError } = await getSupabase()
.from('collective_members')
.select(`
user_id, role,
users(display_name, avatar_type, avatar_emoji, avatar_url)
`)
.eq('collective_id', $currentCollective.id)
.order('joined_at');
if (fetchError) {
error = fetchError.message;
loading = false;
return;
}
members = (data ?? []).map((row) => {
const u = row.users as { display_name: string; avatar_type: string; avatar_emoji: string | null; avatar_url: string | null } | null;
return {
user_id: row.user_id,
role: row.role as Member['role'],
display_name: u?.display_name ?? '',
avatar_type: (u?.avatar_type ?? 'initials') as Member['avatar_type'],
avatar_emoji: u?.avatar_emoji ?? null,
avatar_url: u?.avatar_url ?? null
};
});
collectiveMembers.set(members.map((m) => ({ ...m, collective_id: $currentCollective!.id })));
loading = false;
}
async function changeRole(userId: string, newRole: 'admin' | 'member' | 'guest') {
if (!$currentCollective) return;
const { error: updateError } = await getSupabase()
.from('collective_members')
.update({ role: newRole })
.eq('collective_id', $currentCollective.id)
.eq('user_id', userId);
if (updateError) {
error = updateError.message;
return;
}
members = members.map((m) => m.user_id === userId ? { ...m, role: newRole } : m);
}
async function removeMember(userId: string) {
if (!$currentCollective) return;
const { error: deleteError } = await getSupabase()
.from('collective_members')
.delete()
.eq('collective_id', $currentCollective.id)
.eq('user_id', userId);
if (deleteError) {
error = deleteError.message;
return;
}
members = members.filter((m) => m.user_id !== userId);
}
async function generateInviteLink() {
if (!$currentCollective) return;
generating = true;
generatedLink = null;
const { data, error: insertError } = await getSupabase()
.from('collective_invitations')
.insert({
collective_id: $currentCollective.id,
role: inviteRole,
created_by: $currentUser!.id
})
.select('token')
.single();
if (insertError || !data) {
error = insertError?.message ?? 'Failed to generate link.';
generating = false;
return;
}
generatedLink = `${window.location.origin}/invitation/${data.token}`;
generating = false;
}
async function copyLink() {
if (!generatedLink) return;
await navigator.clipboard.writeText(generatedLink);
copied = true;
setTimeout(() => (copied = false), 2000);
}
const ROLES: Array<'admin' | 'member' | 'guest'> = ['admin', 'member', 'guest'];
function roleLabel(role: 'admin' | 'member' | 'guest'): string {
if (role === 'admin') return m.role_admin();
if (role === 'member') return m.role_member();
return m.role_guest();
}
</script>
<div class="flex-1 overflow-y-auto p-6">
<h1 class="mb-6 text-xl font-bold text-slate-900 dark:text-slate-50">
{m.manage_title()}
</h1>
{#if error}
<p class="mb-4 text-sm text-red-600">{error}</p>
{/if}
<!-- Members list -->
<section class="mb-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-text-secondary">
{m.manage_members()}
</h2>
{#if loading}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-700">
{#each members as member}
<li class="flex items-center gap-3 py-3">
<Avatar
name={member.display_name}
type={member.avatar_type}
emoji={member.avatar_emoji}
src={member.avatar_url}
size={36}
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-900 dark:text-slate-50">
{member.display_name}
{#if member.user_id === $currentUser?.id}
<span class="ml-1 text-text-secondary">({m.manage_you()})</span>
{/if}
</p>
</div>
{#if isAdmin && member.user_id !== $currentUser?.id}
<select
value={member.role}
onchange={(e) => changeRole(member.user_id, e.currentTarget.value as Member['role'])}
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-xs
dark:border-slate-600 dark:bg-slate-800"
>
{#each ROLES as r}
<option value={r}>{roleLabel(r)}</option>
{/each}
</select>
<button
onclick={() => removeMember(member.user_id)}
class="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
dark:hover:bg-red-900/20"
aria-label={m.manage_remove()}
>✕</button>
{:else}
<span class="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600
dark:bg-slate-700 dark:text-slate-400">
{roleLabel(member.role)}
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<!-- Invite link generator (admins only) -->
{#if isAdmin}
<section class="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
<h2 class="mb-3 text-sm font-semibold text-slate-900 dark:text-slate-50">
{m.manage_invite_title()}
</h2>
<div class="mb-3 flex items-center gap-2">
<label for="invite-role" class="text-sm text-slate-700 dark:text-slate-300">{m.manage_invite_role()}</label>
<select
id="invite-role"
bind:value={inviteRole}
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-sm
dark:border-slate-600 dark:bg-slate-800"
>
<option value="member">{m.role_member()}</option>
<option value="guest">{m.role_guest()}</option>
</select>
</div>
<button
onclick={generateInviteLink}
disabled={generating}
class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
>
{generating ? m.loading() : m.manage_generate_link()}
</button>
{#if generatedLink}
<div class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2
dark:border-slate-700 dark:bg-slate-800">
<code class="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-400">
{generatedLink}
</code>
<button
onclick={copyLink}
class="flex-shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-700
hover:bg-slate-200 dark:text-slate-300 dark:hover:bg-slate-700"
>
{copied ? m.manage_copied() : m.manage_copy()}
</button>
</div>
<p class="mt-1 text-xs text-text-secondary">{m.manage_link_expires()}</p>
{/if}
</section>
{/if}
</div>

View File

@@ -1,12 +1,281 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
import {
PUBLIC_KEYCLOAK_URL,
PUBLIC_KEYCLOAK_REALM
} from '$env/static/public';
// Profile state loaded from public.users
let displayNameInput = $state('');
let language = $state<'en' | 'es'>('en');
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
let avatarEmoji = $state<string | null>(null);
let avatarUrl = $state<string | null>(null);
let saving = $state(false);
let saved = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
// Cropper
let cropperFile = $state<File | null>(null);
let uploading = $state(false);
const EMOJI_GRID = [
'😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
'🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
'🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
'🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
];
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
onMount(async () => {
await loadProfile();
});
async function loadProfile() {
if (!$currentUser) return;
const { data } = await getSupabase()
.from('users')
.select('display_name, language, avatar_type, avatar_emoji, avatar_url')
.eq('id', $currentUser.id)
.single();
if (data) {
displayNameInput = data.display_name;
language = data.language as 'en' | 'es';
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
avatarEmoji = data.avatar_emoji;
avatarUrl = data.avatar_url;
}
}
function scheduleNameSave() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(saveName, 700);
}
async function saveName() {
if (!$currentUser) return;
saving = true;
await getSupabase()
.from('users')
.update({ display_name: displayNameInput.trim() })
.eq('id', $currentUser.id);
saving = false;
saved = true;
setTimeout(() => (saved = false), 2000);
}
async function selectEmojiAvatar(emoji: string) {
avatarEmoji = emoji;
avatarType = 'emoji';
await getSupabase()
.from('users')
.update({ avatar_type: 'emoji', avatar_emoji: emoji })
.eq('id', $currentUser!.id);
}
async function selectInitialsAvatar() {
avatarType = 'initials';
await getSupabase()
.from('users')
.update({ avatar_type: 'initials', avatar_emoji: null })
.eq('id', $currentUser!.id);
}
function onFileInput(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) cropperFile = file;
input.value = '';
}
async function onCrop(blob: Blob) {
cropperFile = null;
if (!$currentUser) return;
uploading = true;
const path = `${$currentUser.id}/avatar.webp`;
const { error: uploadError } = await getSupabase()
.storage
.from('avatars')
.upload(path, blob, { upsert: true, contentType: 'image/webp' });
if (uploadError) {
uploading = false;
return;
}
const { data: urlData } = await getSupabase()
.storage
.from('avatars')
.createSignedUrl(path, 60 * 60 * 24 * 7); // 7 days
const signedUrl = urlData?.signedUrl ?? null;
await getSupabase()
.from('users')
.update({ avatar_type: 'upload', avatar_url: signedUrl })
.eq('id', $currentUser.id);
avatarType = 'upload';
avatarUrl = signedUrl;
uploading = false;
}
async function switchLanguage(lang: 'en' | 'es') {
language = lang;
setLanguageTag(lang);
await getSupabase()
.from('users')
.update({ language: lang })
.eq('id', $currentUser!.id);
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 1: settings content (display name, avatar, language) -->
<div class="mx-auto max-w-md space-y-8">
<!-- Avatar preview -->
<div class="flex justify-center">
<Avatar
name={displayNameInput || $currentUser?.email || ''}
type={avatarType}
emoji={avatarEmoji}
src={avatarUrl}
size={80}
/>
</div>
<!-- Display name -->
<section>
<label for="display-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.settings_display_name()}
</label>
<div class="relative">
<input id="display-name"
type="text"
bind:value={displayNameInput}
oninput={scheduleNameSave}
maxlength="60"
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
{#if saving}
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
{:else if saved}
<span class="absolute right-3 top-2 text-xs text-emerald-600"></span>
{/if}
</div>
</section>
<!-- Avatar -->
<section>
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
{m.settings_avatar()}
</p>
<!-- Mode tabs -->
<div class="mb-4 flex gap-2">
{#each [['initials', m.settings_avatar_initials()], ['emoji', m.settings_avatar_emoji()], ['upload', m.settings_avatar_upload()]] as [mode, label]}
<button
onclick={() => mode === 'initials' ? selectInitialsAvatar() : avatarType = mode as typeof avatarType}
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
{avatarType === mode
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
>
{label}
</button>
{/each}
</div>
{#if avatarType === 'emoji'}
<div class="grid grid-cols-8 gap-1">
{#each EMOJI_GRID as e}
<button
onclick={() => selectEmojiAvatar(e)}
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
aria-label={e}
>{e}</button>
{/each}
</div>
{:else if avatarType === 'upload'}
<label
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
border-slate-300 px-4 py-3 text-sm text-slate-600 hover:border-slate-500
dark:border-slate-600 dark:text-slate-400"
>
<span>{uploading ? m.loading() : m.settings_avatar_upload_btn()}</span>
<input type="file" accept="image/*" class="hidden" onchange={onFileInput} />
</label>
{/if}
</section>
<!-- Language -->
<section>
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
{m.settings_language()}
</p>
<div class="flex gap-2">
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
<button
onclick={() => switchLanguage(code as 'en' | 'es')}
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
{language === code
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
>
{label}
</button>
{/each}
</div>
</section>
<!-- Account -->
<section class="border-t border-slate-200 pt-6 dark:border-slate-700">
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
{m.settings_account()}
</p>
<a
href={keycloakAccountUrl}
target="_blank"
rel="noopener noreferrer"
class="mb-3 flex items-center gap-2 text-sm text-slate-600 underline dark:text-slate-400"
>
{m.settings_keycloak_link()}
</a>
<button
onclick={logout}
class="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600
hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
{m.settings_logout()}
</button>
</section>
</div>
</div>
</div>
{#if cropperFile}
<ImageCropper
file={cropperFile}
{onCrop}
onCancel={() => (cropperFile = null)}
/>
{/if}

View File

@@ -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);
const authenticated = await initAuth();
isAuthenticated.set(authenticated);
if (authenticated) {
const user = getUser(kc);
currentUser.set(user);
if (kc.token) setSupabaseToken(kc.token);
// Keep token in sync with Supabase client
kc.onTokenRefreshed = () => {
if (kc.token) setSupabaseToken(kc.token);
};
kc.onAuthLogout = () => {
clearSupabaseToken();
isAuthenticated.set(false);
currentUser.set(null);
};
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some((r) => pathname.startsWith(r));
}
onMount(() => {
const supabase = getSupabase();
const {
data: { subscription }
} = supabase.auth.onAuthStateChange(async (event, session) => {
currentUser.set(session?.user ?? 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}>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getSupabase } from '$lib/supabase';
import * as m from '$lib/paraglide/messages';
let error = $state<string | null>(null);
onMount(async () => {
const code = $page.url.searchParams.get('code');
if (!code) {
error = 'No authorization code received.';
return;
}
const { error: exchangeError } = await getSupabase().auth.exchangeCodeForSession(code);
if (exchangeError) {
error = exchangeError.message;
return;
}
// onAuthStateChange in root layout handles post-login routing
goto('/');
});
</script>
<div class="flex h-screen items-center justify-center bg-background">
{#if error}
<div class="max-w-sm text-center">
<p class="mb-4 text-sm text-red-600">{error}</p>
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
</div>
{:else}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{/if}
</div>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getSupabase } from '$lib/supabase';
import { isAuthenticated } from '$lib/stores/auth';
import { login } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
type Status = 'loading' | 'ready' | 'accepting' | 'error' | 'success';
let status = $state<Status>('loading');
let errorKey = $state<string>('');
const token = $page.params.token ?? '';
onMount(async () => {
if (!token) {
errorKey = 'not_found';
status = 'error';
return;
}
if (!$isAuthenticated) {
// After login, GoTrue redirects to /auth/callback which goes to /.
// We store the invitation token so we can resume after login.
sessionStorage.setItem('pendingInvitationToken', token);
await login(`${window.location.origin}/auth/callback`);
return;
}
status = 'ready';
});
async function accept() {
status = 'accepting';
const { data, error } = await getSupabase().rpc('accept_invitation', { p_token: token });
if (error || !data) {
errorKey = error?.message ?? 'unknown';
status = 'error';
return;
}
const result = data as { ok?: boolean; error?: string; collective_id?: string };
if (result.error) {
errorKey = result.error;
status = 'error';
return;
}
status = 'success';
setTimeout(() => goto('/lists'), 1500);
}
function errorMessage(key: string): string {
const map: Record<string, string> = {
not_found: m.invitation_error_not_found(),
already_used: m.invitation_error_already_used(),
expired: m.invitation_error_expired(),
already_member: m.invitation_error_already_member(),
unauthenticated: m.invitation_error_unauthenticated()
};
return map[key] ?? m.invitation_error_unknown();
}
</script>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-sm text-center">
{#if status === 'loading'}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if status === 'ready'}
<div class="mb-6 text-5xl">🏠</div>
<h1 class="mb-2 text-xl font-bold text-slate-900 dark:text-slate-50">
{m.invitation_title()}
</h1>
<p class="mb-8 text-sm text-text-secondary">{m.invitation_subtitle()}</p>
<button
onclick={accept}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.invitation_accept_btn()}
</button>
{:else if status === 'accepting'}
<p class="text-sm text-text-secondary">{m.loading()}</p>
{:else if status === 'success'}
<div class="mb-4 text-4xl"></div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">
{m.invitation_success()}
</p>
{:else if status === 'error'}
<div class="mb-4 text-4xl"></div>
<p class="mb-4 text-sm text-red-600">{errorMessage(errorKey)}</p>
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
{/if}
</div>
</div>

View File

@@ -1,25 +1,186 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import { currentCollective, userCollectives } from '$lib/stores/collective';
import * as m from '$lib/paraglide/messages';
type Tab = 'create' | 'join';
let activeTab = $state<Tab>('create');
// Create collective form
let collectiveName = $state('');
let collectiveEmoji = $state('🏠');
let creating = $state(false);
let createError = $state<string | null>(null);
const EMOJI_OPTIONS = [
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
];
// Join via invite link
let inviteLink = $state('');
let joining = $state(false);
let joinError = $state<string | null>(null);
async function createCollective() {
if (!collectiveName.trim()) return;
creating = true;
createError = null;
const supabase = getSupabase();
const userId = $currentUser?.id;
if (!userId) return;
const { data: collective, error: collectiveError } = await supabase
.from('collectives')
.insert({ name: collectiveName.trim(), emoji: collectiveEmoji, created_by: userId })
.select()
.single();
if (collectiveError || !collective) {
createError = collectiveError?.message ?? 'Failed to create collective.';
creating = false;
return;
}
const { error: memberError } = await supabase
.from('collective_members')
.insert({ collective_id: collective.id, user_id: userId, role: 'admin' });
if (memberError) {
createError = memberError.message;
creating = false;
return;
}
userCollectives.update((list) => [...list, collective]);
currentCollective.set(collective);
localStorage.setItem('activeCollectiveId', collective.id);
goto('/lists');
}
async function joinViaLink() {
joinError = null;
const tokenMatch = inviteLink.trim().match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
if (!tokenMatch) {
joinError = m.invitation_invalid_link();
return;
}
goto(`/invitation/${tokenMatch[1]}`);
}
</script>
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
<div class="w-full max-w-sm space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
<div class="flex min-h-screen items-center justify-center bg-background px-4">
<div class="w-full max-w-md">
<h1 class="mb-2 text-2xl font-bold text-slate-900 dark:text-slate-50">
{m.onboarding_title()}
</h1>
<p class="mb-8 text-sm text-text-secondary">{m.onboarding_subtitle()}</p>
<!-- Tab switcher -->
<div class="mb-6 flex rounded-lg border border-slate-200 p-1 dark:border-slate-700">
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'create'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
onclick={() => (activeTab = 'create')}
>
{m.onboarding_create_tab()}
</button>
<button
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'join'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-50'}"
onclick={() => (activeTab = 'join')}
>
{m.onboarding_join_tab()}
</button>
</div>
<!-- Fase 1: collective creation / join form -->
<div class="space-y-3">
{#if activeTab === 'create'}
<form onsubmit={(e) => { e.preventDefault(); createCollective(); }} class="space-y-4">
<div>
<label for="collective-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_collective_name()}
</label>
<input
id="collective-name"
type="text"
bind:value={collectiveName}
placeholder={m.onboarding_collective_name_placeholder()}
maxlength="60"
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
</div>
<div>
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_collective_emoji()}
</p>
<div class="grid grid-cols-8 gap-1">
{#each EMOJI_OPTIONS as e}
<button
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.onboarding_create_collective()}
</button>
type="button"
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
{collectiveEmoji === e
? 'bg-slate-900 dark:bg-slate-100'
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
onclick={() => (collectiveEmoji = e)}
aria-label={e}
>{e}</button>
{/each}
</div>
</div>
{#if createError}
<p class="text-sm text-red-600">{createError}</p>
{/if}
<button
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
type="submit"
disabled={!collectiveName.trim() || creating}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
>
{m.onboarding_join_collective()}
{creating ? m.loading() : m.onboarding_create_btn()}
</button>
</div>
</form>
{:else}
<form onsubmit={(e) => { e.preventDefault(); joinViaLink(); }} class="space-y-4">
<div>
<label for="invite-link" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
{m.onboarding_invite_link_label()}
</label>
<input
id="invite-link"
type="text"
bind:value={inviteLink}
placeholder={m.onboarding_invite_link_placeholder()}
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
</div>
{#if joinError}
<p class="text-sm text-red-600">{joinError}</p>
{/if}
<button
type="submit"
disabled={!inviteLink.trim() || joining}
class="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
transition-opacity hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
>
{joining ? m.loading() : m.onboarding_join_btn()}
</button>
</form>
{/if}
</div>
</div>

View File

@@ -52,7 +52,11 @@ services:
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true"
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web}
GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ""
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${PUBLIC_APP_URL:-http://localhost:5173}/auth/callback
# GoTrue fetches Keycloak's OIDC discovery doc at this URL (Docker-internal hostname).
# The browser also uses this hostname — add "127.0.0.1 keycloak" to /etc/hosts.
GOTRUE_EXTERNAL_KEYCLOAK_URL: http://keycloak:8080/realms/colectivo
# Keycloak redirects the browser here after login; GoTrue exchanges the code.
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: http://localhost:8001/auth/v1/callback
GOTRUE_MAILER_AUTOCONFIRM: "true"
# ── PostgREST ───────────────────────────────────────────────────────────────

View File

@@ -29,11 +29,14 @@
},
"redirectUris": [
"http://localhost:5173/*",
"http://localhost:3000/*"
"http://localhost:3000/*",
"http://localhost:8001/auth/v1/callback",
"http://keycloak:8080/auth/v1/callback"
],
"webOrigins": [
"http://localhost:5173",
"http://localhost:3000"
"http://localhost:3000",
"http://localhost:8001"
],
"fullScopeAllowed": true,
"defaultClientScopes": [

View File

@@ -1,14 +1,190 @@
// AUTO-GENERATED — do not edit manually.
// Run `just db-types` to regenerate from Supabase schema.
// Placeholder until migrations exist and `supabase gen types typescript` can run.
// Manually seeded from migrations until `supabase gen types typescript` can run.
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type LanguageCode = 'en' | 'es';
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
export interface Database {
public: {
Tables: Record<string, never>;
Tables: {
users: {
Row: {
id: string;
email: string;
display_name: string;
language: LanguageCode;
avatar_type: AvatarTypeEnum;
avatar_emoji: string | null;
avatar_url: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
email: string;
display_name?: string;
language?: LanguageCode;
avatar_type?: AvatarTypeEnum;
avatar_emoji?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
email?: string;
display_name?: string;
language?: LanguageCode;
avatar_type?: AvatarTypeEnum;
avatar_emoji?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
};
Relationships: [];
};
collectives: {
Row: {
id: string;
name: string;
emoji: string;
created_by: string;
created_at: string;
};
Insert: {
id?: string;
name: string;
emoji?: string;
created_by: string;
created_at?: string;
};
Update: {
id?: string;
name?: string;
emoji?: string;
created_by?: string;
created_at?: string;
};
Relationships: [
{
foreignKeyName: 'collectives_created_by_fkey';
columns: ['created_by'];
referencedRelation: 'users';
referencedColumns: ['id'];
}
];
};
collective_members: {
Row: {
collective_id: string;
user_id: string;
role: MemberRoleEnum;
joined_at: string;
};
Insert: {
collective_id: string;
user_id: string;
role?: MemberRoleEnum;
joined_at?: string;
};
Update: {
collective_id?: string;
user_id?: string;
role?: MemberRoleEnum;
joined_at?: string;
};
Relationships: [
{
foreignKeyName: 'collective_members_collective_id_fkey';
columns: ['collective_id'];
referencedRelation: 'collectives';
referencedColumns: ['id'];
},
{
foreignKeyName: 'collective_members_user_id_fkey';
columns: ['user_id'];
referencedRelation: 'users';
referencedColumns: ['id'];
}
];
};
collective_invitations: {
Row: {
id: string;
collective_id: string;
token: string;
role: MemberRoleEnum;
created_by: string;
created_at: string;
expires_at: string;
accepted_at: string | null;
accepted_by: string | null;
};
Insert: {
id?: string;
collective_id: string;
token?: string;
role?: MemberRoleEnum;
created_by: string;
created_at?: string;
expires_at?: string;
accepted_at?: string | null;
accepted_by?: string | null;
};
Update: {
id?: string;
collective_id?: string;
token?: string;
role?: MemberRoleEnum;
created_by?: string;
created_at?: string;
expires_at?: string;
accepted_at?: string | null;
accepted_by?: string | null;
};
Relationships: [
{
foreignKeyName: 'collective_invitations_collective_id_fkey';
columns: ['collective_id'];
referencedRelation: 'collectives';
referencedColumns: ['id'];
},
{
foreignKeyName: 'collective_invitations_created_by_fkey';
columns: ['created_by'];
referencedRelation: 'users';
referencedColumns: ['id'];
}
];
};
};
Views: Record<string, never>;
Functions: Record<string, never>;
Enums: Record<string, never>;
Functions: {
accept_invitation: {
Args: { p_token: string };
Returns: Json;
};
is_active_member: {
Args: { p_collective_id: string };
Returns: boolean;
};
is_member: {
Args: { p_collective_id: string };
Returns: boolean;
};
is_admin: {
Args: { p_collective_id: string };
Returns: boolean;
};
};
Enums: {
language_code: LanguageCode;
avatar_type: AvatarTypeEnum;
member_role: MemberRoleEnum;
};
};
}

6814
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -65,10 +65,14 @@ enable_confirmations = false
enable_signup = false
enable_confirmations = false
# Keycloak as OIDC provider
# The JWT_SECRET must be set to the Keycloak RS256 public key
# Keycloak as external OIDC provider via GoTrue (Option B).
# GoTrue acts as OIDC proxy: app → GoTrue → Keycloak → GoTrue issues Supabase JWT.
# Configured via environment variables in docker-compose.dev.yml:
# GOTRUE_EXTERNAL_KEYCLOAK_ENABLED, GOTRUE_EXTERNAL_KEYCLOAK_URL, etc.
# The [auth.third_party.keycloak] section is for Supabase CLI managed stacks;
# we use docker-compose directly so this section is intentionally left empty.
[auth.third_party.keycloak]
enabled = false # We bypass GoTrue — Keycloak JWT validated directly by PostgREST
enabled = false
[edge_runtime]
enabled = true

View File

@@ -0,0 +1,87 @@
-- Migration 001: users table
-- Mirrors GoTrue auth.users — populated by trigger on first OIDC login.
-- ── Enums ─────────────────────────────────────────────────────────────────────
CREATE TYPE public.language_code AS ENUM ('en', 'es');
CREATE TYPE public.avatar_type AS ENUM ('initials', 'emoji', 'upload');
-- ── Table ─────────────────────────────────────────────────────────────────────
CREATE TABLE public.users (
id uuid PRIMARY KEY, -- matches auth.users.id (GoTrue UUID)
email text NOT NULL,
display_name text NOT NULL DEFAULT '',
language public.language_code NOT NULL DEFAULT 'en',
avatar_type public.avatar_type NOT NULL DEFAULT 'initials',
avatar_emoji text NULL,
avatar_url text NULL, -- signed URL from Supabase Storage
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- ── updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- ── Sync trigger: GoTrue auth.users → public.users on first OIDC login ────────
-- GoTrue inserts into auth.users after a successful Keycloak OIDC exchange.
-- We mirror display_name and detect language from raw_user_meta_data claims.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_display_name text;
v_language public.language_code;
v_raw jsonb;
BEGIN
v_raw := NEW.raw_user_meta_data;
-- Build display name: full_name > name > email prefix
v_display_name := COALESCE(
NULLIF(trim(v_raw->>'full_name'), ''),
NULLIF(trim(v_raw->>'name'), ''),
split_part(NEW.email, '@', 1)
);
-- Detect language from OIDC locale claim; default en
v_language := CASE
WHEN v_raw->>'locale' ILIKE 'es%' THEN 'es'::public.language_code
ELSE 'en'::public.language_code
END;
INSERT INTO public.users (id, email, display_name, language)
VALUES (NEW.id, NEW.email, v_display_name, v_language)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
-- Preserve user-set display name; only backfill if still empty
display_name = CASE
WHEN public.users.display_name = '' THEN EXCLUDED.display_name
ELSE public.users.display_name
END;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

View File

@@ -0,0 +1,99 @@
-- Migration 002: collectives, collective_members, collective_invitations
-- ── Enums ─────────────────────────────────────────────────────────────────────
CREATE TYPE public.member_role AS ENUM ('admin', 'member', 'guest');
-- ── collectives ───────────────────────────────────────────────────────────────
CREATE TABLE public.collectives (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
emoji text NOT NULL DEFAULT '🏠',
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.collectives ENABLE ROW LEVEL SECURITY;
-- ── collective_members ────────────────────────────────────────────────────────
CREATE TABLE public.collective_members (
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
role public.member_role NOT NULL DEFAULT 'member',
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collective_id, user_id)
);
ALTER TABLE public.collective_members ENABLE ROW LEVEL SECURITY;
CREATE INDEX collective_members_user_idx ON public.collective_members (user_id);
-- ── collective_invitations ────────────────────────────────────────────────────
CREATE TABLE public.collective_invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
token uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
role public.member_role NOT NULL DEFAULT 'member',
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '7 days'),
accepted_at timestamptz NULL,
accepted_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL
);
ALTER TABLE public.collective_invitations ENABLE ROW LEVEL SECURITY;
CREATE INDEX collective_invitations_token_idx ON public.collective_invitations (token);
CREATE INDEX collective_invitations_collective_idx ON public.collective_invitations (collective_id);
-- ── Trigger: auto-promote oldest member when last admin deletes account ────────
-- Fires BEFORE DELETE on public.users. If the departing user is the sole admin
-- of any collective, the longest-standing member (by joined_at) is promoted to
-- admin before the delete proceeds, satisfying business rule RN-10.
CREATE OR REPLACE FUNCTION public.promote_oldest_member_on_admin_leave()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
r RECORD;
BEGIN
-- For each collective where the departing user is an admin:
FOR r IN
SELECT cm.collective_id
FROM public.collective_members cm
WHERE cm.user_id = OLD.id
AND cm.role = 'admin'
-- Only act when this is the SOLE remaining admin
AND (
SELECT count(*)
FROM public.collective_members
WHERE collective_id = cm.collective_id
AND role = 'admin'
) = 1
LOOP
-- Promote the earliest-joined non-admin member in that collective
UPDATE public.collective_members
SET role = 'admin'
WHERE (collective_id, user_id) = (
SELECT collective_id, user_id
FROM public.collective_members
WHERE collective_id = r.collective_id
AND user_id <> OLD.id
ORDER BY joined_at ASC
LIMIT 1
);
END LOOP;
RETURN OLD;
END;
$$;
CREATE TRIGGER promote_on_admin_leave
BEFORE DELETE ON public.users
FOR EACH ROW EXECUTE FUNCTION public.promote_oldest_member_on_admin_leave();

View File

@@ -0,0 +1,229 @@
-- Migration 003: RLS policies + is_active_member helper
-- All business access rules live here. Every table has RLS enabled in its own
-- migration; this file only adds the policies.
-- ── Helper: is_active_member ──────────────────────────────────────────────────
-- Returns true if the calling user is a non-guest member of the collective.
-- Used by all content-table policies. Declared STABLE so PostgreSQL can cache
-- the result within a single statement.
CREATE OR REPLACE FUNCTION public.is_active_member(p_collective_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.collective_members
WHERE collective_id = p_collective_id
AND user_id = auth.uid()
AND role IN ('admin', 'member')
);
$$;
-- Variant that also accepts guests (read access).
CREATE OR REPLACE FUNCTION public.is_member(p_collective_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.collective_members
WHERE collective_id = p_collective_id
AND user_id = auth.uid()
);
$$;
CREATE OR REPLACE FUNCTION public.is_admin(p_collective_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.collective_members
WHERE collective_id = p_collective_id
AND user_id = auth.uid()
AND role = 'admin'
);
$$;
-- ── public.users policies ─────────────────────────────────────────────────────
-- Any authenticated user can read their own row.
CREATE POLICY users_select_own
ON public.users FOR SELECT
USING (id = auth.uid());
-- Members can read profiles of users in the same collective.
CREATE POLICY users_select_collective_peers
ON public.users FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.collective_members cm1
JOIN public.collective_members cm2
ON cm2.collective_id = cm1.collective_id
AND cm2.user_id = auth.uid()
WHERE cm1.user_id = public.users.id
)
);
-- A user can only update their own row.
CREATE POLICY users_update_own
ON public.users FOR UPDATE
USING (id = auth.uid())
WITH CHECK (id = auth.uid());
-- Insert is handled by the trigger (SECURITY DEFINER). No direct INSERT allowed.
CREATE POLICY users_insert_deny
ON public.users FOR INSERT
WITH CHECK (false);
-- Delete cascades from auth.users deletion; no direct DELETE allowed here.
CREATE POLICY users_delete_deny
ON public.users FOR DELETE
USING (false);
-- ── public.collectives policies ───────────────────────────────────────────────
-- Members (any role) can read collectives they belong to.
CREATE POLICY collectives_select
ON public.collectives FOR SELECT
USING (public.is_member(id));
-- Any authenticated user can create a collective (they become its first admin).
CREATE POLICY collectives_insert
ON public.collectives FOR INSERT
WITH CHECK (created_by = auth.uid());
-- Only admins can update the collective name / emoji.
CREATE POLICY collectives_update
ON public.collectives FOR UPDATE
USING (public.is_admin(id))
WITH CHECK (public.is_admin(id));
-- Admins can delete a collective.
CREATE POLICY collectives_delete
ON public.collectives FOR DELETE
USING (public.is_admin(id));
-- ── public.collective_members policies ───────────────────────────────────────
-- Members of a collective can read its member list.
CREATE POLICY collective_members_select
ON public.collective_members FOR SELECT
USING (public.is_member(collective_id));
-- Only admins can add members (accepting invitations handled via server action
-- with SECURITY DEFINER function, not direct INSERT).
CREATE POLICY collective_members_insert
ON public.collective_members FOR INSERT
WITH CHECK (public.is_admin(collective_id));
-- Only admins can change roles; users cannot promote themselves.
CREATE POLICY collective_members_update
ON public.collective_members FOR UPDATE
USING (public.is_admin(collective_id))
WITH CHECK (public.is_admin(collective_id));
-- Admins can remove any member; members can remove themselves.
CREATE POLICY collective_members_delete
ON public.collective_members FOR DELETE
USING (
public.is_admin(collective_id)
OR user_id = auth.uid()
);
-- ── public.collective_invitations policies ────────────────────────────────────
-- Members of a collective can read its invitations (to copy the link).
CREATE POLICY collective_invitations_select
ON public.collective_invitations FOR SELECT
USING (public.is_member(collective_id));
-- Only admins can create invitations.
CREATE POLICY collective_invitations_insert
ON public.collective_invitations FOR INSERT
WITH CHECK (
public.is_admin(collective_id)
AND created_by = auth.uid()
);
-- Only admins can revoke (delete) invitations.
CREATE POLICY collective_invitations_delete
ON public.collective_invitations FOR DELETE
USING (public.is_admin(collective_id));
-- Accepting an invitation (setting accepted_at) is done via a SECURITY DEFINER
-- server action — no UPDATE policy needed here.
CREATE POLICY collective_invitations_update_deny
ON public.collective_invitations FOR UPDATE
USING (false);
-- ── SECURITY DEFINER: accept invitation ──────────────────────────────────────
-- Called from the SvelteKit server action for /invitation/[token].
-- Validates token, expiry, and that user isn't already a member, then inserts
-- into collective_members and stamps accepted_at.
CREATE OR REPLACE FUNCTION public.accept_invitation(p_token uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_invitation public.collective_invitations%ROWTYPE;
v_user_id uuid := auth.uid();
BEGIN
IF v_user_id IS NULL THEN
RETURN jsonb_build_object('error', 'unauthenticated');
END IF;
SELECT * INTO v_invitation
FROM public.collective_invitations
WHERE token = p_token
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('error', 'not_found');
END IF;
IF v_invitation.accepted_at IS NOT NULL THEN
RETURN jsonb_build_object('error', 'already_used');
END IF;
IF v_invitation.expires_at < now() THEN
RETURN jsonb_build_object('error', 'expired');
END IF;
IF EXISTS (
SELECT 1 FROM public.collective_members
WHERE collective_id = v_invitation.collective_id
AND user_id = v_user_id
) THEN
RETURN jsonb_build_object('error', 'already_member');
END IF;
INSERT INTO public.collective_members (collective_id, user_id, role)
VALUES (v_invitation.collective_id, v_user_id, v_invitation.role);
UPDATE public.collective_invitations
SET accepted_at = now(),
accepted_by = v_user_id
WHERE id = v_invitation.id;
RETURN jsonb_build_object(
'ok', true,
'collective_id', v_invitation.collective_id,
'role', v_invitation.role
);
END;
$$;

View File

@@ -0,0 +1,41 @@
-- Migration 004: Storage bucket for user avatars
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars',
'avatars',
false, -- private: access via signed URLs only
2097152, -- 2 MB per file
ARRAY['image/webp', 'image/jpeg', 'image/png', 'image/gif']
)
ON CONFLICT (id) DO NOTHING;
-- Storage RLS: a user can read/write only their own avatar folder ({user_id}/avatar.*)
CREATE POLICY avatars_select
ON storage.objects FOR SELECT
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY avatars_insert
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY avatars_update
ON storage.objects FOR UPDATE
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY avatars_delete
ON storage.objects FOR DELETE
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);