feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup
- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only) - Add @types/pg devDep - Migration 204: add email, auth_provider, auth_subject columns to admin_users; backfill from auth.users; new upsert_admin_user accepts multi-provider args; new get_admin_user_for_session RPC resolves Auth.js session id (UUID or Google sub) to an admin row - Refactor getAdminUser() to use pg + new RPC; auto-provisions on first Google sign-in using session.user.email - Refactor updatePasswordAction to call update_user_password via pg (drops the Supabase REST hop) - Delete orphaned src/actions/login.ts (replaced by auth-actions.ts) - Drop remaining DEV_FORCE_UID references in users.ts; dev path now uses dev_session cookie (the only cookie the dev flow can set) - Update AdminUser type: user_id is now string | null (Google users have no Supabase auth id); add email + auth_provider fields - Fix downstream type errors: StopProductAssignment.callerUid accepts null; pickup.ts performedBy widens to string | null - Bump vitest config, tests/, and other earlier cleanup changes
This commit is contained in:
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user