Add site-wide announcements, account migration, and profile self-service

Announcements: a Super Admin (or an Admin individually flagged via
profiles.can_post_announcements) can post/retire a site-wide banner.
Account migration: a Super Admin can move a locked-out user's diagrams,
private catalog entries, and submissions to another account, with a
migration-history log; ProfileModal adds the self-service half (link a new
Google identity via Supabase manual linking, then unlink the old one,
while signed in as the account being migrated). Also reworks the top bar's
flat button row into grouped dropdown menus (Diagram / Admin / Account) now
that there are enough entries to need it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-28 10:05:11 -05:00
co-authored by Claude Sonnet 5
parent 143a96aed1
commit 6cdfde822d
18 changed files with 1857 additions and 56 deletions
@@ -0,0 +1,135 @@
-- Site-wide announcements (organized-ideas.md §3): a dismissible banner
-- every signed-in user sees, meant as a heads-up right after a Super Admin
-- (or a specially-flagged Admin) makes a compatibility-affecting catalog
-- change, so nobody's surprised by something that already shipped.
--
-- Decided shape (discussed directly, not just inferred from the plan doc):
-- * Posting permission: every Super Admin, plus any Admin individually
-- flagged for it — not the whole Admin role automatically. A narrow,
-- separately-grantable bit alongside the coarse role enum, same idea as
-- is_admin()/is_super_admin() but per-user rather than per-role.
-- * One current announcement at a time. Posting a new one automatically
-- retires whichever was previously active (a trigger, not something
-- every insert path has to remember to do) — history rows stick around
-- (never hard-deleted, same ethos as the catalog's "unpublish, don't
-- delete") but only the current one is ever shown.
-- * Dismissal is per-account, in the database, and per-announcement (not
-- a single "seen the banner" bit) — so dismissing the current one does
-- nothing to hide whatever gets posted next, on any device you sign
-- into.
alter table public.profiles add column can_post_announcements boolean not null default false;
-- profiles_update_self_or_super_admin (init schema) already stops a
-- self-update from changing your own `role` — extend that same guard to
-- this new column, or an Admin could just grant themselves posting rights
-- with a plain `update profiles set can_post_announcements = true`. Only a
-- Super Admin (the `is_super_admin()` branch, unconstrained) can flip it.
drop policy "profiles_update_self_or_super_admin" on public.profiles;
create policy "profiles_update_self_or_super_admin"
on public.profiles for update
using (id = auth.uid() or public.is_super_admin())
with check (
public.is_super_admin()
or (
id = auth.uid()
and role = (select role from public.profiles where id = auth.uid())
and can_post_announcements = (select can_post_announcements from public.profiles where id = auth.uid())
)
);
-- Shared by the announcements table's insert/update policies below —
-- mirrors is_admin()/is_super_admin()'s shape rather than inlining the
-- flag lookup twice.
create or replace function public.can_manage_announcements()
returns boolean
language sql
stable
as $$
select
public.is_super_admin()
or (public.current_user_role() = 'admin' and coalesce((select can_post_announcements from public.profiles where id = auth.uid()), false));
$$;
create table public.announcements (
id uuid primary key default gen_random_uuid(),
message text not null,
created_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
-- null = this is the current banner. Set the moment a newer one is
-- posted (see the trigger below), or early by whoever posted it.
retired_at timestamptz
);
alter table public.announcements enable row level security;
-- Every signed-in user sees the current one; past ones are a Super-Admin-
-- only audit trail (nobody's asked to browse announcement history yet, but
-- the rows are there whenever that's wanted).
create policy "announcements_select" on public.announcements for select
using (retired_at is null or public.is_super_admin());
create policy "announcements_insert" on public.announcements for insert
with check (public.can_manage_announcements() and created_by = auth.uid());
-- Covers retiring the current one early, or editing its text — anyone
-- currently allowed to post is trusted to manage the current banner,
-- not just whoever originally wrote it.
create policy "announcements_update" on public.announcements for update
using (public.can_manage_announcements())
with check (public.can_manage_announcements());
create or replace function public.retire_previous_announcement()
returns trigger
language plpgsql
as $$
begin
update public.announcements set retired_at = now() where retired_at is null;
return new;
end;
$$;
create trigger retire_previous_announcement_trigger
before insert on public.announcements
for each row execute function public.retire_previous_announcement();
create table public.announcement_dismissals (
user_id uuid not null references public.profiles (id) on delete cascade,
announcement_id uuid not null references public.announcements (id) on delete cascade,
dismissed_at timestamptz not null default now(),
primary key (user_id, announcement_id)
);
alter table public.announcement_dismissals enable row level security;
create policy "announcement_dismissals_select" on public.announcement_dismissals for select
using (user_id = auth.uid());
create policy "announcement_dismissals_insert" on public.announcement_dismissals for insert
with check (user_id = auth.uid());
-- Extend the Super-Admin user list with the new flag, so Manage Users can
-- show/toggle it per Admin. `create or replace` can't change a function's
-- return-table shape, only drop-then-recreate can.
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)
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
from public.profiles p
join auth.users u on u.id = p.id
order by p.created_at desc;
end;
$$;
@@ -0,0 +1,234 @@
-- 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;
$$;