feat(fase-1): auth, collective management, and DB migrations

- SQL migrations: users, collectives, collective_members, collective_invitations,
  full RLS policies, is_active_member/is_member/is_admin helpers,
  accept_invitation SECURITY DEFINER function, admin auto-promote trigger,
  avatars storage bucket
- Auth refactor: replace keycloak-js with Supabase OAuth (GoTrue OIDC proxy,
  Option B). signInWithOAuth({ provider: 'keycloak' }) + PKCE flow
- GoTrue config: GOTRUE_EXTERNAL_KEYCLOAK_URL + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI
  added to docker-compose.dev.yml; Keycloak realm updated with GoTrue callback URI
- New routes: /auth/callback, /invitation/[token], /(app)/collective/manage
- Functional onboarding: create collective or join via invite link
- Full settings page: display name (debounced), avatar (initials/emoji/upload),
  language switcher, Keycloak account console link
- Components: Avatar.svelte (initials/emoji/upload with fallback),
  ImageCropper.svelte (cropperjs, 1:1 crop, lazy-loaded)
- i18n: added all Fase 1 message keys (en + es); renamed 'delete' → 'action_delete'
  (JS reserved word); fixed plugin-m-function-matcher major-version URL format
- Database types: manually seeded packages/types/src/database.ts from migrations
- .env.development: committed public dev defaults for SvelteKit type checking
- CLAUDE.md: documented /etc/hosts requirement for keycloak hostname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 15:29:29 +02:00
parent f197a81a42
commit 7d91705fc2
31 changed files with 8770 additions and 199 deletions

View File

@@ -1,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">
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{$currentCollective?.name ?? m.app_name()}
</span>
<!-- 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="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}