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,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}