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
+377 -3
View File
@@ -25,7 +25,7 @@ begin;
create extension if not exists pgtap with schema extensions;
select plan(53);
select plan(103);
-- ----------------------------------------------------------------------
-- Fixtures (as postgres — RLS does not apply)
@@ -35,7 +35,12 @@ insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'alice@example.com', '{"username":"alice"}'),
('22222222-2222-2222-2222-222222222222', 'bob@example.com', '{"username":"bob"}'),
('33333333-3333-3333-3333-333333333333', 'carol@example.com', '{"username":"carol_admin"}'),
('44444444-4444-4444-4444-444444444444', 'dave@example.com', '{"username":"dave_superadmin"}');
('44444444-4444-4444-4444-444444444444', 'dave@example.com', '{"username":"dave_superadmin"}'),
-- Dedicated fixtures for the account-migration tests, kept separate from
-- alice/bob/carol/dave so that block reads standalone rather than relying
-- on state built up by every earlier test.
('55555555-5555-5555-5555-555555555555', 'eve@example.com', '{"username":"eve"}'),
('66666666-6666-6666-6666-666666666666', 'frank@example.com', '{"username":"frank"}');
update public.profiles set role = 'admin' where id = '33333333-3333-3333-3333-333333333333';
update public.profiles set role = 'super_admin' where id = '44444444-4444-4444-4444-444444444444';
@@ -401,7 +406,7 @@ select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111
select lives_ok(
$$ insert into public.diagrams (id, name, owner_id, data)
values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111',
'{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$,
'{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","manufacturerId":"mf-impact-test-manufacturer","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$,
'alice can insert a diagram referencing test catalog ids for the impact-check test'
);
@@ -441,12 +446,69 @@ select is(
'carol (admin) sees the correct impact count for a cable type'
);
select is(
(select diagram_count from public.catalog_entity_usage_impact('device_category', 'other')),
1,
'carol (admin) sees the correct impact count for a device category'
);
select is(
(select diagram_count from public.catalog_entity_usage_impact('manufacturer', 'mf-impact-test-manufacturer')),
1,
'carol (admin) sees the correct impact count for a manufacturer'
);
select is(
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
0,
'the impact count is zero for an entity id referenced by nothing'
);
-- ----------------------------------------------------------------------
-- A regular user can check the usage-impact of their OWN still-private
-- category/manufacturer (needed client-side before offering to delete it),
-- but not anyone else's, and not for other entity types — see this
-- migration's own comment for why that scope is safe.
-- ----------------------------------------------------------------------
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select lives_ok(
$$ insert into public.device_categories (id, name, owner_id, is_public)
values ('c0000000-0000-0000-0000-00000000b001', 'Bob''s Test Category', '22222222-2222-2222-2222-222222222222', false) $$,
'bob can insert his own private category (for the self-check tests below)'
);
select lives_ok(
$$ insert into public.manufacturers (id, name, owner_id, is_public)
values ('c0000000-0000-0000-0000-00000000b002', 'Bob''s Test Manufacturer', '22222222-2222-2222-2222-222222222222', false) $$,
'bob can insert his own private manufacturer (for the self-check tests below)'
);
select is(
(select diagram_count from public.catalog_entity_usage_impact('device_category', 'c0000000-0000-0000-0000-00000000b001')),
0,
'bob can check the usage-impact of his own private category'
);
select is(
(select diagram_count from public.catalog_entity_usage_impact('manufacturer', 'c0000000-0000-0000-0000-00000000b002')),
0,
'bob can check the usage-impact of his own private manufacturer'
);
select throws_ok(
$$ select * from public.catalog_entity_usage_impact('device_category', 'other') $$,
'42501'::char(5), null,
'bob cannot check the usage-impact of a public category he doesn''t own'
);
select throws_ok(
$$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$,
'42501'::char(5), null,
'the self-check bypass does not extend to device templates — bob still cannot check those'
);
-- ----------------------------------------------------------------------
-- Batched submitter-username lookup (gated the same way as the impact
-- function above — an Admin reviewing a submission can see who submitted
@@ -514,6 +576,318 @@ select is(
'the listing includes email, only readable via this Super-Admin-gated function (not directly through PostgREST)'
);
-- ----------------------------------------------------------------------
-- Site-wide announcements (organized-ideas.md §3): posting is gated by
-- can_manage_announcements() — every Super Admin, or an Admin individually
-- flagged via profiles.can_post_announcements (not the whole Admin role).
-- alice is 'admin' from the role-escalation test above but was never
-- flagged, so she doubles as the "admin without the flag" case; bob is
-- still 'regular' throughout, untouched by that promotion.
-- ----------------------------------------------------------------------
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select throws_ok(
$$ insert into public.announcements (message, created_by) values ('bob trying to post', '22222222-2222-2222-2222-222222222222') $$,
'42501'::char(5), null,
'bob (regular user) cannot post an announcement'
);
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
select throws_ok(
$$ insert into public.announcements (message, created_by) values ('alice trying to post', '11111111-1111-1111-1111-111111111111') $$,
'42501'::char(5), null,
'alice (admin, no can_post_announcements flag) cannot post an announcement'
);
select throws_ok(
$$ update public.profiles set can_post_announcements = true where id = '11111111-1111-1111-1111-111111111111' $$,
'42501'::char(5), null,
'alice cannot grant herself the can_post_announcements flag'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select lives_ok(
$$ update public.profiles set can_post_announcements = true where id = '33333333-3333-3333-3333-333333333333' $$,
'dave (super admin) can flag carol as allowed to post announcements'
);
select is(
(select can_post_announcements from public.list_users_for_admin() where username = 'carol_admin'),
true,
'the user listing reflects carol''s can_post_announcements flag'
);
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
select lives_ok(
$$ insert into public.announcements (id, message, created_by)
values ('c0000000-0000-0000-0000-00000000a001', 'carol''s announcement', '33333333-3333-3333-3333-333333333333') $$,
'carol (admin, now flagged) can post an announcement'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select lives_ok(
$$ insert into public.announcements (id, message, created_by)
values ('c0000000-0000-0000-0000-00000000a002', 'dave''s announcement', '44444444-4444-4444-4444-444444444444') $$,
'dave (super admin) can post an announcement, which retires carol''s'
);
select isnt(
(select retired_at from public.announcements where id = 'c0000000-0000-0000-0000-00000000a001'),
null,
'posting a new announcement automatically retires the previous current one'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select is(
(select count(*)::int from public.announcements),
1,
'bob (regular user) only sees the current announcement, not retired history'
);
select is(
(select message from public.announcements limit 1),
'dave''s announcement',
'the one announcement bob sees is the current one'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select is(
(select count(*)::int from public.announcements),
2,
'dave (super admin) can see retired announcements too, for history'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select lives_ok(
$$ insert into public.announcement_dismissals (user_id, announcement_id)
values ('22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-00000000a002') $$,
'bob can dismiss the current announcement for himself'
);
select throws_ok(
$$ insert into public.announcement_dismissals (user_id, announcement_id)
values ('11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-00000000a002') $$,
'42501'::char(5), null,
'bob cannot record a dismissal on alice''s behalf'
);
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
select is(
(select count(*)::int from public.announcement_dismissals where user_id = '22222222-2222-2222-2222-222222222222'),
0,
'alice cannot see bob''s dismissal row'
);
-- ----------------------------------------------------------------------
-- Account migration (organized-ideas.md §2's Super-Admin "lost access to
-- the old account" tool). Uses eve/frank — dedicated fixtures — rather than
-- alice/bob/carol/dave, so this block reads standalone instead of relying
-- on state accumulated by every test above it.
--
-- Fixture shape, set up as eve/bob below:
-- D1 (eve-owned): frank already a collaborator — exercises the
-- owner-transfer dedup (frank can't end up both owner and collaborator).
-- D2 (bob-owned): both eve and frank already collaborators — exercises
-- the "collaborator elsewhere" dedup (eve's row is dropped, not
-- duplicated, since frank already has his own).
-- ----------------------------------------------------------------------
select set_config('request.jwt.claim.sub', '55555555-5555-5555-5555-555555555555', true);
select lives_ok(
$$ insert into public.diagrams (id, name, owner_id, data)
values ('d0000000-0000-0000-0000-00000000d001', 'Eve''s Rig', '55555555-5555-5555-5555-555555555555',
'{"devices":[],"connections":[]}'::jsonb) $$,
'eve can insert her own diagram'
);
select lives_ok(
$$ insert into public.diagram_collaborators (diagram_id, user_id, permission)
values ('d0000000-0000-0000-0000-00000000d001', '66666666-6666-6666-6666-666666666666', 'view') $$,
'eve can add frank as a collaborator on her diagram'
);
select lives_ok(
$$ insert into public.device_categories (id, name, owner_id, is_public)
values ('c0000000-0000-0000-0000-00000000e001', 'Eve''s Test Category', '55555555-5555-5555-5555-555555555555', false) $$,
'eve can insert her own private category'
);
select lives_ok(
$$ insert into public.catalog_submissions (entity_type, entity_id, proposed_data, submitter_id)
values ('device_category', 'c0000000-0000-0000-0000-00000000e001', '{"name":"Eve''s Test Category"}'::jsonb, '55555555-5555-5555-5555-555555555555') $$,
'eve can submit her category for review'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select lives_ok(
$$ insert into public.diagrams (id, name, owner_id, data)
values ('d0000000-0000-0000-0000-00000000d002', 'Bob''s Shared Rig', '22222222-2222-2222-2222-222222222222',
'{"devices":[],"connections":[]}'::jsonb) $$,
'bob can insert a diagram for the collaborator-dedup test'
);
select lives_ok(
$$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values
('d0000000-0000-0000-0000-00000000d002', '55555555-5555-5555-5555-555555555555', 'edit'),
('d0000000-0000-0000-0000-00000000d002', '66666666-6666-6666-6666-666666666666', 'view') $$,
'bob can add both eve and frank as collaborators on his diagram'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select throws_ok(
$$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$,
'42501'::char(5), null,
'bob (regular user) cannot preview an account migration'
);
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
select throws_ok(
$$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$,
'42501'::char(5), null,
'carol (admin, not super admin) cannot preview an account migration either'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select lives_ok(
$$ select * from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666') $$,
'dave (super admin) can preview an account migration'
);
select is(
(select diagram_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')),
1,
'the preview counts eve''s one owned diagram'
);
select is(
(select private_entity_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')),
1,
'the preview counts eve''s one private category'
);
select is(
(select submission_count from public.account_migration_preview('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666')),
1,
'the preview counts eve''s one submission'
);
select throws_ok(
$$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'notes') $$,
'22023'::char(5), null,
'an account cannot be migrated into itself'
);
select throws_ok(
$$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', '') $$,
'22023'::char(5), null,
'migrating without verification notes is rejected'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select throws_ok(
$$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'notes') $$,
'42501'::char(5), null,
'bob (regular user) cannot perform an account migration'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select lives_ok(
$$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'Verified via a video call with eve.') $$,
'dave (super admin) can migrate eve''s account to frank'
);
select is(
(select owner_id from public.diagrams where id = 'd0000000-0000-0000-0000-00000000d001'),
'66666666-6666-6666-6666-666666666666'::uuid,
'eve''s owned diagram now belongs to frank'
);
select is(
(select count(*)::int from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d001'),
0,
'frank''s now-redundant collaborator row on his own new diagram was dropped, not duplicated'
);
select is(
(select count(*)::int from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d002' and user_id = '55555555-5555-5555-5555-555555555555'),
0,
'eve''s collaborator row on bob''s diagram is gone'
);
select is(
(select permission from public.diagram_collaborators where diagram_id = 'd0000000-0000-0000-0000-00000000d002' and user_id = '66666666-6666-6666-6666-666666666666'),
'view',
'frank''s own pre-existing collaborator row on bob''s diagram is untouched, not overwritten by eve''s'
);
select is(
(select owner_id from public.device_categories where id = 'c0000000-0000-0000-0000-00000000e001'),
'66666666-6666-6666-6666-666666666666'::uuid,
'eve''s private category now belongs to frank'
);
select is(
(select submitter_id from public.catalog_submissions where entity_id = 'c0000000-0000-0000-0000-00000000e001'),
'66666666-6666-6666-6666-666666666666'::uuid,
'eve''s submission now belongs to frank'
);
select is(
(select migrated_to_user_id from public.profiles where id = '55555555-5555-5555-5555-555555555555'),
'66666666-6666-6666-6666-666666666666'::uuid,
'eve''s profile records where her account was migrated to'
);
select is(
(select count(*)::int from public.account_migrations where from_user_id = '55555555-5555-5555-5555-555555555555'),
1,
'the migration was logged exactly once'
);
select is(
(select diagram_count from public.account_migrations where from_user_id = '55555555-5555-5555-5555-555555555555'),
1,
'the logged row records the same diagram count the preview and migration returned'
);
select throws_ok(
$$ select * from public.migrate_account_ownership('55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'again') $$,
'22023'::char(5), null,
'an already-migrated account cannot be migrated a second time'
);
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
select is(
(select count(*)::int from public.account_migrations),
0,
'bob (regular user) cannot see the migration audit log'
);
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
select is(
(select migrated_to_username from public.list_users_for_admin() where username = 'eve'),
'frank',
'the user listing reflects who eve''s account was migrated to'
);
select * from finish();
rollback;