mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
PR 3 of the multi-user accounts series. Lands the server-side endpoints the upcoming Members tab UI will call. Built on the helpers from #169 (`requireRole`, `getCurrentAccount`, `toErrorResponse`). API surface - GET /api/account — caller's account + role - PATCH /api/account — rename (admin+) - GET /api/account/members — roster; email visible to admin+ only - PATCH /api/account/members/[userId] — change role (admin+) - DELETE /api/account/members/[userId] — remove (admin+) - POST /api/account/transfer-ownership — atomic owner handoff (owner) Member-role writes can't ride on the regular `profiles_update` RLS policy (which only allows self-edits — admins can't modify a teammate's row that way). The three SECURITY DEFINER RPCs in the new migration are the scoped escape hatches: - `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks promotion to / demotion from owner (use transfer instead) and self-targeting. - `remove_account_member(p_user_id)` — admin+ only. Creates a fresh personal account for the removed user and reassigns their profile as its 'owner' — mirror of the signup flow, so they keep their login and just "start over". Blocks removing the owner or self. - `transfer_account_ownership(p_new_owner_user_id)` — owner only. Atomically demotes current owner → admin, promotes target → owner, and points accounts.owner_user_id at the new owner. Demote-first ordering means the temporary "zero owners" state is never visible. Error contract - Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad input) and a human-readable RAISE message. The route handlers map these onto 403 / 400 respectively; anything else collapses to a generic 500. - The TS guards (e.g. `requireRole('owner')` on the transfer route, `isAccountRole(role)` on the PATCH route) are belt-and-braces — they fail fast before the Supabase round trip but the RPCs would catch the same violations regardless. Field visibility - `GET /api/account/members` returns `email` only when the caller is admin+. Agents and viewers see name + avatar + role + joined date — matches the planning decision so a viewer can't harvest the team's email list. Verification - `npm run lint` → 0 errors (same 20 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds; the 4 new routes register as ƒ (dynamic — they all use `next/headers` cookies via the SSR client) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
9.0 KiB
PL/PgSQL
284 lines
9.0 KiB
PL/PgSQL
-- ============================================================
|
|
-- 018_account_member_rpcs.sql — RPCs for member management
|
|
--
|
|
-- Why RPCs and not direct UPDATEs from the client
|
|
--
|
|
-- The `profiles_update` RLS policy from migration 017 only
|
|
-- allows a user to update their *own* profile row. That is
|
|
-- correct for self-service edits (name, avatar) but it would
|
|
-- block an admin from changing a teammate's role or moving
|
|
-- a removed member to a fresh personal account.
|
|
--
|
|
-- These three SECURITY DEFINER functions are the supervised
|
|
-- escape hatches: they bypass RLS to do exactly the writes the
|
|
-- matching API route needs, but every function self-checks the
|
|
-- caller's authority via `auth.uid()` first, so the privilege
|
|
-- bypass is scoped tightly.
|
|
--
|
|
-- Error contract
|
|
--
|
|
-- All functions raise Postgres exceptions with these SQLSTATEs:
|
|
-- 42501 ("insufficient_privilege") — forbidden
|
|
-- 22023 ("invalid_parameter_value") — bad input / 400
|
|
-- The `toErrorResponse` helper on the API side maps each to
|
|
-- the right HTTP status, with the RAISE message surfaced to
|
|
-- the caller.
|
|
--
|
|
-- Idempotent — safe to run multiple times.
|
|
-- ============================================================
|
|
|
|
-- ============================================================
|
|
-- set_member_role(p_user_id, p_new_role)
|
|
--
|
|
-- Admin+ changes another member's role within the caller's
|
|
-- account. Cannot promote to / demote from 'owner' (that is the
|
|
-- transfer endpoint). Cannot target self.
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION public.set_member_role(
|
|
p_user_id UUID,
|
|
p_new_role account_role_enum
|
|
) RETURNS VOID
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_caller_account_id UUID;
|
|
v_caller_role account_role_enum;
|
|
v_target_account_id UUID;
|
|
v_target_role account_role_enum;
|
|
BEGIN
|
|
-- Caller must be authenticated.
|
|
IF auth.uid() IS NULL THEN
|
|
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Resolve caller's account + role.
|
|
SELECT account_id, account_role
|
|
INTO v_caller_account_id, v_caller_role
|
|
FROM profiles
|
|
WHERE user_id = auth.uid();
|
|
|
|
IF v_caller_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Caller must be admin+.
|
|
IF v_caller_role NOT IN ('owner', 'admin') THEN
|
|
RAISE EXCEPTION 'This action requires the admin role or higher'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Can't change own role via this endpoint.
|
|
IF p_user_id = auth.uid() THEN
|
|
RAISE EXCEPTION 'Cannot change your own role'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
-- Resolve target.
|
|
SELECT account_id, account_role
|
|
INTO v_target_account_id, v_target_role
|
|
FROM profiles
|
|
WHERE user_id = p_user_id;
|
|
|
|
IF v_target_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
-- Target must be in caller's account.
|
|
IF v_target_account_id <> v_caller_account_id THEN
|
|
RAISE EXCEPTION 'Target user is not a member of your account'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Owner role changes go through transfer_account_ownership.
|
|
IF v_target_role = 'owner' THEN
|
|
RAISE EXCEPTION 'Use transfer_account_ownership to demote an owner'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
IF p_new_role = 'owner' THEN
|
|
RAISE EXCEPTION 'Use transfer_account_ownership to promote to owner'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
UPDATE profiles
|
|
SET account_role = p_new_role
|
|
WHERE user_id = p_user_id;
|
|
END;
|
|
$$;
|
|
|
|
ALTER FUNCTION public.set_member_role(UUID, account_role_enum) OWNER TO postgres;
|
|
REVOKE ALL ON FUNCTION public.set_member_role(UUID, account_role_enum) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.set_member_role(UUID, account_role_enum) TO authenticated;
|
|
|
|
-- ============================================================
|
|
-- remove_account_member(p_user_id)
|
|
--
|
|
-- Admin+ removes another member from the caller's account. The
|
|
-- removed user is NOT deleted from auth.users — they keep their
|
|
-- login. Instead, a fresh personal account is created on the fly
|
|
-- and their profile is reassigned to it as 'owner'. This is the
|
|
-- mirror image of the signup trigger: the user effectively
|
|
-- "starts over" with an empty account, free to invite their own
|
|
-- teammates if they want.
|
|
--
|
|
-- Cannot target the owner. Cannot target self.
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION public.remove_account_member(
|
|
p_user_id UUID
|
|
) RETURNS UUID -- the new personal account id
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_caller_account_id UUID;
|
|
v_caller_role account_role_enum;
|
|
v_target_account_id UUID;
|
|
v_target_role account_role_enum;
|
|
v_target_name TEXT;
|
|
v_target_email TEXT;
|
|
v_new_account_id UUID;
|
|
BEGIN
|
|
IF auth.uid() IS NULL THEN
|
|
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT account_id, account_role
|
|
INTO v_caller_account_id, v_caller_role
|
|
FROM profiles
|
|
WHERE user_id = auth.uid();
|
|
|
|
IF v_caller_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
IF v_caller_role NOT IN ('owner', 'admin') THEN
|
|
RAISE EXCEPTION 'This action requires the admin role or higher'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
IF p_user_id = auth.uid() THEN
|
|
RAISE EXCEPTION 'Cannot remove yourself; transfer ownership or leave the account instead'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT account_id, account_role, full_name, email
|
|
INTO v_target_account_id, v_target_role, v_target_name, v_target_email
|
|
FROM profiles
|
|
WHERE user_id = p_user_id;
|
|
|
|
IF v_target_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF v_target_account_id <> v_caller_account_id THEN
|
|
RAISE EXCEPTION 'Target user is not a member of your account'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
IF v_target_role = 'owner' THEN
|
|
RAISE EXCEPTION 'Cannot remove the account owner; transfer ownership first'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
-- Spin up a fresh personal account for the removed user. Mirror
|
|
-- of handle_new_user's logic — keep them whole, just relocated.
|
|
INSERT INTO accounts (name, owner_user_id)
|
|
VALUES (
|
|
COALESCE(NULLIF(v_target_name, ''), v_target_email, 'My account'),
|
|
p_user_id
|
|
)
|
|
RETURNING id INTO v_new_account_id;
|
|
|
|
UPDATE profiles
|
|
SET account_id = v_new_account_id,
|
|
account_role = 'owner'
|
|
WHERE user_id = p_user_id;
|
|
|
|
RETURN v_new_account_id;
|
|
END;
|
|
$$;
|
|
|
|
ALTER FUNCTION public.remove_account_member(UUID) OWNER TO postgres;
|
|
REVOKE ALL ON FUNCTION public.remove_account_member(UUID) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.remove_account_member(UUID) TO authenticated;
|
|
|
|
-- ============================================================
|
|
-- transfer_account_ownership(p_new_owner_user_id)
|
|
--
|
|
-- Owner only. Atomically:
|
|
-- - demotes the current owner to 'admin'
|
|
-- - promotes the target to 'owner'
|
|
-- - updates accounts.owner_user_id
|
|
--
|
|
-- Both writes happen in the same statement-level transaction.
|
|
-- ============================================================
|
|
CREATE OR REPLACE FUNCTION public.transfer_account_ownership(
|
|
p_new_owner_user_id UUID
|
|
) RETURNS VOID
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_caller_account_id UUID;
|
|
v_caller_role account_role_enum;
|
|
v_target_account_id UUID;
|
|
v_target_role account_role_enum;
|
|
BEGIN
|
|
IF auth.uid() IS NULL THEN
|
|
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT account_id, account_role
|
|
INTO v_caller_account_id, v_caller_role
|
|
FROM profiles
|
|
WHERE user_id = auth.uid();
|
|
|
|
IF v_caller_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
IF v_caller_role <> 'owner' THEN
|
|
RAISE EXCEPTION 'Only the account owner can transfer ownership'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
IF p_new_owner_user_id = auth.uid() THEN
|
|
RAISE EXCEPTION 'You are already the owner'
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT account_id, account_role
|
|
INTO v_target_account_id, v_target_role
|
|
FROM profiles
|
|
WHERE user_id = p_new_owner_user_id;
|
|
|
|
IF v_target_account_id IS NULL THEN
|
|
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF v_target_account_id <> v_caller_account_id THEN
|
|
RAISE EXCEPTION 'Target user is not a member of your account'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
-- Demote current owner first so the temporary state where the
|
|
-- account has zero owners is never visible — both writes happen
|
|
-- in the same function transaction.
|
|
UPDATE profiles SET account_role = 'admin'
|
|
WHERE user_id = auth.uid();
|
|
|
|
UPDATE profiles SET account_role = 'owner'
|
|
WHERE user_id = p_new_owner_user_id;
|
|
|
|
UPDATE accounts SET owner_user_id = p_new_owner_user_id
|
|
WHERE id = v_caller_account_id;
|
|
END;
|
|
$$;
|
|
|
|
ALTER FUNCTION public.transfer_account_ownership(UUID) OWNER TO postgres;
|
|
REVOKE ALL ON FUNCTION public.transfer_account_ownership(UUID) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.transfer_account_ownership(UUID) TO authenticated;
|