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:
252
apps/web/src/routes/(app)/collective/manage/+page.svelte
Normal file
252
apps/web/src/routes/(app)/collective/manage/+page.svelte
Normal file
@@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, collectiveMembers } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Member = {
|
||||
user_id: string;
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
display_name: string;
|
||||
avatar_type: 'initials' | 'emoji' | 'upload';
|
||||
avatar_emoji: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
let members = $state<Member[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let inviteRole = $state<'member' | 'guest'>('member');
|
||||
let generatedLink = $state<string | null>(null);
|
||||
let generating = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
|
||||
const isAdmin = $derived(myRole === 'admin');
|
||||
|
||||
onMount(async () => {
|
||||
await loadMembers();
|
||||
});
|
||||
|
||||
async function loadMembers() {
|
||||
if (!$currentCollective) return;
|
||||
loading = true;
|
||||
|
||||
const { data, error: fetchError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.select(`
|
||||
user_id, role,
|
||||
users(display_name, avatar_type, avatar_emoji, avatar_url)
|
||||
`)
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.order('joined_at');
|
||||
|
||||
if (fetchError) {
|
||||
error = fetchError.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
members = (data ?? []).map((row) => {
|
||||
const u = row.users as { display_name: string; avatar_type: string; avatar_emoji: string | null; avatar_url: string | null } | null;
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
role: row.role as Member['role'],
|
||||
display_name: u?.display_name ?? '',
|
||||
avatar_type: (u?.avatar_type ?? 'initials') as Member['avatar_type'],
|
||||
avatar_emoji: u?.avatar_emoji ?? null,
|
||||
avatar_url: u?.avatar_url ?? null
|
||||
};
|
||||
});
|
||||
collectiveMembers.set(members.map((m) => ({ ...m, collective_id: $currentCollective!.id })));
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function changeRole(userId: string, newRole: 'admin' | 'member' | 'guest') {
|
||||
if (!$currentCollective) return;
|
||||
const { error: updateError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.update({ role: newRole })
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (updateError) {
|
||||
error = updateError.message;
|
||||
return;
|
||||
}
|
||||
members = members.map((m) => m.user_id === userId ? { ...m, role: newRole } : m);
|
||||
}
|
||||
|
||||
async function removeMember(userId: string) {
|
||||
if (!$currentCollective) return;
|
||||
const { error: deleteError } = await getSupabase()
|
||||
.from('collective_members')
|
||||
.delete()
|
||||
.eq('collective_id', $currentCollective.id)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (deleteError) {
|
||||
error = deleteError.message;
|
||||
return;
|
||||
}
|
||||
members = members.filter((m) => m.user_id !== userId);
|
||||
}
|
||||
|
||||
async function generateInviteLink() {
|
||||
if (!$currentCollective) return;
|
||||
generating = true;
|
||||
generatedLink = null;
|
||||
|
||||
const { data, error: insertError } = await getSupabase()
|
||||
.from('collective_invitations')
|
||||
.insert({
|
||||
collective_id: $currentCollective.id,
|
||||
role: inviteRole,
|
||||
created_by: $currentUser!.id
|
||||
})
|
||||
.select('token')
|
||||
.single();
|
||||
|
||||
if (insertError || !data) {
|
||||
error = insertError?.message ?? 'Failed to generate link.';
|
||||
generating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
generatedLink = `${window.location.origin}/invitation/${data.token}`;
|
||||
generating = false;
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!generatedLink) return;
|
||||
await navigator.clipboard.writeText(generatedLink);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
const ROLES: Array<'admin' | 'member' | 'guest'> = ['admin', 'member', 'guest'];
|
||||
|
||||
function roleLabel(role: 'admin' | 'member' | 'guest'): string {
|
||||
if (role === 'admin') return m.role_admin();
|
||||
if (role === 'member') return m.role_member();
|
||||
return m.role_guest();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<h1 class="mb-6 text-xl font-bold text-slate-900 dark:text-slate-50">
|
||||
{m.manage_title()}
|
||||
</h1>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-4 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Members list -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.manage_members()}
|
||||
</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{#each members as member}
|
||||
<li class="flex items-center gap-3 py-3">
|
||||
<Avatar
|
||||
name={member.display_name}
|
||||
type={member.avatar_type}
|
||||
emoji={member.avatar_emoji}
|
||||
src={member.avatar_url}
|
||||
size={36}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-900 dark:text-slate-50">
|
||||
{member.display_name}
|
||||
{#if member.user_id === $currentUser?.id}
|
||||
<span class="ml-1 text-text-secondary">({m.manage_you()})</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAdmin && member.user_id !== $currentUser?.id}
|
||||
<select
|
||||
value={member.role}
|
||||
onchange={(e) => changeRole(member.user_id, e.currentTarget.value as Member['role'])}
|
||||
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-xs
|
||||
dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
{#each ROLES as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
onclick={() => removeMember(member.user_id)}
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
|
||||
dark:hover:bg-red-900/20"
|
||||
aria-label={m.manage_remove()}
|
||||
>✕</button>
|
||||
{:else}
|
||||
<span class="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600
|
||||
dark:bg-slate-700 dark:text-slate-400">
|
||||
{roleLabel(member.role)}
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Invite link generator (admins only) -->
|
||||
{#if isAdmin}
|
||||
<section class="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
|
||||
<h2 class="mb-3 text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.manage_invite_title()}
|
||||
</h2>
|
||||
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<label for="invite-role" class="text-sm text-slate-700 dark:text-slate-300">{m.manage_invite_role()}</label>
|
||||
<select
|
||||
id="invite-role"
|
||||
bind:value={inviteRole}
|
||||
class="rounded-md border border-slate-300 bg-surface px-2 py-1 text-sm
|
||||
dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
<option value="member">{m.role_member()}</option>
|
||||
<option value="guest">{m.role_guest()}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={generateInviteLink}
|
||||
disabled={generating}
|
||||
class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
|
||||
hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{generating ? m.loading() : m.manage_generate_link()}
|
||||
</button>
|
||||
|
||||
{#if generatedLink}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2
|
||||
dark:border-slate-700 dark:bg-slate-800">
|
||||
<code class="min-w-0 flex-1 truncate text-xs text-slate-600 dark:text-slate-400">
|
||||
{generatedLink}
|
||||
</code>
|
||||
<button
|
||||
onclick={copyLink}
|
||||
class="flex-shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-700
|
||||
hover:bg-slate-200 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
{copied ? m.manage_copied() : m.manage_copy()}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-text-secondary">{m.manage_link_expires()}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user