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

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Props {
file: File;
onCrop: (blob: Blob) => void;
onCancel: () => void;
}
let { file, onCrop, onCancel }: Props = $props();
let canvasEl: HTMLCanvasElement;
let imgEl: HTMLImageElement;
let cropperInstance: unknown = null;
let objectUrl = $state('');
onMount(async () => {
// Lazy-load Cropper.js only when the component mounts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CropperModule = await import('cropperjs') as any;
const Cropper = CropperModule.default ?? CropperModule;
objectUrl = URL.createObjectURL(file);
imgEl.src = objectUrl;
imgEl.onload = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cropperInstance = new (Cropper as any)(imgEl, {
aspectRatio: 1,
viewMode: 1,
autoCropArea: 0.8,
movable: true,
zoomable: true,
rotatable: false,
scalable: false
});
};
});
onDestroy(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cropperInstance as any)?.destroy();
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
function crop() {
if (!cropperInstance) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canvas: HTMLCanvasElement = (cropperInstance as any).getCroppedCanvas({ width: 256, height: 256 });
canvas.toBlob(
(blob) => {
if (blob) onCrop(blob);
},
'image/webp',
0.85
);
}
</script>
<!-- Import Cropper.js CSS via cdn-style bundled import -->
<svelte:head>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"
/>
</svelte:head>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-sm rounded-xl bg-white p-4 dark:bg-slate-900">
<p class="mb-3 text-sm font-semibold text-slate-900 dark:text-slate-50">Crop photo</p>
<div class="mb-4 max-h-72 overflow-hidden rounded-lg">
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={imgEl} class="block max-w-full" />
</div>
<div class="flex gap-2">
<button
onclick={onCancel}
class="flex-1 rounded-lg border border-slate-300 px-4 py-2 text-sm text-slate-700
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300"
>
Cancel
</button>
<button
onclick={crop}
class="flex-1 rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
Use photo
</button>
</div>
</div>
</div>