-- Super-Admin account migration (organized-ideas.md §2's "lost access to -- the old Google account" tier — the self-service link/unlink path in -- ProfileModal only covers the case where you still control the old -- account). Discussed at length before building: identity verification is -- fundamentally a human problem no function can solve, so this is deliberately -- narrow — it moves *ownership of app data*, never credentials — and it's -- built to make good practice easy rather than to enforce it outright: -- * Required, freeform verification-notes field — the function refuses to -- run without one, but what counts as adequate verification is a human -- judgment call this schema doesn't try to make for anyone. -- * A permanent audit row per migration: who performed it, the two -- accounts (by id and by username snapshot, so the log stays readable -- even if an account is later deleted), what moved, and why it was -- believed safe. -- * Never touches auth.users, passwords, or email — only ownership -- columns already used to scope RLS elsewhere in this schema. -- * Never deletes the old account — it's left exactly as it was (still -- banned/deleted only by the existing, separate, reversible tools) with -- just a marker recording where its data went. -- ---------------------------------------------------------------------- -- profiles.migrated_to_user_id — set once an account's data has been moved -- elsewhere, both to show that state in Manage Users and to stop the same -- already-emptied account from being migrated a second time by mistake. -- ---------------------------------------------------------------------- alter table public.profiles add column migrated_to_user_id uuid references public.profiles (id) on delete set null; -- ---------------------------------------------------------------------- -- account_migrations — permanent audit log. Written only by -- migrate_account_ownership() below (a security definer function), so -- there's deliberately no insert/update/delete policy for the authenticated -- role at all — only a select policy, for Super Admins to review the log. -- ---------------------------------------------------------------------- create table public.account_migrations ( id uuid primary key default gen_random_uuid(), from_user_id uuid references public.profiles (id) on delete set null, to_user_id uuid references public.profiles (id) on delete set null, -- Snapshotted at migration time so the log stays legible even after one -- of the accounts is later deleted (the FKs above go null, these don't). from_username text not null, to_username text not null, performed_by uuid references public.profiles (id) on delete set null, verification_notes text not null, diagram_count integer not null, private_entity_count integer not null, submission_count integer not null, created_at timestamptz not null default now() ); alter table public.account_migrations enable row level security; create policy "account_migrations_select" on public.account_migrations for select using (public.is_super_admin()); -- ---------------------------------------------------------------------- -- Read-only impact preview — shown before a Super Admin commits, same -- "see the blast radius first" idea as catalog_entity_usage_impact. -- ---------------------------------------------------------------------- create or replace function public.account_migration_preview(p_from_user_id uuid, p_to_user_id uuid) returns table(diagram_count integer, private_entity_count integer, submission_count integer) language plpgsql stable security definer set search_path = public as $$ begin if not public.is_super_admin() then raise exception 'insufficient_privilege' using errcode = '42501'; end if; return query select (select count(*)::int from public.diagrams where owner_id = p_from_user_id), ( (select count(*)::int from public.device_templates where owner_id = p_from_user_id and not is_public) + (select count(*)::int from public.device_categories where owner_id = p_from_user_id and not is_public) + (select count(*)::int from public.manufacturers where owner_id = p_from_user_id and not is_public) + (select count(*)::int from public.port_types where owner_id = p_from_user_id and not is_public) + (select count(*)::int from public.cable_types where owner_id = p_from_user_id and not is_public) ), (select count(*)::int from public.catalog_submissions where submitter_id = p_from_user_id); end; $$; -- ---------------------------------------------------------------------- -- The actual migration. Runs as security definer, which means it bypasses -- RLS entirely — including diagram_collaborators_insert/_update's "not your -- own diagram's owner" check added in 20260914000000_prevent_self_collaborator.sql. -- That check exists to stop a self-collaborator row from ever being -- created; bypassing it here means this function has to uphold that same -- invariant by hand (the explicit dedupe deletes below), not rely on RLS -- to catch a mistake the way client code could. -- ---------------------------------------------------------------------- create or replace function public.migrate_account_ownership(p_from_user_id uuid, p_to_user_id uuid, p_verification_notes text) returns table(diagram_count integer, private_entity_count integer, submission_count integer) language plpgsql security definer set search_path = public as $$ declare v_diagram_count int; v_template_count int; v_category_count int; v_manufacturer_count int; v_port_type_count int; v_cable_type_count int; v_submission_count int; v_from_username text; v_to_username text; v_already_migrated uuid; begin if not public.is_super_admin() then raise exception 'insufficient_privilege' using errcode = '42501'; end if; if p_from_user_id = p_to_user_id then raise exception 'Cannot migrate an account into itself.' using errcode = '22023'; end if; if trim(coalesce(p_verification_notes, '')) = '' then raise exception 'Verification notes are required.' using errcode = '22023'; end if; select username, migrated_to_user_id into v_from_username, v_already_migrated from public.profiles where id = p_from_user_id; select username into v_to_username from public.profiles where id = p_to_user_id; if v_from_username is null or v_to_username is null then raise exception 'Both accounts must exist.' using errcode = '22023'; end if; if v_already_migrated is not null then raise exception 'This account has already been migrated.' using errcode = '22023'; end if; -- Diagrams the old account owns outright become the new account's — but -- first drop a now-redundant self-collaborator row if the new account -- happened to already be a collaborator on one of them (it's about to -- become the owner, which already implies full access). delete from public.diagram_collaborators dc using public.diagrams d where dc.diagram_id = d.id and d.owner_id = p_from_user_id and dc.user_id = p_to_user_id; update public.diagrams set owner_id = p_to_user_id where owner_id = p_from_user_id; get diagnostics v_diagram_count = row_count; -- Collaborator invitations the old account held on *other* people's -- diagrams move the same way — dropping the old account's row instead of -- moving it wherever the new account is already a collaborator there too. delete from public.diagram_collaborators dc1 where dc1.user_id = p_from_user_id and exists ( select 1 from public.diagram_collaborators dc2 where dc2.diagram_id = dc1.diagram_id and dc2.user_id = p_to_user_id ); update public.diagram_collaborators set user_id = p_to_user_id where user_id = p_from_user_id; -- Private catalog entries only — a public entry isn't "owned" in any -- sense that matters to move, and moving it would touch shared state well -- outside what this tool is meant to reach. update public.device_templates set owner_id = p_to_user_id where owner_id = p_from_user_id and not is_public; get diagnostics v_template_count = row_count; update public.device_categories set owner_id = p_to_user_id where owner_id = p_from_user_id and not is_public; get diagnostics v_category_count = row_count; update public.manufacturers set owner_id = p_to_user_id where owner_id = p_from_user_id and not is_public; get diagnostics v_manufacturer_count = row_count; update public.port_types set owner_id = p_to_user_id where owner_id = p_from_user_id and not is_public; get diagnostics v_port_type_count = row_count; update public.cable_types set owner_id = p_to_user_id where owner_id = p_from_user_id and not is_public; get diagnostics v_cable_type_count = row_count; -- Pending/past submissions move too, so "My Submissions" stays continuous -- for whoever's now the same person under a new account. update public.catalog_submissions set submitter_id = p_to_user_id where submitter_id = p_from_user_id; get diagnostics v_submission_count = row_count; -- Deliberately untouched: role, can_post_announcements (a fresh account -- shouldn't silently inherit elevated capability), username (both -- accounts keep their own), and announcement_dismissals (preference-only, -- not worth the complexity). update public.profiles set migrated_to_user_id = p_to_user_id where id = p_from_user_id; insert into public.account_migrations ( from_user_id, to_user_id, from_username, to_username, performed_by, verification_notes, diagram_count, private_entity_count, submission_count ) values ( p_from_user_id, p_to_user_id, v_from_username, v_to_username, auth.uid(), p_verification_notes, v_diagram_count, v_template_count + v_category_count + v_manufacturer_count + v_port_type_count + v_cable_type_count, v_submission_count ); return query select v_diagram_count, v_template_count + v_category_count + v_manufacturer_count + v_port_type_count + v_cable_type_count, v_submission_count; end; $$; -- Manage Users needs to know which accounts have already been migrated (to -- show it, and to grey out migrating them again) — extends the same -- function AdminUsersModal already calls, rather than a separate round trip. drop function public.list_users_for_admin(); create function public.list_users_for_admin() returns table( id uuid, username text, email text, role text, can_post_announcements boolean, banned_until timestamptz, created_at timestamptz, migrated_to_user_id uuid, migrated_to_username text ) language plpgsql stable security definer set search_path = public as $$ begin if not public.is_super_admin() then raise exception 'insufficient_privilege' using errcode = '42501'; end if; return query select p.id, p.username, u.email::text, p.role, p.can_post_announcements, u.banned_until, p.created_at, p.migrated_to_user_id, mp.username from public.profiles p join auth.users u on u.id = p.id left join public.profiles mp on mp.id = p.migrated_to_user_id order by p.created_at desc; end; $$;