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