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
+52 -47
View File
@@ -6,6 +6,9 @@ import { useAuthStore } from '../../state/authStore'
import { useDiagramStore } from '../../state/diagramStore'
import { useSubmissionStore } from '../../state/submissionStore'
import AdminUsersModal from '../admin/AdminUsersModal'
import ProfileModal from '../account/ProfileModal'
import AnnouncementComposerModal from '../announcements/AnnouncementComposerModal'
import DropdownMenu, { DropdownMenuItem } from '../common/DropdownMenu'
import DiagramSharingModal from '../sharing/DiagramSharingModal'
import VersionHistoryModal from '../sharing/VersionHistoryModal'
import AdminReviewModal from '../submissions/AdminReviewModal'
@@ -43,6 +46,8 @@ export default function TopBar() {
const role = useAuthStore((s) => s.role)
const isAdmin = role === 'admin' || role === 'super_admin'
const isSuperAdmin = role === 'super_admin'
const canPostAnnouncements = useAuthStore((s) => s.canPostAnnouncements)
const canManageAnnouncements = isSuperAdmin || (role === 'admin' && canPostAnnouncements)
const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
const fileInputRef = useRef<HTMLInputElement>(null)
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
@@ -51,6 +56,8 @@ export default function TopBar() {
const [submissionsOpen, setSubmissionsOpen] = useState(false)
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
const [announcementComposerOpen, setAnnouncementComposerOpen] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const pendingSubmissionCount = useMemo(
() => mySubmissions.filter((s) => s.status === 'pending').length,
@@ -86,7 +93,7 @@ export default function TopBar() {
return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-indigo-700">AV Planner</span>
<span className="text-sm font-semibold text-indigo-700">Diagrav</span>
<input
value={diagram.name}
onChange={(e) => renameDiagram(e.target.value)}
@@ -101,18 +108,14 @@ export default function TopBar() {
>
Diagrams
</button>
<button
onClick={() => setVersionHistoryOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
History
</button>
<button
onClick={() => setSharingOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Share
</button>
<DropdownMenu label="Diagram">
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportClick}>Import</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>Export</DropdownMenuItem>
</DropdownMenu>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
My Submissions
{unseenOutcomeCount > 0 ? (
@@ -130,44 +133,44 @@ export default function TopBar() {
)
)}
</button>
{isAdmin && (
<button
onClick={() => setAdminReviewOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
<DropdownMenu
align="right"
label={
<>
Admin
{adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)}
</>
}
>
Review Queue
{adminPendingCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
<DropdownMenuItem
onClick={() => setAdminReviewOpen(true)}
badge={
adminPendingCount > 0 && (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
{adminPendingCount}
</span>
)
}
>
Review Queue
</DropdownMenuItem>
{isSuperAdmin && <DropdownMenuItem onClick={() => setAdminUsersOpen(true)}>Manage Users</DropdownMenuItem>}
{canManageAnnouncements && (
<DropdownMenuItem onClick={() => setAnnouncementComposerOpen(true)}>Announcement</DropdownMenuItem>
)}
</button>
</DropdownMenu>
)}
{isSuperAdmin && (
<button
onClick={() => setAdminUsersOpen(true)}
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Manage Users
</button>
)}
<button onClick={handleImportClick} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
Import
</button>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
<button
onClick={handleExport}
className="rounded bg-indigo-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Export
</button>
{username && <span className="ml-1 text-xs text-slate-400">{username}</span>}
<button
onClick={() => supabase.auth.signOut()}
className="rounded px-2.5 py-1.5 text-xs text-slate-500 hover:bg-slate-100"
>
Sign out
</button>
<DropdownMenu label={username ?? 'Account'} align="right">
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
<DropdownMenuItem onClick={() => supabase.auth.signOut()}>Sign out</DropdownMenuItem>
</DropdownMenu>
</div>
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
@@ -175,6 +178,8 @@ export default function TopBar() {
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
{announcementComposerOpen && <AnnouncementComposerModal onClose={() => setAnnouncementComposerOpen(false)} />}
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
</header>
)
}