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:
102
apps/web/src/routes/invitation/[token]/+page.svelte
Normal file
102
apps/web/src/routes/invitation/[token]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user