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,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">
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{m.onboarding_join_collective()}
|
||||
</button>
|
||||
</div>
|
||||
{#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
|
||||
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
|
||||
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"
|
||||
>
|
||||
{creating ? m.loading() : m.onboarding_create_btn()}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user