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:
13
CLAUDE.md
13
CLAUDE.md
@@ -8,12 +8,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
This repository is in the **planning phase**. No application code exists yet. The repo contains:
|
**Fase 0 complete. Fase 1 in progress.**
|
||||||
|
|
||||||
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
||||||
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
||||||
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 4)
|
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 4)
|
||||||
|
|
||||||
Start by reading these documents before implementing anything.
|
## Local Dev: Required /etc/hosts Entry
|
||||||
|
|
||||||
|
GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycloak's discovery document is unreachable from the browser.
|
||||||
|
|
||||||
## Planned Stack
|
## Planned Stack
|
||||||
|
|
||||||
|
|||||||
8
apps/web/.env.development
Normal file
8
apps/web/.env.development
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default public env vars for local development.
|
||||||
|
# Committed — these are not secrets. Secret vars (.env) remain gitignored.
|
||||||
|
PUBLIC_SUPABASE_URL=http://localhost:8001
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNDc0MDAwMCwiZXhwIjo0NzkwNDEzNjAwfQ.F70ylwDTR13K6X_jOWkGFdYNdXrQlzIFz6gbN4aqaMM
|
||||||
|
PUBLIC_KEYCLOAK_URL=http://keycloak:8080
|
||||||
|
PUBLIC_KEYCLOAK_REALM=colectivo
|
||||||
|
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web
|
||||||
|
PUBLIC_APP_URL=http://localhost:5173
|
||||||
@@ -11,15 +11,58 @@
|
|||||||
"login_title": "Sign in to Colectivo",
|
"login_title": "Sign in to Colectivo",
|
||||||
"login_button": "Sign in",
|
"login_button": "Sign in",
|
||||||
"logout_button": "Sign out",
|
"logout_button": "Sign out",
|
||||||
|
"auth_back_to_home": "Back to home",
|
||||||
"onboarding_title": "Welcome to Colectivo",
|
"onboarding_title": "Welcome to Colectivo",
|
||||||
|
"onboarding_subtitle": "Get started by creating a new collective or joining an existing one.",
|
||||||
|
"onboarding_create_tab": "Create",
|
||||||
|
"onboarding_join_tab": "Join",
|
||||||
"onboarding_create_collective": "Create a collective",
|
"onboarding_create_collective": "Create a collective",
|
||||||
"onboarding_join_collective": "Join with an invitation link",
|
"onboarding_join_collective": "Join with an invitation link",
|
||||||
|
"onboarding_collective_name": "Collective name",
|
||||||
|
"onboarding_collective_name_placeholder": "e.g. Our Home",
|
||||||
|
"onboarding_collective_emoji": "Icon",
|
||||||
|
"onboarding_invite_link_label": "Invitation link",
|
||||||
|
"onboarding_invite_link_placeholder": "Paste the invite link here…",
|
||||||
|
"onboarding_create_btn": "Create collective",
|
||||||
|
"onboarding_join_btn": "Join collective",
|
||||||
"collective_name_label": "Collective name",
|
"collective_name_label": "Collective name",
|
||||||
"collective_name_placeholder": "e.g. Our Home",
|
"collective_name_placeholder": "e.g. Our Home",
|
||||||
|
"invitation_title": "You're invited",
|
||||||
|
"invitation_subtitle": "You've been invited to join a collective.",
|
||||||
|
"invitation_accept_btn": "Accept invitation",
|
||||||
|
"invitation_success": "You've joined the collective!",
|
||||||
|
"invitation_invalid_link": "That doesn't look like a valid invitation link.",
|
||||||
|
"invitation_error_not_found": "This invitation link is not valid.",
|
||||||
|
"invitation_error_already_used": "This invitation has already been used.",
|
||||||
|
"invitation_error_expired": "This invitation has expired.",
|
||||||
|
"invitation_error_already_member": "You are already a member of this collective.",
|
||||||
|
"invitation_error_unauthenticated": "You must be signed in to accept an invitation.",
|
||||||
|
"invitation_error_unknown": "Something went wrong. Please try again.",
|
||||||
|
"manage_title": "Manage collective",
|
||||||
|
"manage_members": "Members",
|
||||||
|
"manage_you": "you",
|
||||||
|
"manage_remove": "Remove member",
|
||||||
|
"manage_invite_title": "Invite someone",
|
||||||
|
"manage_invite_role": "Role",
|
||||||
|
"manage_generate_link": "Generate invite link",
|
||||||
|
"manage_copy": "Copy",
|
||||||
|
"manage_copied": "Copied!",
|
||||||
|
"manage_link_expires": "Link expires in 7 days.",
|
||||||
|
"role_admin": "Admin",
|
||||||
|
"role_member": "Member",
|
||||||
|
"role_guest": "Guest",
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"settings_display_name": "Display name",
|
"settings_display_name": "Display name",
|
||||||
|
"settings_saving": "Saving…",
|
||||||
"settings_language": "Language",
|
"settings_language": "Language",
|
||||||
"settings_avatar": "Avatar",
|
"settings_avatar": "Avatar",
|
||||||
|
"settings_avatar_initials": "Initials",
|
||||||
|
"settings_avatar_emoji": "Emoji",
|
||||||
|
"settings_avatar_upload": "Photo",
|
||||||
|
"settings_avatar_upload_btn": "Upload photo…",
|
||||||
|
"settings_account": "Account",
|
||||||
|
"settings_keycloak_link": "Change email or password",
|
||||||
|
"settings_logout": "Sign out",
|
||||||
"settings_save": "Save changes",
|
"settings_save": "Save changes",
|
||||||
"settings_saved": "Changes saved",
|
"settings_saved": "Changes saved",
|
||||||
"invite_member": "Invite member",
|
"invite_member": "Invite member",
|
||||||
@@ -44,7 +87,7 @@
|
|||||||
"confirm_delete": "Delete?",
|
"confirm_delete": "Delete?",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"action_delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
|
|||||||
@@ -11,15 +11,58 @@
|
|||||||
"login_title": "Accede a Colectivo",
|
"login_title": "Accede a Colectivo",
|
||||||
"login_button": "Iniciar sesión",
|
"login_button": "Iniciar sesión",
|
||||||
"logout_button": "Cerrar sesión",
|
"logout_button": "Cerrar sesión",
|
||||||
|
"auth_back_to_home": "Volver al inicio",
|
||||||
"onboarding_title": "Bienvenido a Colectivo",
|
"onboarding_title": "Bienvenido a Colectivo",
|
||||||
|
"onboarding_subtitle": "Crea un nuevo colectivo o únete a uno existente.",
|
||||||
|
"onboarding_create_tab": "Crear",
|
||||||
|
"onboarding_join_tab": "Unirse",
|
||||||
"onboarding_create_collective": "Crear un colectivo",
|
"onboarding_create_collective": "Crear un colectivo",
|
||||||
"onboarding_join_collective": "Unirme con un enlace de invitación",
|
"onboarding_join_collective": "Unirme con un enlace de invitación",
|
||||||
|
"onboarding_collective_name": "Nombre del colectivo",
|
||||||
|
"onboarding_collective_name_placeholder": "p. ej. Nuestro Hogar",
|
||||||
|
"onboarding_collective_emoji": "Icono",
|
||||||
|
"onboarding_invite_link_label": "Enlace de invitación",
|
||||||
|
"onboarding_invite_link_placeholder": "Pega aquí el enlace de invitación…",
|
||||||
|
"onboarding_create_btn": "Crear colectivo",
|
||||||
|
"onboarding_join_btn": "Unirse al colectivo",
|
||||||
"collective_name_label": "Nombre del colectivo",
|
"collective_name_label": "Nombre del colectivo",
|
||||||
"collective_name_placeholder": "p. ej. Nuestro Hogar",
|
"collective_name_placeholder": "p. ej. Nuestro Hogar",
|
||||||
|
"invitation_title": "Tienes una invitación",
|
||||||
|
"invitation_subtitle": "Te han invitado a unirte a un colectivo.",
|
||||||
|
"invitation_accept_btn": "Aceptar invitación",
|
||||||
|
"invitation_success": "¡Te has unido al colectivo!",
|
||||||
|
"invitation_invalid_link": "Ese no parece un enlace de invitación válido.",
|
||||||
|
"invitation_error_not_found": "Este enlace de invitación no es válido.",
|
||||||
|
"invitation_error_already_used": "Esta invitación ya ha sido utilizada.",
|
||||||
|
"invitation_error_expired": "Esta invitación ha caducado.",
|
||||||
|
"invitation_error_already_member": "Ya eres miembro de este colectivo.",
|
||||||
|
"invitation_error_unauthenticated": "Debes iniciar sesión para aceptar una invitación.",
|
||||||
|
"invitation_error_unknown": "Algo salió mal. Por favor, inténtalo de nuevo.",
|
||||||
|
"manage_title": "Gestionar colectivo",
|
||||||
|
"manage_members": "Miembros",
|
||||||
|
"manage_you": "tú",
|
||||||
|
"manage_remove": "Eliminar miembro",
|
||||||
|
"manage_invite_title": "Invitar a alguien",
|
||||||
|
"manage_invite_role": "Rol",
|
||||||
|
"manage_generate_link": "Generar enlace de invitación",
|
||||||
|
"manage_copy": "Copiar",
|
||||||
|
"manage_copied": "¡Copiado!",
|
||||||
|
"manage_link_expires": "El enlace caduca en 7 días.",
|
||||||
|
"role_admin": "Administrador",
|
||||||
|
"role_member": "Miembro",
|
||||||
|
"role_guest": "Invitado",
|
||||||
"settings_title": "Ajustes",
|
"settings_title": "Ajustes",
|
||||||
"settings_display_name": "Nombre visible",
|
"settings_display_name": "Nombre visible",
|
||||||
|
"settings_saving": "Guardando…",
|
||||||
"settings_language": "Idioma",
|
"settings_language": "Idioma",
|
||||||
"settings_avatar": "Avatar",
|
"settings_avatar": "Avatar",
|
||||||
|
"settings_avatar_initials": "Iniciales",
|
||||||
|
"settings_avatar_emoji": "Emoji",
|
||||||
|
"settings_avatar_upload": "Foto",
|
||||||
|
"settings_avatar_upload_btn": "Subir foto…",
|
||||||
|
"settings_account": "Cuenta",
|
||||||
|
"settings_keycloak_link": "Cambiar correo o contraseña",
|
||||||
|
"settings_logout": "Cerrar sesión",
|
||||||
"settings_save": "Guardar cambios",
|
"settings_save": "Guardar cambios",
|
||||||
"settings_saved": "Cambios guardados",
|
"settings_saved": "Cambios guardados",
|
||||||
"invite_member": "Invitar miembro",
|
"invite_member": "Invitar miembro",
|
||||||
@@ -44,7 +87,7 @@
|
|||||||
"confirm_delete": "¿Eliminar?",
|
"confirm_delete": "¿Eliminar?",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"delete": "Eliminar",
|
"action_delete": "Eliminar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"done": "Listo"
|
"done": "Listo"
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colectivo/types": "workspace:*",
|
"@colectivo/types": "workspace:*",
|
||||||
"@inlang/paraglide-sveltekit": "^0.12.3",
|
"@inlang/paraglide-sveltekit": "^0.16.1",
|
||||||
"@supabase/supabase-js": "^2.46.2",
|
"@supabase/supabase-js": "^2.46.2",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"idb": "^8.0.1",
|
"idb": "^8.0.1",
|
||||||
"keycloak-js": "^26.0.7",
|
|
||||||
"svelte-dnd-action": "^0.9.51"
|
"svelte-dnd-action": "^0.9.51"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1
apps/web/project.inlang/.gitignore
vendored
Normal file
1
apps/web/project.inlang/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cache
|
||||||
1
apps/web/project.inlang/project_id
Normal file
1
apps/web/project.inlang/project_id
Normal file
@@ -0,0 +1 @@
|
|||||||
|
55d2634950b110a0d186a22dcc078940aa1367335dd97dc5a9bb96944f5a40a4
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"languageTags": ["en", "es"],
|
"languageTags": ["en", "es"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0.9.5/dist/index.js"
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@1/dist/index.js"
|
||||||
],
|
],
|
||||||
"plugin.inlang.messageFormat": {
|
"plugin.inlang.messageFormat": {
|
||||||
"pathPattern": "./messages/{languageTag}.json"
|
"pathPattern": "./messages/{languageTag}.json"
|
||||||
|
|||||||
5
apps/web/src/app.d.ts
vendored
5
apps/web/src/app.d.ts
vendored
@@ -1,12 +1,9 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
import type { KeycloakUser } from '$lib/auth';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
|
||||||
user: KeycloakUser | null;
|
|
||||||
}
|
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|||||||
@@ -1,78 +1,29 @@
|
|||||||
import Keycloak from 'keycloak-js';
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public';
|
import { getSupabase } from '$lib/supabase';
|
||||||
|
|
||||||
export interface KeycloakUser {
|
/**
|
||||||
id: string;
|
* Redirect to Keycloak login via GoTrue OAuth proxy.
|
||||||
email: string;
|
* GoTrue handles the PKCE exchange; the browser lands at /auth/callback.
|
||||||
displayName: string;
|
*/
|
||||||
preferredUsername: string;
|
export async function login(redirectTo?: string): Promise<void> {
|
||||||
}
|
if (!browser) return;
|
||||||
|
|
||||||
let keycloak: Keycloak | null = null;
|
const { error } = await getSupabase().auth.signInWithOAuth({
|
||||||
|
provider: 'keycloak',
|
||||||
export function getKeycloak(): Keycloak {
|
options: {
|
||||||
if (!keycloak) {
|
redirectTo: redirectTo ?? `${window.location.origin}/auth/callback`,
|
||||||
keycloak = new Keycloak({
|
scopes: 'openid profile email'
|
||||||
url: PUBLIC_KEYCLOAK_URL,
|
}
|
||||||
realm: PUBLIC_KEYCLOAK_REALM,
|
|
||||||
clientId: PUBLIC_KEYCLOAK_CLIENT_ID
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return keycloak;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initAuth(): Promise<boolean> {
|
|
||||||
if (!browser) return false;
|
|
||||||
|
|
||||||
const kc = getKeycloak();
|
|
||||||
|
|
||||||
const authenticated = await kc.init({
|
|
||||||
onLoad: 'check-sso',
|
|
||||||
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
|
|
||||||
// Safari blocks third-party cookies in iframes — must be false
|
|
||||||
checkLoginIframe: false,
|
|
||||||
pkceMethod: 'S256'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (authenticated) {
|
if (error) throw error;
|
||||||
// Schedule proactive token refresh (60s before expiry)
|
|
||||||
scheduleTokenRefresh(kc);
|
|
||||||
}
|
|
||||||
|
|
||||||
return authenticated;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleTokenRefresh(kc: Keycloak): void {
|
|
||||||
setInterval(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
await kc.updateToken(60);
|
|
||||||
} catch {
|
|
||||||
// Token refresh failed — redirect to login
|
|
||||||
kc.login();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30 * 1000 // check every 30s
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUser(kc: Keycloak): KeycloakUser | null {
|
|
||||||
if (!kc.authenticated || !kc.tokenParsed) return null;
|
|
||||||
|
|
||||||
const token = kc.tokenParsed as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: token['sub'] as string,
|
|
||||||
email: token['email'] as string,
|
|
||||||
displayName: (token['name'] as string) ?? (token['preferred_username'] as string),
|
|
||||||
preferredUsername: token['preferred_username'] as string
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(): Promise<void> {
|
|
||||||
getKeycloak().login();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out from Supabase (clears local session).
|
||||||
|
* The Keycloak session remains active — the user won't be asked for credentials
|
||||||
|
* again on next login unless they also log out from the Keycloak Account Console.
|
||||||
|
*/
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
getKeycloak().logout({ redirectUri: window.location.origin });
|
await getSupabase().auth.signOut();
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
import type Keycloak from 'keycloak-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import type { KeycloakUser } from '$lib/auth';
|
|
||||||
|
|
||||||
export const keycloak = writable<Keycloak | null>(null);
|
export const currentUser = writable<User | null>(null);
|
||||||
export const isAuthenticated = writable<boolean>(false);
|
|
||||||
export const currentUser = writable<KeycloakUser | null>(null);
|
|
||||||
export const authLoading = writable<boolean>(true);
|
export const authLoading = writable<boolean>(true);
|
||||||
|
|
||||||
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);
|
export const isAuthenticated = derived(currentUser, ($user) => $user !== null);
|
||||||
|
|
||||||
|
/** Display name derived from Supabase user metadata, with email fallback. */
|
||||||
|
export const displayName = derived(currentUser, ($user) => {
|
||||||
|
if (!$user) return '';
|
||||||
|
const meta = $user.user_metadata as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
(meta['full_name'] as string) ??
|
||||||
|
(meta['name'] as string) ??
|
||||||
|
$user.email?.split('@')[0] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import type { Database } from '@colectivo/types';
|
import type { Database } from '@colectivo/types';
|
||||||
|
|
||||||
@@ -9,36 +8,13 @@ export function getSupabase(): SupabaseClient<Database> {
|
|||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
auth: {
|
auth: {
|
||||||
// Auth is handled by Keycloak — disable Supabase GoTrue auth flows
|
autoRefreshToken: true,
|
||||||
autoRefreshToken: false,
|
persistSession: true,
|
||||||
persistSession: false,
|
detectSessionInUrl: true,
|
||||||
detectSessionInUrl: false
|
// PKCE flow — required for SPA/PWA OAuth
|
||||||
},
|
flowType: 'pkce'
|
||||||
global: {
|
|
||||||
headers: {}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return supabase;
|
return supabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the Keycloak access token on the Supabase client so RLS policies
|
|
||||||
* resolve auth.uid() to the Keycloak sub claim.
|
|
||||||
*/
|
|
||||||
export function setSupabaseToken(accessToken: string): void {
|
|
||||||
const client = getSupabase();
|
|
||||||
// @ts-expect-error — internal API to inject a custom JWT
|
|
||||||
client.rest.headers['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
if (browser) {
|
|
||||||
// Also set on the realtime client
|
|
||||||
// @ts-expect-error
|
|
||||||
client.realtime.setAuth(accessToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSupabaseToken(): void {
|
|
||||||
const client = getSupabase();
|
|
||||||
// @ts-expect-error
|
|
||||||
delete client.rest.headers['Authorization'];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { isAuthenticated, authLoading, displayName } from '$lib/stores/auth';
|
||||||
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
|
||||||
import { currentCollective } from '$lib/stores/collective';
|
let { children }: { children: Snippet } = $props();
|
||||||
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
import { logout } from '$lib/auth';
|
import { logout } from '$lib/auth';
|
||||||
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -13,25 +16,63 @@
|
|||||||
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
|
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
|
||||||
];
|
];
|
||||||
|
|
||||||
$: activePath = $page.url.pathname;
|
let collectiveSwitcherOpen = $state(false);
|
||||||
|
|
||||||
|
const activePath = $derived($page.url.pathname);
|
||||||
|
|
||||||
|
function switchCollective(id: string) {
|
||||||
|
const c = $userCollectives.find((col) => col.id === id);
|
||||||
|
if (c) {
|
||||||
|
currentCollective.set(c);
|
||||||
|
localStorage.setItem('activeCollectiveId', c.id);
|
||||||
|
collectiveSwitcherOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $authLoading}
|
{#if $authLoading}
|
||||||
<div class="flex h-screen items-center justify-center bg-background">
|
<div class="flex h-screen items-center justify-center bg-background">
|
||||||
<span class="text-text-secondary text-sm">{m.loading()}</span>
|
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if !$isAuthenticated}
|
{:else if $isAuthenticated}
|
||||||
<!-- Keycloak redirect is triggered in root layout; show nothing -->
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-screen overflow-hidden bg-background">
|
<div class="flex h-screen overflow-hidden bg-background">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
|
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
|
||||||
<!-- Collective name -->
|
|
||||||
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
|
<!-- Collective header + switcher -->
|
||||||
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
<div class="relative">
|
||||||
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
<button
|
||||||
{$currentCollective?.name ?? m.app_name()}
|
class="flex h-14 w-full items-center gap-2 border-b border-slate-200 px-4 text-left
|
||||||
</span>
|
hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||||
|
onclick={() => (collectiveSwitcherOpen = !collectiveSwitcherOpen)}
|
||||||
|
aria-expanded={collectiveSwitcherOpen}
|
||||||
|
>
|
||||||
|
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||||
|
{$currentCollective?.name ?? m.app_name()}
|
||||||
|
</span>
|
||||||
|
{#if $userCollectives.length > 1}
|
||||||
|
<span class="text-xs text-slate-400">⌄</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if collectiveSwitcherOpen && $userCollectives.length > 1}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 top-full z-50 border-b border-slate-200 bg-surface shadow-md dark:border-slate-700"
|
||||||
|
>
|
||||||
|
{#each $userCollectives as c}
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm
|
||||||
|
hover:bg-slate-50 dark:hover:bg-slate-800
|
||||||
|
{c.id === $currentCollective?.id ? 'font-semibold text-slate-900 dark:text-slate-50' : 'text-slate-600 dark:text-slate-400'}"
|
||||||
|
onclick={() => switchCollective(c.id)}
|
||||||
|
>
|
||||||
|
<span class="text-base">{c.emoji}</span>
|
||||||
|
<span class="truncate">{c.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
@@ -50,18 +91,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User + settings -->
|
<!-- User footer -->
|
||||||
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
|
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<!-- Avatar -->
|
<Avatar name={$displayName} size={32} />
|
||||||
<div
|
|
||||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
|
|
||||||
>
|
|
||||||
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{$currentUser?.displayName ?? ''}
|
{$displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@@ -77,7 +113,7 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex flex-1 flex-col overflow-hidden">
|
<main class="flex flex-1 flex-col overflow-hidden">
|
||||||
<slot />
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
21
apps/web/src/routes/(app)/+layout.ts
Normal file
21
apps/web/src/routes/(app)/+layout.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { getSupabase } from '$lib/supabase';
|
||||||
|
import { login } from '$lib/auth';
|
||||||
|
|
||||||
|
// No SSR — this is a client-rendered PWA
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
if (!browser) return {};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session }
|
||||||
|
} = await getSupabase().auth.getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// Not authenticated — redirect to Keycloak
|
||||||
|
await login();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -1,12 +1,281 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getSupabase } from '$lib/supabase';
|
||||||
|
import { currentUser } from '$lib/stores/auth';
|
||||||
|
import { logout } from '$lib/auth';
|
||||||
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
|
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||||
|
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
import {
|
||||||
|
PUBLIC_KEYCLOAK_URL,
|
||||||
|
PUBLIC_KEYCLOAK_REALM
|
||||||
|
} from '$env/static/public';
|
||||||
|
|
||||||
|
// Profile state loaded from public.users
|
||||||
|
let displayNameInput = $state('');
|
||||||
|
let language = $state<'en' | 'es'>('en');
|
||||||
|
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
||||||
|
let avatarEmoji = $state<string | null>(null);
|
||||||
|
let avatarUrl = $state<string | null>(null);
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let saved = $state(false);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
// Cropper
|
||||||
|
let cropperFile = $state<File | null>(null);
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
const EMOJI_GRID = [
|
||||||
|
'😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
|
||||||
|
'🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
|
||||||
|
'🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
|
||||||
|
'🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
|
||||||
|
];
|
||||||
|
|
||||||
|
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadProfile();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
if (!$currentUser) return;
|
||||||
|
|
||||||
|
const { data } = await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.select('display_name, language, avatar_type, avatar_emoji, avatar_url')
|
||||||
|
.eq('id', $currentUser.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
displayNameInput = data.display_name;
|
||||||
|
language = data.language as 'en' | 'es';
|
||||||
|
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
||||||
|
avatarEmoji = data.avatar_emoji;
|
||||||
|
avatarUrl = data.avatar_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNameSave() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(saveName, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
if (!$currentUser) return;
|
||||||
|
saving = true;
|
||||||
|
await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.update({ display_name: displayNameInput.trim() })
|
||||||
|
.eq('id', $currentUser.id);
|
||||||
|
saving = false;
|
||||||
|
saved = true;
|
||||||
|
setTimeout(() => (saved = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectEmojiAvatar(emoji: string) {
|
||||||
|
avatarEmoji = emoji;
|
||||||
|
avatarType = 'emoji';
|
||||||
|
await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.update({ avatar_type: 'emoji', avatar_emoji: emoji })
|
||||||
|
.eq('id', $currentUser!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInitialsAvatar() {
|
||||||
|
avatarType = 'initials';
|
||||||
|
await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.update({ avatar_type: 'initials', avatar_emoji: null })
|
||||||
|
.eq('id', $currentUser!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileInput(e: Event) {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (file) cropperFile = file;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCrop(blob: Blob) {
|
||||||
|
cropperFile = null;
|
||||||
|
if (!$currentUser) return;
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
const path = `${$currentUser.id}/avatar.webp`;
|
||||||
|
const { error: uploadError } = await getSupabase()
|
||||||
|
.storage
|
||||||
|
.from('avatars')
|
||||||
|
.upload(path, blob, { upsert: true, contentType: 'image/webp' });
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
uploading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: urlData } = await getSupabase()
|
||||||
|
.storage
|
||||||
|
.from('avatars')
|
||||||
|
.createSignedUrl(path, 60 * 60 * 24 * 7); // 7 days
|
||||||
|
|
||||||
|
const signedUrl = urlData?.signedUrl ?? null;
|
||||||
|
|
||||||
|
await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.update({ avatar_type: 'upload', avatar_url: signedUrl })
|
||||||
|
.eq('id', $currentUser.id);
|
||||||
|
|
||||||
|
avatarType = 'upload';
|
||||||
|
avatarUrl = signedUrl;
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchLanguage(lang: 'en' | 'es') {
|
||||||
|
language = lang;
|
||||||
|
setLanguageTag(lang);
|
||||||
|
await getSupabase()
|
||||||
|
.from('users')
|
||||||
|
.update({ language: lang })
|
||||||
|
.eq('id', $currentUser!.id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
|
||||||
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
|
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<!-- Fase 1: settings content (display name, avatar, language) -->
|
<div class="mx-auto max-w-md space-y-8">
|
||||||
|
|
||||||
|
<!-- Avatar preview -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Avatar
|
||||||
|
name={displayNameInput || $currentUser?.email || ''}
|
||||||
|
type={avatarType}
|
||||||
|
emoji={avatarEmoji}
|
||||||
|
src={avatarUrl}
|
||||||
|
size={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display name -->
|
||||||
|
<section>
|
||||||
|
<label for="display-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{m.settings_display_name()}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="display-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={displayNameInput}
|
||||||
|
oninput={scheduleNameSave}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{#if saving}
|
||||||
|
<span class="absolute right-3 top-2 text-xs text-text-secondary">{m.settings_saving()}</span>
|
||||||
|
{:else if saved}
|
||||||
|
<span class="absolute right-3 top-2 text-xs text-emerald-600">✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<section>
|
||||||
|
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{m.settings_avatar()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Mode tabs -->
|
||||||
|
<div class="mb-4 flex gap-2">
|
||||||
|
{#each [['initials', m.settings_avatar_initials()], ['emoji', m.settings_avatar_emoji()], ['upload', m.settings_avatar_upload()]] as [mode, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => mode === 'initials' ? selectInitialsAvatar() : avatarType = mode as typeof avatarType}
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||||
|
{avatarType === mode
|
||||||
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||||
|
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if avatarType === 'emoji'}
|
||||||
|
<div class="grid grid-cols-8 gap-1">
|
||||||
|
{#each EMOJI_GRID as e}
|
||||||
|
<button
|
||||||
|
onclick={() => selectEmojiAvatar(e)}
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
|
||||||
|
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||||
|
aria-label={e}
|
||||||
|
>{e}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if avatarType === 'upload'}
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
||||||
|
border-slate-300 px-4 py-3 text-sm text-slate-600 hover:border-slate-500
|
||||||
|
dark:border-slate-600 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<span>{uploading ? m.loading() : m.settings_avatar_upload_btn()}</span>
|
||||||
|
<input type="file" accept="image/*" class="hidden" onchange={onFileInput} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<section>
|
||||||
|
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{m.settings_language()}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => switchLanguage(code as 'en' | 'es')}
|
||||||
|
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||||
|
{language === code
|
||||||
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||||
|
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<section class="border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||||
|
<p class="mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{m.settings_account()}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={keycloakAccountUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mb-3 flex items-center gap-2 text-sm text-slate-600 underline dark:text-slate-400"
|
||||||
|
>
|
||||||
|
{m.settings_keycloak_link()} ↗
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={logout}
|
||||||
|
class="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600
|
||||||
|
hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
{m.settings_logout()}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if cropperFile}
|
||||||
|
<ImageCropper
|
||||||
|
file={cropperFile}
|
||||||
|
{onCrop}
|
||||||
|
onCancel={() => (cropperFile = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -3,47 +3,76 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { initAuth, getKeycloak, getUser } from '$lib/auth';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
|
import { login } from '$lib/auth';
|
||||||
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
|
import { currentUser, authLoading } from '$lib/stores/auth';
|
||||||
|
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||||
import { i18n } from '$lib/i18n';
|
import { i18n } from '$lib/i18n';
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ['/invitation'];
|
// Routes that don't require authentication
|
||||||
|
const PUBLIC_ROUTES = ['/auth', '/invitation'];
|
||||||
|
|
||||||
onMount(async () => {
|
function isPublicRoute(pathname: string): boolean {
|
||||||
const kc = getKeycloak();
|
return PUBLIC_ROUTES.some((r) => pathname.startsWith(r));
|
||||||
keycloak.set(kc);
|
}
|
||||||
|
|
||||||
const authenticated = await initAuth();
|
onMount(() => {
|
||||||
isAuthenticated.set(authenticated);
|
const supabase = getSupabase();
|
||||||
|
|
||||||
if (authenticated) {
|
const {
|
||||||
const user = getUser(kc);
|
data: { subscription }
|
||||||
currentUser.set(user);
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
if (kc.token) setSupabaseToken(kc.token);
|
currentUser.set(session?.user ?? null);
|
||||||
|
authLoading.set(false);
|
||||||
|
|
||||||
// Keep token in sync with Supabase client
|
if (!session && !isPublicRoute($page.url.pathname)) {
|
||||||
kc.onTokenRefreshed = () => {
|
// No session on a protected route → redirect to Keycloak
|
||||||
if (kc.token) setSupabaseToken(kc.token);
|
await login();
|
||||||
};
|
return;
|
||||||
kc.onAuthLogout = () => {
|
|
||||||
clearSupabaseToken();
|
|
||||||
isAuthenticated.set(false);
|
|
||||||
currentUser.set(null);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
authLoading.set(false);
|
|
||||||
|
|
||||||
// Redirect unauthenticated users unless on a public route
|
|
||||||
if (!authenticated) {
|
|
||||||
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
|
|
||||||
if (!isPublic) {
|
|
||||||
kc.login();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (session && event === 'SIGNED_IN') {
|
||||||
|
await loadUserCollectives(session.user.id);
|
||||||
|
|
||||||
|
// Post-login routing: onboarding if user has no collective
|
||||||
|
if ($userCollectives.length === 0 && $page.url.pathname === '/') {
|
||||||
|
goto('/onboarding');
|
||||||
|
} else if ($page.url.pathname === '/') {
|
||||||
|
goto('/lists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadUserCollectives(userId: string): Promise<void> {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('collective_members')
|
||||||
|
.select('collective_id, role, collectives(id, name, emoji, created_at)')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
type CollectiveRow = { id: string; name: string; emoji: string; created_at: string };
|
||||||
|
const collectives = data
|
||||||
|
.map((row) => {
|
||||||
|
const c = row.collectives as CollectiveRow | null;
|
||||||
|
return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null;
|
||||||
|
})
|
||||||
|
.filter((c): c is CollectiveRow => c !== null);
|
||||||
|
|
||||||
|
userCollectives.set(collectives);
|
||||||
|
|
||||||
|
// Restore last active collective from localStorage, or default to first
|
||||||
|
const savedId = localStorage.getItem('activeCollectiveId');
|
||||||
|
const active = collectives.find((c) => c.id === savedId) ?? collectives[0] ?? null;
|
||||||
|
currentCollective.set(active);
|
||||||
|
if (active) localStorage.setItem('activeCollectiveId', active.id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ParaglideJS {i18n}>
|
<ParaglideJS {i18n}>
|
||||||
|
|||||||
39
apps/web/src/routes/auth/callback/+page.svelte
Normal file
39
apps/web/src/routes/auth/callback/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { getSupabase } from '$lib/supabase';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const code = $page.url.searchParams.get('code');
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
error = 'No authorization code received.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: exchangeError } = await getSupabase().auth.exchangeCodeForSession(code);
|
||||||
|
|
||||||
|
if (exchangeError) {
|
||||||
|
error = exchangeError.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// onAuthStateChange in root layout handles post-login routing
|
||||||
|
goto('/');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen items-center justify-center bg-background">
|
||||||
|
{#if error}
|
||||||
|
<div class="max-w-sm text-center">
|
||||||
|
<p class="mb-4 text-sm text-red-600">{error}</p>
|
||||||
|
<a href="/" class="text-sm text-slate-600 underline">{m.auth_back_to_home()}</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
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>
|
||||||
@@ -1,25 +1,186 @@
|
|||||||
<script lang="ts">
|
<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';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
|
<div class="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
<div class="w-full max-w-sm space-y-6">
|
<div class="w-full max-w-md">
|
||||||
<div class="text-center">
|
<h1 class="mb-2 text-2xl font-bold text-slate-900 dark:text-slate-50">
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
|
{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>
|
</div>
|
||||||
|
|
||||||
<!-- Fase 1: collective creation / join form -->
|
{#if activeTab === 'create'}
|
||||||
<div class="space-y-3">
|
<form onsubmit={(e) => { e.preventDefault(); createCollective(); }} class="space-y-4">
|
||||||
<button
|
<div>
|
||||||
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"
|
<label for="collective-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
>
|
{m.onboarding_collective_name()}
|
||||||
{m.onboarding_create_collective()}
|
</label>
|
||||||
</button>
|
<input
|
||||||
<button
|
id="collective-name"
|
||||||
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"
|
type="text"
|
||||||
>
|
bind:value={collectiveName}
|
||||||
{m.onboarding_join_collective()}
|
placeholder={m.onboarding_collective_name_placeholder()}
|
||||||
</button>
|
maxlength="60"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ services:
|
|||||||
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true"
|
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true"
|
||||||
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web}
|
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web}
|
||||||
GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ""
|
GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ""
|
||||||
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${PUBLIC_APP_URL:-http://localhost:5173}/auth/callback
|
# GoTrue fetches Keycloak's OIDC discovery doc at this URL (Docker-internal hostname).
|
||||||
|
# The browser also uses this hostname — add "127.0.0.1 keycloak" to /etc/hosts.
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_URL: http://keycloak:8080/realms/colectivo
|
||||||
|
# Keycloak redirects the browser here after login; GoTrue exchanges the code.
|
||||||
|
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: http://localhost:8001/auth/v1/callback
|
||||||
GOTRUE_MAILER_AUTOCONFIRM: "true"
|
GOTRUE_MAILER_AUTOCONFIRM: "true"
|
||||||
|
|
||||||
# ── PostgREST ───────────────────────────────────────────────────────────────
|
# ── PostgREST ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -29,11 +29,14 @@
|
|||||||
},
|
},
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:5173/*",
|
"http://localhost:5173/*",
|
||||||
"http://localhost:3000/*"
|
"http://localhost:3000/*",
|
||||||
|
"http://localhost:8001/auth/v1/callback",
|
||||||
|
"http://keycloak:8080/auth/v1/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000"
|
"http://localhost:3000",
|
||||||
|
"http://localhost:8001"
|
||||||
],
|
],
|
||||||
"fullScopeAllowed": true,
|
"fullScopeAllowed": true,
|
||||||
"defaultClientScopes": [
|
"defaultClientScopes": [
|
||||||
|
|||||||
@@ -1,14 +1,190 @@
|
|||||||
// AUTO-GENERATED — do not edit manually.
|
// AUTO-GENERATED — do not edit manually.
|
||||||
// Run `just db-types` to regenerate from Supabase schema.
|
// Run `just db-types` to regenerate from Supabase schema.
|
||||||
// Placeholder until migrations exist and `supabase gen types typescript` can run.
|
// Manually seeded from migrations until `supabase gen types typescript` can run.
|
||||||
|
|
||||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
||||||
|
|
||||||
|
export type LanguageCode = 'en' | 'es';
|
||||||
|
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
|
||||||
|
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
|
||||||
|
|
||||||
export interface Database {
|
export interface Database {
|
||||||
public: {
|
public: {
|
||||||
Tables: Record<string, never>;
|
Tables: {
|
||||||
|
users: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
language: LanguageCode;
|
||||||
|
avatar_type: AvatarTypeEnum;
|
||||||
|
avatar_emoji: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name?: string;
|
||||||
|
language?: LanguageCode;
|
||||||
|
avatar_type?: AvatarTypeEnum;
|
||||||
|
avatar_emoji?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
display_name?: string;
|
||||||
|
language?: LanguageCode;
|
||||||
|
avatar_type?: AvatarTypeEnum;
|
||||||
|
avatar_emoji?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
collectives: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
emoji?: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: 'collectives_created_by_fkey';
|
||||||
|
columns: ['created_by'];
|
||||||
|
referencedRelation: 'users';
|
||||||
|
referencedColumns: ['id'];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
collective_members: {
|
||||||
|
Row: {
|
||||||
|
collective_id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: MemberRoleEnum;
|
||||||
|
joined_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
collective_id: string;
|
||||||
|
user_id: string;
|
||||||
|
role?: MemberRoleEnum;
|
||||||
|
joined_at?: string;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
collective_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
role?: MemberRoleEnum;
|
||||||
|
joined_at?: string;
|
||||||
|
};
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: 'collective_members_collective_id_fkey';
|
||||||
|
columns: ['collective_id'];
|
||||||
|
referencedRelation: 'collectives';
|
||||||
|
referencedColumns: ['id'];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: 'collective_members_user_id_fkey';
|
||||||
|
columns: ['user_id'];
|
||||||
|
referencedRelation: 'users';
|
||||||
|
referencedColumns: ['id'];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
collective_invitations: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
collective_id: string;
|
||||||
|
token: string;
|
||||||
|
role: MemberRoleEnum;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
accepted_by: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
collective_id: string;
|
||||||
|
token?: string;
|
||||||
|
role?: MemberRoleEnum;
|
||||||
|
created_by: string;
|
||||||
|
created_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
accepted_at?: string | null;
|
||||||
|
accepted_by?: string | null;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
collective_id?: string;
|
||||||
|
token?: string;
|
||||||
|
role?: MemberRoleEnum;
|
||||||
|
created_by?: string;
|
||||||
|
created_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
accepted_at?: string | null;
|
||||||
|
accepted_by?: string | null;
|
||||||
|
};
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: 'collective_invitations_collective_id_fkey';
|
||||||
|
columns: ['collective_id'];
|
||||||
|
referencedRelation: 'collectives';
|
||||||
|
referencedColumns: ['id'];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: 'collective_invitations_created_by_fkey';
|
||||||
|
columns: ['created_by'];
|
||||||
|
referencedRelation: 'users';
|
||||||
|
referencedColumns: ['id'];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
Views: Record<string, never>;
|
Views: Record<string, never>;
|
||||||
Functions: Record<string, never>;
|
Functions: {
|
||||||
Enums: Record<string, never>;
|
accept_invitation: {
|
||||||
|
Args: { p_token: string };
|
||||||
|
Returns: Json;
|
||||||
|
};
|
||||||
|
is_active_member: {
|
||||||
|
Args: { p_collective_id: string };
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
|
is_member: {
|
||||||
|
Args: { p_collective_id: string };
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
|
is_admin: {
|
||||||
|
Args: { p_collective_id: string };
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Enums: {
|
||||||
|
language_code: LanguageCode;
|
||||||
|
avatar_type: AvatarTypeEnum;
|
||||||
|
member_role: MemberRoleEnum;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
6814
pnpm-lock.yaml
generated
Normal file
6814
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -65,10 +65,14 @@ enable_confirmations = false
|
|||||||
enable_signup = false
|
enable_signup = false
|
||||||
enable_confirmations = false
|
enable_confirmations = false
|
||||||
|
|
||||||
# Keycloak as OIDC provider
|
# Keycloak as external OIDC provider via GoTrue (Option B).
|
||||||
# The JWT_SECRET must be set to the Keycloak RS256 public key
|
# GoTrue acts as OIDC proxy: app → GoTrue → Keycloak → GoTrue issues Supabase JWT.
|
||||||
|
# Configured via environment variables in docker-compose.dev.yml:
|
||||||
|
# GOTRUE_EXTERNAL_KEYCLOAK_ENABLED, GOTRUE_EXTERNAL_KEYCLOAK_URL, etc.
|
||||||
|
# The [auth.third_party.keycloak] section is for Supabase CLI managed stacks;
|
||||||
|
# we use docker-compose directly so this section is intentionally left empty.
|
||||||
[auth.third_party.keycloak]
|
[auth.third_party.keycloak]
|
||||||
enabled = false # We bypass GoTrue — Keycloak JWT validated directly by PostgREST
|
enabled = false
|
||||||
|
|
||||||
[edge_runtime]
|
[edge_runtime]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|||||||
87
supabase/migrations/001_users.sql
Normal file
87
supabase/migrations/001_users.sql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
-- Migration 001: users table
|
||||||
|
-- Mirrors GoTrue auth.users — populated by trigger on first OIDC login.
|
||||||
|
|
||||||
|
-- ── Enums ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TYPE public.language_code AS ENUM ('en', 'es');
|
||||||
|
CREATE TYPE public.avatar_type AS ENUM ('initials', 'emoji', 'upload');
|
||||||
|
|
||||||
|
-- ── Table ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.users (
|
||||||
|
id uuid PRIMARY KEY, -- matches auth.users.id (GoTrue UUID)
|
||||||
|
email text NOT NULL,
|
||||||
|
display_name text NOT NULL DEFAULT '',
|
||||||
|
language public.language_code NOT NULL DEFAULT 'en',
|
||||||
|
avatar_type public.avatar_type NOT NULL DEFAULT 'initials',
|
||||||
|
avatar_emoji text NULL,
|
||||||
|
avatar_url text NULL, -- signed URL from Supabase Storage
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── updated_at trigger ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_updated_at()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_set_updated_at
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- ── Sync trigger: GoTrue auth.users → public.users on first OIDC login ────────
|
||||||
|
-- GoTrue inserts into auth.users after a successful Keycloak OIDC exchange.
|
||||||
|
-- We mirror display_name and detect language from raw_user_meta_data claims.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_display_name text;
|
||||||
|
v_language public.language_code;
|
||||||
|
v_raw jsonb;
|
||||||
|
BEGIN
|
||||||
|
v_raw := NEW.raw_user_meta_data;
|
||||||
|
|
||||||
|
-- Build display name: full_name > name > email prefix
|
||||||
|
v_display_name := COALESCE(
|
||||||
|
NULLIF(trim(v_raw->>'full_name'), ''),
|
||||||
|
NULLIF(trim(v_raw->>'name'), ''),
|
||||||
|
split_part(NEW.email, '@', 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Detect language from OIDC locale claim; default en
|
||||||
|
v_language := CASE
|
||||||
|
WHEN v_raw->>'locale' ILIKE 'es%' THEN 'es'::public.language_code
|
||||||
|
ELSE 'en'::public.language_code
|
||||||
|
END;
|
||||||
|
|
||||||
|
INSERT INTO public.users (id, email, display_name, language)
|
||||||
|
VALUES (NEW.id, NEW.email, v_display_name, v_language)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
-- Preserve user-set display name; only backfill if still empty
|
||||||
|
display_name = CASE
|
||||||
|
WHEN public.users.display_name = '' THEN EXCLUDED.display_name
|
||||||
|
ELSE public.users.display_name
|
||||||
|
END;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
99
supabase/migrations/002_collectives.sql
Normal file
99
supabase/migrations/002_collectives.sql
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
-- Migration 002: collectives, collective_members, collective_invitations
|
||||||
|
|
||||||
|
-- ── Enums ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TYPE public.member_role AS ENUM ('admin', 'member', 'guest');
|
||||||
|
|
||||||
|
-- ── collectives ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.collectives (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
emoji text NOT NULL DEFAULT '🏠',
|
||||||
|
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.collectives ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── collective_members ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.collective_members (
|
||||||
|
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
role public.member_role NOT NULL DEFAULT 'member',
|
||||||
|
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (collective_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.collective_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE INDEX collective_members_user_idx ON public.collective_members (user_id);
|
||||||
|
|
||||||
|
-- ── collective_invitations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.collective_invitations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||||
|
token uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||||
|
role public.member_role NOT NULL DEFAULT 'member',
|
||||||
|
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '7 days'),
|
||||||
|
accepted_at timestamptz NULL,
|
||||||
|
accepted_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.collective_invitations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE INDEX collective_invitations_token_idx ON public.collective_invitations (token);
|
||||||
|
CREATE INDEX collective_invitations_collective_idx ON public.collective_invitations (collective_id);
|
||||||
|
|
||||||
|
-- ── Trigger: auto-promote oldest member when last admin deletes account ────────
|
||||||
|
-- Fires BEFORE DELETE on public.users. If the departing user is the sole admin
|
||||||
|
-- of any collective, the longest-standing member (by joined_at) is promoted to
|
||||||
|
-- admin before the delete proceeds, satisfying business rule RN-10.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.promote_oldest_member_on_admin_leave()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- For each collective where the departing user is an admin:
|
||||||
|
FOR r IN
|
||||||
|
SELECT cm.collective_id
|
||||||
|
FROM public.collective_members cm
|
||||||
|
WHERE cm.user_id = OLD.id
|
||||||
|
AND cm.role = 'admin'
|
||||||
|
-- Only act when this is the SOLE remaining admin
|
||||||
|
AND (
|
||||||
|
SELECT count(*)
|
||||||
|
FROM public.collective_members
|
||||||
|
WHERE collective_id = cm.collective_id
|
||||||
|
AND role = 'admin'
|
||||||
|
) = 1
|
||||||
|
LOOP
|
||||||
|
-- Promote the earliest-joined non-admin member in that collective
|
||||||
|
UPDATE public.collective_members
|
||||||
|
SET role = 'admin'
|
||||||
|
WHERE (collective_id, user_id) = (
|
||||||
|
SELECT collective_id, user_id
|
||||||
|
FROM public.collective_members
|
||||||
|
WHERE collective_id = r.collective_id
|
||||||
|
AND user_id <> OLD.id
|
||||||
|
ORDER BY joined_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER promote_on_admin_leave
|
||||||
|
BEFORE DELETE ON public.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.promote_oldest_member_on_admin_leave();
|
||||||
229
supabase/migrations/003_rls.sql
Normal file
229
supabase/migrations/003_rls.sql
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
-- Migration 003: RLS policies + is_active_member helper
|
||||||
|
-- All business access rules live here. Every table has RLS enabled in its own
|
||||||
|
-- migration; this file only adds the policies.
|
||||||
|
|
||||||
|
-- ── Helper: is_active_member ──────────────────────────────────────────────────
|
||||||
|
-- Returns true if the calling user is a non-guest member of the collective.
|
||||||
|
-- Used by all content-table policies. Declared STABLE so PostgreSQL can cache
|
||||||
|
-- the result within a single statement.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_active_member(p_collective_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.collective_members
|
||||||
|
WHERE collective_id = p_collective_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
AND role IN ('admin', 'member')
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Variant that also accepts guests (read access).
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_member(p_collective_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.collective_members
|
||||||
|
WHERE collective_id = p_collective_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_admin(p_collective_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.collective_members
|
||||||
|
WHERE collective_id = p_collective_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
AND role = 'admin'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── public.users policies ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Any authenticated user can read their own row.
|
||||||
|
CREATE POLICY users_select_own
|
||||||
|
ON public.users FOR SELECT
|
||||||
|
USING (id = auth.uid());
|
||||||
|
|
||||||
|
-- Members can read profiles of users in the same collective.
|
||||||
|
CREATE POLICY users_select_collective_peers
|
||||||
|
ON public.users FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.collective_members cm1
|
||||||
|
JOIN public.collective_members cm2
|
||||||
|
ON cm2.collective_id = cm1.collective_id
|
||||||
|
AND cm2.user_id = auth.uid()
|
||||||
|
WHERE cm1.user_id = public.users.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A user can only update their own row.
|
||||||
|
CREATE POLICY users_update_own
|
||||||
|
ON public.users FOR UPDATE
|
||||||
|
USING (id = auth.uid())
|
||||||
|
WITH CHECK (id = auth.uid());
|
||||||
|
|
||||||
|
-- Insert is handled by the trigger (SECURITY DEFINER). No direct INSERT allowed.
|
||||||
|
CREATE POLICY users_insert_deny
|
||||||
|
ON public.users FOR INSERT
|
||||||
|
WITH CHECK (false);
|
||||||
|
|
||||||
|
-- Delete cascades from auth.users deletion; no direct DELETE allowed here.
|
||||||
|
CREATE POLICY users_delete_deny
|
||||||
|
ON public.users FOR DELETE
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ── public.collectives policies ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Members (any role) can read collectives they belong to.
|
||||||
|
CREATE POLICY collectives_select
|
||||||
|
ON public.collectives FOR SELECT
|
||||||
|
USING (public.is_member(id));
|
||||||
|
|
||||||
|
-- Any authenticated user can create a collective (they become its first admin).
|
||||||
|
CREATE POLICY collectives_insert
|
||||||
|
ON public.collectives FOR INSERT
|
||||||
|
WITH CHECK (created_by = auth.uid());
|
||||||
|
|
||||||
|
-- Only admins can update the collective name / emoji.
|
||||||
|
CREATE POLICY collectives_update
|
||||||
|
ON public.collectives FOR UPDATE
|
||||||
|
USING (public.is_admin(id))
|
||||||
|
WITH CHECK (public.is_admin(id));
|
||||||
|
|
||||||
|
-- Admins can delete a collective.
|
||||||
|
CREATE POLICY collectives_delete
|
||||||
|
ON public.collectives FOR DELETE
|
||||||
|
USING (public.is_admin(id));
|
||||||
|
|
||||||
|
-- ── public.collective_members policies ───────────────────────────────────────
|
||||||
|
|
||||||
|
-- Members of a collective can read its member list.
|
||||||
|
CREATE POLICY collective_members_select
|
||||||
|
ON public.collective_members FOR SELECT
|
||||||
|
USING (public.is_member(collective_id));
|
||||||
|
|
||||||
|
-- Only admins can add members (accepting invitations handled via server action
|
||||||
|
-- with SECURITY DEFINER function, not direct INSERT).
|
||||||
|
CREATE POLICY collective_members_insert
|
||||||
|
ON public.collective_members FOR INSERT
|
||||||
|
WITH CHECK (public.is_admin(collective_id));
|
||||||
|
|
||||||
|
-- Only admins can change roles; users cannot promote themselves.
|
||||||
|
CREATE POLICY collective_members_update
|
||||||
|
ON public.collective_members FOR UPDATE
|
||||||
|
USING (public.is_admin(collective_id))
|
||||||
|
WITH CHECK (public.is_admin(collective_id));
|
||||||
|
|
||||||
|
-- Admins can remove any member; members can remove themselves.
|
||||||
|
CREATE POLICY collective_members_delete
|
||||||
|
ON public.collective_members FOR DELETE
|
||||||
|
USING (
|
||||||
|
public.is_admin(collective_id)
|
||||||
|
OR user_id = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── public.collective_invitations policies ────────────────────────────────────
|
||||||
|
|
||||||
|
-- Members of a collective can read its invitations (to copy the link).
|
||||||
|
CREATE POLICY collective_invitations_select
|
||||||
|
ON public.collective_invitations FOR SELECT
|
||||||
|
USING (public.is_member(collective_id));
|
||||||
|
|
||||||
|
-- Only admins can create invitations.
|
||||||
|
CREATE POLICY collective_invitations_insert
|
||||||
|
ON public.collective_invitations FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
public.is_admin(collective_id)
|
||||||
|
AND created_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only admins can revoke (delete) invitations.
|
||||||
|
CREATE POLICY collective_invitations_delete
|
||||||
|
ON public.collective_invitations FOR DELETE
|
||||||
|
USING (public.is_admin(collective_id));
|
||||||
|
|
||||||
|
-- Accepting an invitation (setting accepted_at) is done via a SECURITY DEFINER
|
||||||
|
-- server action — no UPDATE policy needed here.
|
||||||
|
CREATE POLICY collective_invitations_update_deny
|
||||||
|
ON public.collective_invitations FOR UPDATE
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ── SECURITY DEFINER: accept invitation ──────────────────────────────────────
|
||||||
|
-- Called from the SvelteKit server action for /invitation/[token].
|
||||||
|
-- Validates token, expiry, and that user isn't already a member, then inserts
|
||||||
|
-- into collective_members and stamps accepted_at.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.accept_invitation(p_token uuid)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_invitation public.collective_invitations%ROWTYPE;
|
||||||
|
v_user_id uuid := auth.uid();
|
||||||
|
BEGIN
|
||||||
|
IF v_user_id IS NULL THEN
|
||||||
|
RETURN jsonb_build_object('error', 'unauthenticated');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO v_invitation
|
||||||
|
FROM public.collective_invitations
|
||||||
|
WHERE token = p_token
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN jsonb_build_object('error', 'not_found');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_invitation.accepted_at IS NOT NULL THEN
|
||||||
|
RETURN jsonb_build_object('error', 'already_used');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_invitation.expires_at < now() THEN
|
||||||
|
RETURN jsonb_build_object('error', 'expired');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.collective_members
|
||||||
|
WHERE collective_id = v_invitation.collective_id
|
||||||
|
AND user_id = v_user_id
|
||||||
|
) THEN
|
||||||
|
RETURN jsonb_build_object('error', 'already_member');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||||
|
VALUES (v_invitation.collective_id, v_user_id, v_invitation.role);
|
||||||
|
|
||||||
|
UPDATE public.collective_invitations
|
||||||
|
SET accepted_at = now(),
|
||||||
|
accepted_by = v_user_id
|
||||||
|
WHERE id = v_invitation.id;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'collective_id', v_invitation.collective_id,
|
||||||
|
'role', v_invitation.role
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
41
supabase/migrations/004_storage.sql
Normal file
41
supabase/migrations/004_storage.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- Migration 004: Storage bucket for user avatars
|
||||||
|
|
||||||
|
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
VALUES (
|
||||||
|
'avatars',
|
||||||
|
'avatars',
|
||||||
|
false, -- private: access via signed URLs only
|
||||||
|
2097152, -- 2 MB per file
|
||||||
|
ARRAY['image/webp', 'image/jpeg', 'image/png', 'image/gif']
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Storage RLS: a user can read/write only their own avatar folder ({user_id}/avatar.*)
|
||||||
|
|
||||||
|
CREATE POLICY avatars_select
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY avatars_insert
|
||||||
|
ON storage.objects FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY avatars_update
|
||||||
|
ON storage.objects FOR UPDATE
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY avatars_delete
|
||||||
|
ON storage.objects FOR DELETE
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user