Merge branch 'feature/drizzle-rls-real-auth'

# Conflicts:
#	CLAUDE.md
#	package.json
#	src/app/api/auth/[...nextauth]/route.ts
#	src/app/login/LoginClient.tsx
#	src/auth.config.ts
#	src/components/admin/AdminSidebar.tsx
#	src/lib/admin-permissions-types.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/middleware.ts
This commit is contained in:
2026-06-07 01:55:06 +00:00
84 changed files with 4165 additions and 2881 deletions
@@ -0,0 +1,214 @@
-- 204_admin_users_email_and_auth_subject.sql
--
-- Prepare `admin_users` for Auth.js v5 multi-provider sign-in.
--
-- Context:
-- The platform is migrating from a Supabase-only auth flow to Auth.js v5
-- with multiple providers (Google, Supabase password). With Auth.js, a
-- sign-in "subject" is the provider's stable user identifier:
-- - Supabase password: a UUID (matches `auth.users.id`).
-- - Google OAuth: an opaque string (the Google `sub` claim).
-- The existing `admin_users.user_id UUID` column can only hold Supabase
-- UUIDs, so Google sign-ins have nowhere to land. We also need the
-- `admin_users` row to carry an `email` directly so server-side lookups
-- no longer have to JOIN `auth.users` (which won't exist for Google users
-- anyway).
--
-- This migration:
-- 1. Adds `email`, `auth_provider`, `auth_subject` columns.
-- 2. Backfills `email` + `auth_provider` for existing Supabase rows.
-- 3. Adds a unique index on `(auth_provider, auth_subject)` so the same
-- Google subject can't be provisioned twice.
-- 4. Replaces `upsert_admin_user` with a version that accepts email +
-- provider + subject and can mint first-time Google sign-ins.
-- 5. Adds `get_admin_user_for_session` so the application layer can
-- resolve an Auth.js `session.user.id` to an admin row in one call
-- (no `auth.users` join required).
--
-- This is idempotent — re-running is safe.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Schema additions
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE public.admin_users
ADD COLUMN IF NOT EXISTS email TEXT,
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
ADD COLUMN IF NOT EXISTS auth_subject TEXT;
-- Backfill: pull email + mark existing rows as Supabase-originated.
UPDATE public.admin_users au
SET
email = COALESCE(au.email, u.email),
auth_provider = COALESCE(au.auth_provider, 'supabase')
FROM auth.users u
WHERE au.user_id = u.id
AND (au.email IS NULL OR au.auth_provider IS NULL);
-- Backfill the remaining rows (no auth.users match) so the NOT NULL
-- constraint below doesn't reject them. These are likely orphaned test
-- rows; flag them with provider='supabase-orphan' for visibility.
UPDATE public.admin_users
SET
email = COALESCE(email, 'unknown-' || id::TEXT || '@orphan.local'),
auth_provider = COALESCE(auth_provider, 'supabase-orphan')
WHERE email IS NULL OR auth_provider IS NULL;
-- Enforce that every row carries an email and a provider. The lookup
-- RPCs rely on `email` being NOT NULL.
ALTER TABLE public.admin_users
ALTER COLUMN email SET NOT NULL,
ALTER COLUMN auth_provider SET NOT NULL;
-- CHECK: a row must have either a Supabase `user_id` (UUID) or an
-- `auth_subject` (Google etc.). This catches the case where a row is
-- accidentally inserted with neither.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'admin_users_subject_check'
) THEN
ALTER TABLE public.admin_users
ADD CONSTRAINT admin_users_subject_check
CHECK (user_id IS NOT NULL OR auth_subject IS NOT NULL);
END IF;
END $$;
-- Index: fast lookup by Auth.js session id (which is `auth_subject` for
-- Google, `user_id` for Supabase).
CREATE INDEX IF NOT EXISTS admin_users_user_id_idx
ON public.admin_users (user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS admin_users_auth_subject_idx
ON public.admin_users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
-- Unique: prevent the same Google subject from being provisioned twice.
CREATE UNIQUE INDEX IF NOT EXISTS admin_users_auth_subject_unique
ON public.admin_users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
-- Index: fast lookup by email (used for admin invites + future "sign in
-- with email" flows).
CREATE INDEX IF NOT EXISTS admin_users_email_idx
ON public.admin_users (email);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. upsert_admin_user — replace with multi-provider version
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop the old single-arg version if it exists (Supabase may have
-- auto-generated it). Idempotent.
DROP FUNCTION IF EXISTS public.upsert_admin_user(UUID);
CREATE OR REPLACE FUNCTION public.upsert_admin_user(
p_user_id UUID DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_auth_provider TEXT DEFAULT 'supabase',
p_auth_subject TEXT DEFAULT NULL
)
RETURNS public.admin_users
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_existing public.admin_users;
v_inserted public.admin_users;
BEGIN
-- 1. Find an existing row by the strongest signal we have.
IF p_auth_subject IS NOT NULL AND p_auth_provider <> 'supabase' THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE auth_provider = p_auth_provider
AND auth_subject = p_auth_subject
LIMIT 1;
ELSIF p_user_id IS NOT NULL THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE user_id = p_user_id
LIMIT 1;
ELSIF p_email IS NOT NULL THEN
SELECT * INTO v_existing
FROM public.admin_users
WHERE lower(email) = lower(p_email)
LIMIT 1;
END IF;
IF v_existing.id IS NOT NULL THEN
-- 2a. Update missing fields in place (e.g. fill in `email` if the
-- row was orphaned at provision time).
UPDATE public.admin_users
SET
email = COALESCE(email, p_email),
auth_provider = COALESCE(NULLIF(auth_provider, 'supabase-orphan'), p_auth_provider),
auth_subject = COALESCE(auth_subject, p_auth_subject),
user_id = COALESCE(user_id, p_user_id)
WHERE id = v_existing.id
RETURNING * INTO v_existing;
RETURN v_existing;
END IF;
-- 2b. First-time sign-in. Insert a platform_admin row. Active is
-- true so the user can sign in immediately; brand assignment is
-- a manual step the platform admin takes later.
INSERT INTO public.admin_users (
user_id, email, auth_provider, auth_subject, role, active,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
must_change_password
) VALUES (
p_user_id, p_email, p_auth_provider, p_auth_subject,
'platform_admin', true,
true, true, true, true, true, true, true, true, true,
false
)
RETURNING * INTO v_inserted;
RETURN v_inserted;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_admin_user_for_session — Auth.js session resolver
-- ═══════════════════════════════════════════════════════════════════════════
-- Returns the admin row matching an Auth.js `session.user.id`, looking
-- up by `user_id` (Supabase UUID) OR `auth_subject` (Google etc.).
-- Returns NULL on miss — the application layer decides whether to
-- auto-provision a new row.
DROP FUNCTION IF EXISTS public.get_admin_user_for_session(TEXT);
CREATE OR REPLACE FUNCTION public.get_admin_user_for_session(p_session_id TEXT)
RETURNS public.admin_users
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row public.admin_users;
BEGIN
-- Try Supabase path first (UUID match on user_id).
IF p_session_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
SELECT * INTO v_row
FROM public.admin_users
WHERE user_id = p_session_id::UUID
LIMIT 1;
IF v_row.id IS NOT NULL THEN
RETURN v_row;
END IF;
END IF;
-- Fallback: treat the session id as a provider `auth_subject`. The
-- provider is unknown at this point, so match on the subject alone
-- (still unique thanks to the partial unique index).
SELECT * INTO v_row
FROM public.admin_users
WHERE auth_subject = p_session_id
LIMIT 1;
RETURN v_row;
END;
$$;
+111
View File
@@ -0,0 +1,111 @@
-- _000_auth_schema.sql
--
-- Local stand-in for the Supabase `auth` schema. Supabase ships a
-- built-in `auth.users` table and `auth.uid()` / `auth.role()` functions
-- that SECURITY DEFINER RPCs read. For a direct-Postgres deployment
-- (no Supabase platform), we recreate the minimum surface those RPCs
-- depend on, and use Postgres session GUCs to thread the caller's
-- identity from the application layer.
--
-- Production auth model:
-- - Auth.js v5 manages the user session (Google OAuth in /login,
-- `dev_session` cookie for the demo flow).
-- - Each `pg` connection that calls a SECURITY DEFINER RPC first
-- runs `SELECT set_config('app.current_user_id', $1, true)` so
-- `auth.uid()` returns the correct value inside the RPC.
-- - The app-level middleware (`getAdminUser()`) is the primary
-- authorization gate; the RPCs are a defense-in-depth check that
-- the caller is in `admin_users` and not a foreign brand.
--
-- This file is local-only and should NOT be pushed to a Supabase-hosted
-- DB (the schema already exists there).
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Schema
-- ═══════════════════════════════════════════════════════════════════════════
CREATE SCHEMA IF NOT EXISTS auth;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. auth.users — minimal Supabase-compatible shape
-- ═══════════════════════════════════════════════════════════════════════════
--
-- Columns the migrations actually read:
-- id, email, raw_user_meta_data, raw_app_meta_data, encrypted_password
-- (Supabase's full schema has ~30 columns; the SECURITY DEFINER
-- functions in this codebase only need the four above.)
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
raw_user_meta_data JSONB DEFAULT '{}'::jsonb,
raw_app_meta_data JSONB DEFAULT '{}'::jsonb,
encrypted_password TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Mirror Supabase's `auth.identities` for the `update_admin_user`
-- trigger that writes back to `auth.users`. Most migrations don't
-- touch it; kept here so a stray FK / view doesn't blow up.
CREATE TABLE IF NOT EXISTS auth.identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
identity_data JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (provider, provider_id)
);
CREATE INDEX IF NOT EXISTS auth_identities_user_id_idx
ON auth.identities (user_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. auth.uid() / auth.role() — session helpers
-- ═══════════════════════════════════════════════════════════════════════════
--
-- These mirror Supabase's signature. They read Postgres session GUCs
-- (`app.current_user_id` and `app.current_user_role`) that the
-- application layer sets before calling SECURITY DEFINER RPCs:
--
-- await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
-- await client.rpc("get_admin_users", { p_brand_id });
--
-- The `true` argument makes the setting transaction-local, so it
-- auto-resets at COMMIT / ROLLBACK — no leakage across pooled
-- connections.
CREATE OR REPLACE FUNCTION auth.uid()
RETURNS UUID
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('app.current_user_id', true), '')::UUID;
$$;
CREATE OR REPLACE FUNCTION auth.role()
RETURNS TEXT
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(NULLIF(current_setting('app.current_user_role', true), ''), 'anon');
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. notify_pgrst — stub for PostgREST schema-reload signaling
-- ═══════════════════════════════════════════════════════════════════════════
--
-- Some migrations `NOTIFY pgrst, 'reload schema'` to tell PostgREST to
-- refresh its cache. In a direct-pg deployment, no PostgREST runs, so
-- this is a no-op.
CREATE OR REPLACE FUNCTION public.notify_pgrst()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
-- no-op: no PostgREST to notify in a direct-pg deployment
NULL;
END;
$$;