From 6cdfde822de85b1f14b6b87b14155b109ea10085 Mon Sep 17 00:00:00 2001 From: aarbit Date: Mon, 28 Sep 2026 10:05:11 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x --- src/components/account/ProfileModal.tsx | 352 ++++++++++++++++ .../admin/AccountMigrationModal.tsx | 160 ++++++++ src/components/admin/AdminUsersModal.tsx | 81 +++- .../announcements/AnnouncementBanner.tsx | 24 ++ .../AnnouncementComposerModal.tsx | 79 ++++ src/components/layout/AppShell.tsx | 7 +- src/components/layout/TopBar.tsx | 99 ++--- src/data/AdminUserRepository.ts | 48 +++ src/data/AnnouncementRepository.ts | 30 ++ src/data/SupabaseAdminUserRepository.ts | 103 ++++- src/data/SupabaseAnnouncementRepository.ts | 74 ++++ src/state/adminUserStore.ts | 43 +- src/state/announcementStore.ts | 47 +++ src/state/authStore.ts | 12 +- supabase/config.toml | 5 +- .../20260916000000_announcements.sql | 135 +++++++ .../20260920000000_account_migration.sql | 234 +++++++++++ supabase/tests/rls.sql | 380 +++++++++++++++++- 18 files changed, 1857 insertions(+), 56 deletions(-) create mode 100644 src/components/account/ProfileModal.tsx create mode 100644 src/components/admin/AccountMigrationModal.tsx create mode 100644 src/components/announcements/AnnouncementBanner.tsx create mode 100644 src/components/announcements/AnnouncementComposerModal.tsx create mode 100644 src/data/AnnouncementRepository.ts create mode 100644 src/data/SupabaseAnnouncementRepository.ts create mode 100644 src/state/announcementStore.ts create mode 100644 supabase/migrations/20260916000000_announcements.sql create mode 100644 supabase/migrations/20260920000000_account_migration.sql diff --git a/src/components/account/ProfileModal.tsx b/src/components/account/ProfileModal.tsx new file mode 100644 index 0000000..a310a4f --- /dev/null +++ b/src/components/account/ProfileModal.tsx @@ -0,0 +1,352 @@ +import type { User, UserIdentity } from '@supabase/supabase-js' +import { useEffect, useState } from 'react' +import { supabase } from '../../data/supabaseClient' +import { useAuthStore } from '../../state/authStore' +import Modal from '../common/Modal' + +/** Username changes don't need the current-password check below — unlike + * email/password, a wrong username doesn't lock anyone out of anything. */ +function UsernameSection({ currentUsername, userId }: { currentUsername: string; userId: string }) { + const loadAuth = useAuthStore((s) => s.load) + const [username, setUsername] = useState(currentUsername) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const trimmed = username.trim() + const canSave = trimmed.length > 0 && trimmed !== currentUsername + + const handleSave = async () => { + if (!canSave) return + setError(null) + setSaved(false) + setLoading(true) + try { + const { error } = await supabase.from('profiles').update({ username: trimmed }).eq('id', userId) + if (error) throw error.code === '23505' ? new Error('That username is already taken.') : error + await loadAuth() + setSaved(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Username

+
+ { + setUsername(e.target.value) + setSaved(false) + }} + className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm" + /> + +
+ {error &&

{error}

} + {saved && !error &&

Username updated.

} +
+ ) +} + +/** Email and password changes both require the current password up front + * (Supabase validates it server-side via `current_password`) — extra + * defense-in-depth beyond Supabase's own double-confirmation email flow, + * so a hijacked-but-unlocked session can't quietly lock the real owner + * out. Only rendered when the account actually has a password identity — + * see ProfileModal's hasPasswordIdentity check. */ +function EmailSection({ currentEmail }: { currentEmail: string }) { + const [newEmail, setNewEmail] = useState('') + const [currentPassword, setCurrentPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + const canSave = newEmail.trim().length > 0 && newEmail.trim() !== currentEmail && currentPassword.length > 0 + + const handleSave = async () => { + if (!canSave) return + setError(null) + setPending(false) + setLoading(true) + try { + const { error } = await supabase.auth.updateUser({ + email: newEmail.trim(), + current_password: currentPassword, + }) + if (error) throw error + setNewEmail('') + setCurrentPassword('') + setPending(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Email

+

Current: {currentEmail}

+ { + setNewEmail(e.target.value) + setPending(false) + }} + placeholder="New email" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + setCurrentPassword(e.target.value)} + placeholder="Current password" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + + {error &&

{error}

} + {pending && !error && ( +

+ Check both your old and new inbox — the change won't take effect until you confirm both. +

+ )} +
+ ) +} + +function PasswordSection() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const canSave = currentPassword.length > 0 && newPassword.length >= 6 + + const handleSave = async () => { + if (!canSave) return + setError(null) + setSaved(false) + setLoading(true) + try { + const { error } = await supabase.auth.updateUser({ password: newPassword, current_password: currentPassword }) + if (error) throw error + setCurrentPassword('') + setNewPassword('') + setSaved(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Password

+ setCurrentPassword(e.target.value)} + placeholder="Current password" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + { + setNewPassword(e.target.value) + setSaved(false) + }} + placeholder="New password (min. 6 characters)" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + + {error &&

{error}

} + {saved && !error &&

Password updated.

} +
+ ) +} + +/** + * organized-ideas.md §2's self-service Google-account migration: link a new + * Google identity while still signed in with the old one, confirm it + * worked, then unlink the old one — same account/UUID throughout, so every + * owned diagram and private catalog entry carries over with zero data + * migration. (The other tier from that plan — a Super Admin tool for + * someone who's already lost access to their old Google account — is a + * separate, bigger feature: reassigning/merging accounts you don't own, + * not something this self-service section can help with.) + * + * Requires `enable_manual_linking = true` in supabase/config.toml — off by + * default since Supabase treats letting a user attach an arbitrary second + * identity as a deliberate opt-in, not something to enable silently. + */ +function GoogleIdentitiesSection() { + const [identities, setIdentities] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadIdentities = async () => { + const { data, error } = await supabase.auth.getUserIdentities() + if (error) { + setError(error.message) + return + } + setIdentities(data.identities) + } + + useEffect(() => { + supabase.auth.getUserIdentities().then(({ data, error }) => { + if (error) setError(error.message) + else setIdentities(data.identities) + }) + }, []) + + const googleIdentities = identities?.filter((i) => i.provider === 'google') ?? [] + // unlinkIdentity refuses to leave an account with zero identities — this + // mirrors that so the button reads as disabled rather than failing. + const canUnlink = (identities?.length ?? 0) > 1 + + const handleLink = async () => { + setError(null) + setLoading(true) + // Redirects the whole page to Google's consent screen on success — if + // we're still here, it failed before ever leaving. + const { error } = await supabase.auth.linkIdentity({ + provider: 'google', + options: { redirectTo: window.location.origin }, + }) + if (error) { + setError(error.message) + setLoading(false) + } + } + + const handleUnlink = async (identity: UserIdentity) => { + setError(null) + setLoading(true) + try { + const { error } = await supabase.auth.unlinkIdentity(identity) + if (error) throw error + await loadIdentities() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Google accounts

+

+ Moving to a new Google account? Link it here, sign out and confirm you can sign back in with it, then unlink + the old one — this stays the same account throughout, so nothing you own needs to move. +

+ {googleIdentities.length === 0 ? ( +

No Google account linked.

+ ) : ( +
    + {googleIdentities.map((identity) => ( +
  • + + {(identity.identity_data as { email?: string } | undefined)?.email ?? identity.id} + + +
  • + ))} +
+ )} + + {error &&

{error}

} +
+ ) +} + +/** Reachable from TopBar's Account menu — a home for anything about your + * own account, not any one diagram. Username always shown; email/password + * only for an account that actually has a password identity (a Google- + * only sign-in has neither — their email is Google's, and there's no + * password to change or use as the current-password check below). */ +export default function ProfileModal({ onClose }: { onClose: () => void }) { + const username = useAuthStore((s) => s.username) + const userId = useAuthStore((s) => s.userId) + const [user, setUser] = useState(undefined) + + useEffect(() => { + supabase.auth.getUser().then(({ data }) => setUser(data.user)) + }, []) + + const hasPasswordIdentity = !!user?.identities?.some((i) => i.provider === 'email') + + return ( + +
+ {username && userId && } + + {user === undefined ? ( +

Loading…

+ ) : hasPasswordIdentity ? ( + <> + + + + ) : ( +

+ You sign in with Google — your email and password are managed by your Google account. +

+ )} + + +
+ +
+ +
+
+ ) +} diff --git a/src/components/admin/AccountMigrationModal.tsx b/src/components/admin/AccountMigrationModal.tsx new file mode 100644 index 0000000..2d5cfd5 --- /dev/null +++ b/src/components/admin/AccountMigrationModal.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import type { AccountMigrationImpact, AdminUserSummary } from '../../data/AdminUserRepository' +import { useAdminUserStore } from '../../state/adminUserStore' +import Modal from '../common/Modal' + +/** + * organized-ideas.md §2's Super-Admin tier for someone who's lost access to + * their old account entirely (the self-service link/unlink path in + * ProfileModal only covers "I still have access to the old account too"). + * Deliberately narrow: moves ownership of diagrams/private catalog + * entries/submissions, never touches credentials, never deletes the old + * account — see migrate_account_ownership()'s own migration comment for + * the full reasoning. + */ +export default function AccountMigrationModal({ + fromUser, + users, + onClose, +}: { + fromUser: AdminUserSummary + users: AdminUserSummary[] + onClose: () => void +}) { + const previewMigration = useAdminUserStore((s) => s.previewMigration) + const migrateAccount = useAdminUserStore((s) => s.migrateAccount) + // Already-migrated accounts are empty husks — not sensible migration + // targets themselves. + const candidates = users.filter((u) => u.id !== fromUser.id && !u.migratedToUserId) + const [toUserId, setToUserId] = useState(candidates[0]?.id ?? '') + const [notes, setNotes] = useState('') + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + + const toUser = users.find((u) => u.id === toUserId) + + const handlePreview = async () => { + if (!toUserId) return + setError(null) + setPreviewLoading(true) + try { + setPreview(await previewMigration(fromUser.id, toUserId)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setPreviewLoading(false) + } + } + + const handleMigrate = async () => { + if (!toUserId || !notes.trim() || !preview) return + setError(null) + setBusy(true) + try { + await migrateAccount(fromUser.id, toUserId, notes.trim()) + setDone(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setBusy(false) + } + } + + return ( + + {done ? ( +
+

+ Moved "{fromUser.username}"'s data to "{toUser?.username}". {fromUser.username}'s account still exists, + just empty now — nothing was deleted. +

+
+ +
+
+ ) : candidates.length === 0 ? ( +

No other account to migrate this into.

+ ) : ( +
+

+ Moves every diagram, private catalog entry, and submission owned by{' '} + {fromUser.username} to another account. Doesn't touch{' '} + {fromUser.username}'s login (email, password, Google identity) and doesn't delete the account — only use + this once you've verified, independently of anything in this app, that the person asking really is{' '} + {fromUser.username}. +

+ + + +