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:
75
apps/web/src/lib/components/Avatar.svelte
Normal file
75
apps/web/src/lib/components/Avatar.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Display name — used to generate initials and background colour. */
|
||||
name: string;
|
||||
/** Avatar type. Defaults to initials. */
|
||||
type?: 'initials' | 'emoji' | 'upload';
|
||||
/** Emoji character when type = 'emoji'. */
|
||||
emoji?: string | null;
|
||||
/** Image URL when type = 'upload'. */
|
||||
src?: string | null;
|
||||
/** Diameter in px. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, type = 'initials', emoji = null, src = null, size = 36 }: Props = $props();
|
||||
|
||||
let imgError = $state(false);
|
||||
|
||||
// Two initials from display name
|
||||
const initials = $derived(
|
||||
name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
);
|
||||
|
||||
// Deterministic background colour from name (one of 8 slate-adjacent tones)
|
||||
const COLOURS = [
|
||||
'bg-slate-400',
|
||||
'bg-blue-400',
|
||||
'bg-violet-400',
|
||||
'bg-emerald-400',
|
||||
'bg-amber-400',
|
||||
'bg-rose-400',
|
||||
'bg-cyan-400',
|
||||
'bg-pink-400'
|
||||
];
|
||||
const colourClass = $derived(
|
||||
COLOURS[
|
||||
name.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % COLOURS.length
|
||||
]
|
||||
);
|
||||
|
||||
const fontSize = $derived(Math.round(size * 0.38));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
>
|
||||
{#if type === 'upload' && src && !imgError}
|
||||
<img
|
||||
{src}
|
||||
alt={name}
|
||||
class="h-full w-full object-cover"
|
||||
onerror={() => (imgError = true)}
|
||||
/>
|
||||
{:else if type === 'emoji' && emoji}
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center bg-slate-100 dark:bg-slate-700"
|
||||
style="font-size: {fontSize * 1.4}px;"
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Initials fallback (also used when upload fails) -->
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center font-semibold text-white {colourClass}"
|
||||
style="font-size: {fontSize}px;"
|
||||
>
|
||||
{initials || '?'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
92
apps/web/src/lib/components/ImageCropper.svelte
Normal file
92
apps/web/src/lib/components/ImageCropper.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user