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:
@@ -65,10 +65,14 @@ enable_confirmations = false
|
||||
enable_signup = false
|
||||
enable_confirmations = false
|
||||
|
||||
# Keycloak as OIDC provider
|
||||
# The JWT_SECRET must be set to the Keycloak RS256 public key
|
||||
# Keycloak as external OIDC provider via GoTrue (Option B).
|
||||
# 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]
|
||||
enabled = false # We bypass GoTrue — Keycloak JWT validated directly by PostgREST
|
||||
enabled = false
|
||||
|
||||
[edge_runtime]
|
||||
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