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:
@@ -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<string | null>(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 (
|
||||||
|
<section className="space-y-1.5">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Username</h3>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !canSave}
|
||||||
|
className="shrink-0 rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
||||||
|
{saved && !error && <p className="text-[11px] text-emerald-600">Username updated.</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string | null>(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 (
|
||||||
|
<section className="space-y-1.5 border-t border-slate-100 pt-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Email</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">Current: {currentEmail}</p>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewEmail(e.target.value)
|
||||||
|
setPending(false)
|
||||||
|
}}
|
||||||
|
placeholder="New email"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Current password"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !canSave}
|
||||||
|
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving…' : 'Change email'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
||||||
|
{pending && !error && (
|
||||||
|
<p className="text-[11px] text-emerald-600">
|
||||||
|
Check both your old and new inbox — the change won't take effect until you confirm both.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordSection() {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<section className="space-y-1.5 border-t border-slate-100 pt-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Password</h3>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Current password"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !canSave}
|
||||||
|
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving…' : 'Change password'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
||||||
|
{saved && !error && <p className="text-[11px] text-emerald-600">Password updated.</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<UserIdentity[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<section className="space-y-1.5 border-t border-slate-100 pt-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Google accounts</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{googleIdentities.length === 0 ? (
|
||||||
|
<p className="text-[11px] italic text-slate-400">No Google account linked.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{googleIdentities.map((identity) => (
|
||||||
|
<li
|
||||||
|
key={identity.identity_id}
|
||||||
|
className="flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="truncate text-slate-700">
|
||||||
|
{(identity.identity_data as { email?: string } | undefined)?.email ?? identity.id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlink(identity)}
|
||||||
|
disabled={loading || !canUnlink}
|
||||||
|
title={!canUnlink ? "Can't unlink your only sign-in method" : 'Unlink this Google account'}
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLink}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ Link a Google account
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<User | null | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getUser().then(({ data }) => setUser(data.user))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasPasswordIdentity = !!user?.identities?.some((i) => i.provider === 'email')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Profile" onClose={onClose} width="max-w-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{username && userId && <UsernameSection currentUsername={username} userId={userId} />}
|
||||||
|
|
||||||
|
{user === undefined ? (
|
||||||
|
<p className="border-t border-slate-100 pt-3 text-xs italic text-slate-400">Loading…</p>
|
||||||
|
) : hasPasswordIdentity ? (
|
||||||
|
<>
|
||||||
|
<EmailSection currentEmail={user?.email ?? ''} />
|
||||||
|
<PasswordSection />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="border-t border-slate-100 pt-3 text-[11px] text-slate-500">
|
||||||
|
You sign in with Google — your email and password are managed by your Google account.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GoogleIdentitiesSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<AccountMigrationImpact | null>(null)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Modal title="Migrate Account" onClose={onClose} width="max-w-lg">
|
||||||
|
{done ? (
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<p className="rounded bg-emerald-50 px-3 py-2 text-emerald-700">
|
||||||
|
Moved "{fromUser.username}"'s data to "{toUser?.username}". {fromUser.username}'s account still exists,
|
||||||
|
just empty now — nothing was deleted.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="text-xs italic text-slate-400">No other account to migrate this into.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Moves every diagram, private catalog entry, and submission owned by{' '}
|
||||||
|
<span className="font-medium text-slate-700">{fromUser.username}</span> 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}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-slate-600">
|
||||||
|
Migrate to
|
||||||
|
<select
|
||||||
|
value={toUserId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setToUserId(e.target.value)
|
||||||
|
setPreview(null)
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{candidates.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.username} ({u.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-slate-600">
|
||||||
|
How was this verified?
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Confirmed over a video call; they described diagrams only the real owner would know about."
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!toUserId || previewLoading}
|
||||||
|
className="rounded bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Checking…' : 'Preview what will move'}
|
||||||
|
</button>
|
||||||
|
{preview && (
|
||||||
|
<span className="text-[11px] text-slate-500">
|
||||||
|
{preview.diagramCount} diagram{preview.diagramCount === 1 ? '' : 's'}, {preview.privateEntityCount}{' '}
|
||||||
|
private catalog {preview.privateEntityCount === 1 ? 'entry' : 'entries'}, {preview.submissionCount}{' '}
|
||||||
|
submission{preview.submissionCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="rounded bg-red-50 px-2 py-1.5 text-red-700">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMigrate}
|
||||||
|
disabled={busy || !toUserId || !notes.trim() || !preview}
|
||||||
|
title={!preview ? 'Preview what will move first' : undefined}
|
||||||
|
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy ? 'Migrating…' : 'Migrate account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
|
|||||||
import type { AdminUserSummary, UserRole } from '../../data/AdminUserRepository'
|
import type { AdminUserSummary, UserRole } from '../../data/AdminUserRepository'
|
||||||
import { useAdminUserStore } from '../../state/adminUserStore'
|
import { useAdminUserStore } from '../../state/adminUserStore'
|
||||||
import { useAuthStore } from '../../state/authStore'
|
import { useAuthStore } from '../../state/authStore'
|
||||||
|
import Chevron from '../common/Chevron'
|
||||||
import Modal from '../common/Modal'
|
import Modal from '../common/Modal'
|
||||||
|
import AccountMigrationModal from './AccountMigrationModal'
|
||||||
|
|
||||||
const ROLE_OPTIONS: UserRole[] = ['regular', 'admin', 'super_admin']
|
const ROLE_OPTIONS: UserRole[] = ['regular', 'admin', 'super_admin']
|
||||||
|
|
||||||
@@ -19,15 +21,21 @@ export default function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
|||||||
const users = useAdminUserStore((s) => s.users)
|
const users = useAdminUserStore((s) => s.users)
|
||||||
const loadUsers = useAdminUserStore((s) => s.loadUsers)
|
const loadUsers = useAdminUserStore((s) => s.loadUsers)
|
||||||
const updateRole = useAdminUserStore((s) => s.updateRole)
|
const updateRole = useAdminUserStore((s) => s.updateRole)
|
||||||
|
const updateCanPostAnnouncements = useAdminUserStore((s) => s.updateCanPostAnnouncements)
|
||||||
const banUser = useAdminUserStore((s) => s.banUser)
|
const banUser = useAdminUserStore((s) => s.banUser)
|
||||||
const unbanUser = useAdminUserStore((s) => s.unbanUser)
|
const unbanUser = useAdminUserStore((s) => s.unbanUser)
|
||||||
const deleteUser = useAdminUserStore((s) => s.deleteUser)
|
const deleteUser = useAdminUserStore((s) => s.deleteUser)
|
||||||
|
const migrations = useAdminUserStore((s) => s.migrations)
|
||||||
|
const loadMigrations = useAdminUserStore((s) => s.loadMigrations)
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [migrationTarget, setMigrationTarget] = useState<AdminUserSummary | null>(null)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}, [loadUsers])
|
loadMigrations()
|
||||||
|
}, [loadUsers, loadMigrations])
|
||||||
|
|
||||||
const runAction = async (userId: string, action: () => Promise<void>) => {
|
const runAction = async (userId: string, action: () => Promise<void>) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -87,6 +95,14 @@ export default function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
|||||||
{banned && (
|
{banned && (
|
||||||
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">Banned</span>
|
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">Banned</span>
|
||||||
)}
|
)}
|
||||||
|
{user.migratedToUsername && (
|
||||||
|
<span
|
||||||
|
title="This account's data has been moved elsewhere — see migration history below"
|
||||||
|
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600"
|
||||||
|
>
|
||||||
|
Migrated → {user.migratedToUsername}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[10px] text-slate-400">
|
<div className="truncate text-[10px] text-slate-400">
|
||||||
{user.email} · joined {formatDate(user.createdAt)}
|
{user.email} · joined {formatDate(user.createdAt)}
|
||||||
@@ -105,6 +121,34 @@ export default function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<label
|
||||||
|
title="Lets this specific Admin post/retire site-wide announcements"
|
||||||
|
className="flex items-center gap-1 text-[11px] text-slate-500"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={user.canPostAnnouncements}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => runAction(user.id, () => updateCanPostAnnouncements(user.id, e.target.checked))}
|
||||||
|
/>
|
||||||
|
Announcements
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setMigrationTarget(user)}
|
||||||
|
disabled={busy || self || !!user.migratedToUsername}
|
||||||
|
title={
|
||||||
|
self
|
||||||
|
? "You can't migrate your own account"
|
||||||
|
: user.migratedToUsername
|
||||||
|
? 'Already migrated'
|
||||||
|
: 'Move this account\'s diagrams and private catalog entries to another account'
|
||||||
|
}
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Migrate
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBanToggle(user)}
|
onClick={() => handleBanToggle(user)}
|
||||||
disabled={busy || self}
|
disabled={busy || self}
|
||||||
@@ -127,11 +171,46 @@ export default function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
|||||||
})}
|
})}
|
||||||
{users.length === 0 && <p className="py-2 text-center text-xs italic text-slate-400">No users found.</p>}
|
{users.length === 0 && <p className="py-2 text-center text-xs italic text-slate-400">No users found.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 border-t border-slate-100 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setHistoryOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-[11px] text-slate-500 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<span>Migration history ({migrations.length})</span>
|
||||||
|
<Chevron expanded={historyOpen} className="text-slate-500" />
|
||||||
|
</button>
|
||||||
|
{historyOpen && (
|
||||||
|
<ul className="mt-1 max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{migrations.length === 0 && <p className="px-1.5 py-1 text-[11px] italic text-slate-400">No migrations yet.</p>}
|
||||||
|
{migrations.map((m) => (
|
||||||
|
<li key={m.id} className="rounded border border-slate-200 bg-white px-2 py-1.5 text-[11px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-slate-700">
|
||||||
|
{m.fromUsername} → {m.toUsername}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-slate-400">{formatDate(m.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500">
|
||||||
|
{m.diagramCount} diagram{m.diagramCount === 1 ? '' : 's'}, {m.privateEntityCount} private catalog{' '}
|
||||||
|
{m.privateEntityCount === 1 ? 'entry' : 'entries'}, {m.submissionCount} submission
|
||||||
|
{m.submissionCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 italic text-slate-400">"{m.verificationNotes}"</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
||||||
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{migrationTarget && (
|
||||||
|
<AccountMigrationModal fromUser={migrationTarget} users={users} onClose={() => setMigrationTarget(null)} />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useAnnouncementStore } from '../../state/announcementStore'
|
||||||
|
|
||||||
|
/** Site-wide dismissible banner (organized-ideas.md §3) — rendered above
|
||||||
|
* everything else in AppShell so it's visible regardless of what's
|
||||||
|
* selected below, for every signed-in user until they dismiss it. */
|
||||||
|
export default function AnnouncementBanner() {
|
||||||
|
const current = useAnnouncementStore((s) => s.current)
|
||||||
|
const dismiss = useAnnouncementStore((s) => s.dismiss)
|
||||||
|
|
||||||
|
if (!current || current.dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900">
|
||||||
|
<p className="min-w-0">{current.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
title="Dismiss"
|
||||||
|
className="shrink-0 rounded p-1 text-amber-500 hover:bg-amber-100 hover:text-amber-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAnnouncementStore } from '../../state/announcementStore'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
/** Reachable only by whoever can_manage_announcements() allows (every Super
|
||||||
|
* Admin, or an Admin individually flagged — see AdminUsersModal). Shows
|
||||||
|
* what's currently live regardless of whether *this* viewer has dismissed
|
||||||
|
* it themselves — that flag is personal, not a proxy for "is it still
|
||||||
|
* showing to everyone else." */
|
||||||
|
export default function AnnouncementComposerModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const current = useAnnouncementStore((s) => s.current)
|
||||||
|
const post = useAnnouncementStore((s) => s.post)
|
||||||
|
const retireCurrent = useAnnouncementStore((s) => s.retireCurrent)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const runAction = async (action: () => Promise<void>) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePost = () => {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
runAction(async () => {
|
||||||
|
await post(trimmed)
|
||||||
|
setMessage('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Site Announcement" onClose={onClose} width="max-w-lg">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{current ? (
|
||||||
|
<div className="rounded border border-amber-200 bg-amber-50 p-2.5 text-sm text-amber-900">
|
||||||
|
<p className="mb-2">{current.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => runAction(retireCurrent)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded bg-white px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Retire now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-slate-400">Nothing is currently showing.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-slate-600">
|
||||||
|
{current ? 'Post a new announcement (replaces the one above)' : 'Post an announcement'}
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="e.g. The XLR-to-TRS adapter cable's compatibility changed on 2026-09-16 — check any diagrams using it."
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePost}
|
||||||
|
disabled={busy || !message.trim()}
|
||||||
|
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
import { useAdminReviewStore } from '../../state/adminReviewStore'
|
||||||
|
import { useAnnouncementStore } from '../../state/announcementStore'
|
||||||
import { useAuthStore } from '../../state/authStore'
|
import { useAuthStore } from '../../state/authStore'
|
||||||
import { useCatalogStore } from '../../state/catalogStore'
|
import { useCatalogStore } from '../../state/catalogStore'
|
||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
|
import AnnouncementBanner from '../announcements/AnnouncementBanner'
|
||||||
import FlowCanvas from '../canvas/FlowCanvas'
|
import FlowCanvas from '../canvas/FlowCanvas'
|
||||||
import DevicePalette from '../palette/DevicePalette'
|
import DevicePalette from '../palette/DevicePalette'
|
||||||
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
||||||
@@ -20,13 +22,15 @@ export default function AppShell() {
|
|||||||
const loadAuth = useAuthStore((s) => s.load)
|
const loadAuth = useAuthStore((s) => s.load)
|
||||||
const role = useAuthStore((s) => s.role)
|
const role = useAuthStore((s) => s.role)
|
||||||
const loadAdminQueue = useAdminReviewStore((s) => s.loadAll)
|
const loadAdminQueue = useAdminReviewStore((s) => s.loadAll)
|
||||||
|
const loadAnnouncement = useAnnouncementStore((s) => s.load)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialDiagram()
|
loadInitialDiagram()
|
||||||
loadCatalog()
|
loadCatalog()
|
||||||
loadMySubmissions()
|
loadMySubmissions()
|
||||||
loadAuth()
|
loadAuth()
|
||||||
}, [loadInitialDiagram, loadCatalog, loadMySubmissions, loadAuth])
|
loadAnnouncement()
|
||||||
|
}, [loadInitialDiagram, loadCatalog, loadMySubmissions, loadAuth, loadAnnouncement])
|
||||||
|
|
||||||
// Only Admins/Super-Admins need the review queue at all — and role isn't
|
// Only Admins/Super-Admins need the review queue at all — and role isn't
|
||||||
// known until loadAuth() above resolves, so this is a separate effect
|
// known until loadAuth() above resolves, so this is a separate effect
|
||||||
@@ -41,6 +45,7 @@ export default function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
<AnnouncementBanner />
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<DevicePalette />
|
<DevicePalette />
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { useAuthStore } from '../../state/authStore'
|
|||||||
import { useDiagramStore } from '../../state/diagramStore'
|
import { useDiagramStore } from '../../state/diagramStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import AdminUsersModal from '../admin/AdminUsersModal'
|
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 DiagramSharingModal from '../sharing/DiagramSharingModal'
|
||||||
import VersionHistoryModal from '../sharing/VersionHistoryModal'
|
import VersionHistoryModal from '../sharing/VersionHistoryModal'
|
||||||
import AdminReviewModal from '../submissions/AdminReviewModal'
|
import AdminReviewModal from '../submissions/AdminReviewModal'
|
||||||
@@ -43,6 +46,8 @@ export default function TopBar() {
|
|||||||
const role = useAuthStore((s) => s.role)
|
const role = useAuthStore((s) => s.role)
|
||||||
const isAdmin = role === 'admin' || role === 'super_admin'
|
const isAdmin = role === 'admin' || role === 'super_admin'
|
||||||
const isSuperAdmin = 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 adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
||||||
@@ -51,6 +56,8 @@ export default function TopBar() {
|
|||||||
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
||||||
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
|
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
|
||||||
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
|
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
|
||||||
|
const [announcementComposerOpen, setAnnouncementComposerOpen] = useState(false)
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false)
|
||||||
|
|
||||||
const pendingSubmissionCount = useMemo(
|
const pendingSubmissionCount = useMemo(
|
||||||
() => mySubmissions.filter((s) => s.status === 'pending').length,
|
() => mySubmissions.filter((s) => s.status === 'pending').length,
|
||||||
@@ -86,7 +93,7 @@ export default function TopBar() {
|
|||||||
return (
|
return (
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3">
|
<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">
|
<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
|
<input
|
||||||
value={diagram.name}
|
value={diagram.name}
|
||||||
onChange={(e) => renameDiagram(e.target.value)}
|
onChange={(e) => renameDiagram(e.target.value)}
|
||||||
@@ -101,18 +108,14 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
Diagrams
|
Diagrams
|
||||||
</button>
|
</button>
|
||||||
<button
|
<DropdownMenu label="Diagram">
|
||||||
onClick={() => setVersionHistoryOpen(true)}
|
<DropdownMenuItem onClick={() => setVersionHistoryOpen(true)}>History</DropdownMenuItem>
|
||||||
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
<DropdownMenuItem onClick={() => setSharingOpen(true)}>Share</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuItem onClick={handleImportClick}>Import</DropdownMenuItem>
|
||||||
History
|
<DropdownMenuItem onClick={handleExport}>Export</DropdownMenuItem>
|
||||||
</button>
|
</DropdownMenu>
|
||||||
<button
|
<input ref={fileInputRef} type="file" accept="application/json,.json" className="hidden" onChange={handleFileChange} />
|
||||||
onClick={() => setSharingOpen(true)}
|
|
||||||
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
My Submissions
|
My Submissions
|
||||||
{unseenOutcomeCount > 0 ? (
|
{unseenOutcomeCount > 0 ? (
|
||||||
@@ -130,44 +133,44 @@ export default function TopBar() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<DropdownMenu
|
||||||
onClick={() => setAdminReviewOpen(true)}
|
align="right"
|
||||||
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
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
|
<DropdownMenuItem
|
||||||
{adminPendingCount > 0 && (
|
onClick={() => setAdminReviewOpen(true)}
|
||||||
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700">
|
badge={
|
||||||
{adminPendingCount}
|
adminPendingCount > 0 && (
|
||||||
</span>
|
<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
|
<DropdownMenu label={username ?? 'Account'} align="right">
|
||||||
onClick={() => setAdminUsersOpen(true)}
|
<DropdownMenuItem onClick={() => setProfileOpen(true)}>Profile</DropdownMenuItem>
|
||||||
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
<DropdownMenuItem onClick={() => supabase.auth.signOut()}>Sign out</DropdownMenuItem>
|
||||||
>
|
</DropdownMenu>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
||||||
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
|
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
|
||||||
@@ -175,6 +178,8 @@ export default function TopBar() {
|
|||||||
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
||||||
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
|
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
|
||||||
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
|
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
|
||||||
|
{announcementComposerOpen && <AnnouncementComposerModal onClose={() => setAnnouncementComposerOpen(false)} />}
|
||||||
|
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,40 @@ export interface AdminUserSummary {
|
|||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
/** Lets this specific Admin post/retire site-wide announcements — a
|
||||||
|
* narrower grant than the role itself (a Super Admin can always post
|
||||||
|
* regardless of this). Meaningless for a regular/super_admin row, but
|
||||||
|
* present either way since it mirrors the underlying profiles column. */
|
||||||
|
canPostAnnouncements: boolean
|
||||||
/** Set (a future timestamp) while banned; undefined otherwise. */
|
/** Set (a future timestamp) while banned; undefined otherwise. */
|
||||||
bannedUntil?: string
|
bannedUntil?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** Set once this account's data has been moved to another account via
|
||||||
|
* migrateAccount below (organized-ideas.md §2) — never cleared, and
|
||||||
|
* never set back to undefined by anything in this app. */
|
||||||
|
migratedToUserId?: string
|
||||||
|
migratedToUsername?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountMigrationImpact {
|
||||||
|
diagramCount: number
|
||||||
|
privateEntityCount: number
|
||||||
|
submissionCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of the permanent account-migration audit log. */
|
||||||
|
export interface AccountMigrationRecord extends AccountMigrationImpact {
|
||||||
|
id: string
|
||||||
|
/** Null if that account has since been deleted — fromUsername/toUsername
|
||||||
|
* (snapshotted at migration time) stay populated regardless, so the log
|
||||||
|
* stays legible either way. */
|
||||||
|
fromUserId: string | null
|
||||||
|
toUserId: string | null
|
||||||
|
fromUsername: string
|
||||||
|
toUsername: string
|
||||||
|
performedBy: string | null
|
||||||
|
verificationNotes: string
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Storage abstraction for Super-Admin user management (organized-ideas.md
|
/** Storage abstraction for Super-Admin user management (organized-ideas.md
|
||||||
@@ -23,10 +54,27 @@ export interface AdminUserSummary {
|
|||||||
export interface AdminUserRepository {
|
export interface AdminUserRepository {
|
||||||
listUsers(): Promise<AdminUserSummary[]>
|
listUsers(): Promise<AdminUserSummary[]>
|
||||||
updateRole(userId: string, role: UserRole): Promise<void>
|
updateRole(userId: string, role: UserRole): Promise<void>
|
||||||
|
/** Super-Admin-only in practice (RLS), same as updateRole — grants or
|
||||||
|
* revokes one Admin's ability to post announcements. */
|
||||||
|
updateCanPostAnnouncements(userId: string, canPostAnnouncements: boolean): Promise<void>
|
||||||
/** Reversible — blocks login without touching the account's data. */
|
/** Reversible — blocks login without touching the account's data. */
|
||||||
banUser(userId: string): Promise<void>
|
banUser(userId: string): Promise<void>
|
||||||
unbanUser(userId: string): Promise<void>
|
unbanUser(userId: string): Promise<void>
|
||||||
/** Irreversible — cascades to the user's profile, diagrams, and owned
|
/** Irreversible — cascades to the user's profile, diagrams, and owned
|
||||||
* private catalog entries via their existing foreign keys. */
|
* private catalog entries via their existing foreign keys. */
|
||||||
deleteUser(userId: string): Promise<void>
|
deleteUser(userId: string): Promise<void>
|
||||||
|
|
||||||
|
/** Read-only "what would move" check before committing a migration below —
|
||||||
|
* same idea as the catalog usage-impact check before an unpublish. */
|
||||||
|
previewAccountMigration(fromUserId: string, toUserId: string): Promise<AccountMigrationImpact>
|
||||||
|
/** Moves diagrams, private catalog entries, and submissions from one
|
||||||
|
* account to another (organized-ideas.md §2's tool for someone who's
|
||||||
|
* lost access to their old account) — never touches credentials or
|
||||||
|
* deletes the old account, just reassigns what it owns. Throws (rather
|
||||||
|
* than swallowing, unlike most methods here) so the caller can surface
|
||||||
|
* *why* it failed — most commonly: already migrated, missing
|
||||||
|
* verification notes, or migrating an account into itself. */
|
||||||
|
migrateAccount(fromUserId: string, toUserId: string, verificationNotes: string): Promise<AccountMigrationImpact>
|
||||||
|
/** The permanent audit trail, most recent first. */
|
||||||
|
listAccountMigrations(): Promise<AccountMigrationRecord[]>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** The current site-wide announcement (organized-ideas.md §3), if any, from
|
||||||
|
* the signed-in user's own point of view — `dismissed` reflects only their
|
||||||
|
* own dismissal, since that's tracked per-account, per-announcement. */
|
||||||
|
export interface Announcement {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
createdAt: string
|
||||||
|
dismissed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage abstraction for site-wide announcements. Posting/retiring is
|
||||||
|
* gated server-side by `can_manage_announcements()` (every Super Admin, or
|
||||||
|
* an Admin individually flagged via `profiles.can_post_announcements`) —
|
||||||
|
* this interface doesn't re-check that, same as every other repository
|
||||||
|
* here leaving authorization to RLS.
|
||||||
|
*/
|
||||||
|
export interface AnnouncementRepository {
|
||||||
|
/** The current (unretired) announcement, or null if none. */
|
||||||
|
getCurrent(): Promise<Announcement | null>
|
||||||
|
/** Posts a new announcement. A DB trigger retires whichever one was
|
||||||
|
* previously current, so there's always at most one current at a time. */
|
||||||
|
post(message: string): Promise<void>
|
||||||
|
/** Retires the current announcement early, without posting a replacement. */
|
||||||
|
retireCurrent(): Promise<void>
|
||||||
|
/** Records that the signed-in user has dismissed this announcement —
|
||||||
|
* permanent for that announcement id, but doesn't affect whatever gets
|
||||||
|
* posted next (a new id starts undismissed for everyone again). */
|
||||||
|
dismiss(announcementId: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { FunctionsHttpError } from '@supabase/supabase-js'
|
import { FunctionsHttpError } from '@supabase/supabase-js'
|
||||||
import type { AdminUserRepository, AdminUserSummary, UserRole } from './AdminUserRepository'
|
import type {
|
||||||
|
AccountMigrationImpact,
|
||||||
|
AccountMigrationRecord,
|
||||||
|
AdminUserRepository,
|
||||||
|
AdminUserSummary,
|
||||||
|
UserRole,
|
||||||
|
} from './AdminUserRepository'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
@@ -7,8 +13,11 @@ interface UserRow {
|
|||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
can_post_announcements: boolean
|
||||||
banned_until: string | null
|
banned_until: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
migrated_to_user_id: string | null
|
||||||
|
migrated_to_username: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSummary(row: UserRow): AdminUserSummary {
|
function toSummary(row: UserRow): AdminUserSummary {
|
||||||
@@ -17,8 +26,25 @@ function toSummary(row: UserRow): AdminUserSummary {
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
|
canPostAnnouncements: row.can_post_announcements,
|
||||||
bannedUntil: row.banned_until ?? undefined,
|
bannedUntil: row.banned_until ?? undefined,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
migratedToUserId: row.migrated_to_user_id ?? undefined,
|
||||||
|
migratedToUsername: row.migrated_to_username ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationImpactRow {
|
||||||
|
diagram_count: number
|
||||||
|
private_entity_count: number
|
||||||
|
submission_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toImpact(row: MigrationImpactRow | undefined): AccountMigrationImpact {
|
||||||
|
return {
|
||||||
|
diagramCount: row?.diagram_count ?? 0,
|
||||||
|
privateEntityCount: row?.private_entity_count ?? 0,
|
||||||
|
submissionCount: row?.submission_count ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +67,14 @@ export class SupabaseAdminUserRepository implements AdminUserRepository {
|
|||||||
if (error) console.error('Failed to update user role in Supabase', error)
|
if (error) console.error('Failed to update user role in Supabase', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCanPostAnnouncements(userId: string, canPostAnnouncements: boolean): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({ can_post_announcements: canPostAnnouncements })
|
||||||
|
.eq('id', userId)
|
||||||
|
if (error) console.error('Failed to update can_post_announcements in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
private async invokeUserAction(action: UserAction, userId: string): Promise<void> {
|
private async invokeUserAction(action: UserAction, userId: string): Promise<void> {
|
||||||
const { error } = await supabase.functions.invoke('admin-user-action', { body: { action, userId } })
|
const { error } = await supabase.functions.invoke('admin-user-action', { body: { action, userId } })
|
||||||
if (!error) return
|
if (!error) return
|
||||||
@@ -70,4 +104,71 @@ export class SupabaseAdminUserRepository implements AdminUserRepository {
|
|||||||
deleteUser(userId: string): Promise<void> {
|
deleteUser(userId: string): Promise<void> {
|
||||||
return this.invokeUserAction('delete', userId)
|
return this.invokeUserAction('delete', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async previewAccountMigration(fromUserId: string, toUserId: string): Promise<AccountMigrationImpact> {
|
||||||
|
const { data, error } = await supabase.rpc('account_migration_preview', {
|
||||||
|
p_from_user_id: fromUserId,
|
||||||
|
p_to_user_id: toUserId,
|
||||||
|
})
|
||||||
|
if (error) console.error('Failed to preview account migration in Supabase', error)
|
||||||
|
return toImpact((data as MigrationImpactRow[] | null)?.[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateAccount(fromUserId: string, toUserId: string, verificationNotes: string): Promise<AccountMigrationImpact> {
|
||||||
|
const { data, error } = await supabase.rpc('migrate_account_ownership', {
|
||||||
|
p_from_user_id: fromUserId,
|
||||||
|
p_to_user_id: toUserId,
|
||||||
|
p_verification_notes: verificationNotes,
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to migrate account in Supabase', error)
|
||||||
|
// Postgres raises the specific reason (already migrated, missing
|
||||||
|
// notes, self-migration) as the message — surface that verbatim
|
||||||
|
// rather than a generic fallback, wrapped in a real Error since the
|
||||||
|
// raw Postgrest error object isn't one (`instanceof Error` would
|
||||||
|
// otherwise fail for callers that check it).
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
return toImpact((data as MigrationImpactRow[] | null)?.[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAccountMigrations(): Promise<AccountMigrationRecord[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('account_migrations')
|
||||||
|
.select(
|
||||||
|
'id, from_user_id, to_user_id, from_username, to_username, performed_by, verification_notes, diagram_count, private_entity_count, submission_count, created_at',
|
||||||
|
)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to load account migrations from Supabase', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(data ?? []) as Array<{
|
||||||
|
id: string
|
||||||
|
from_user_id: string | null
|
||||||
|
to_user_id: string | null
|
||||||
|
from_username: string
|
||||||
|
to_username: string
|
||||||
|
performed_by: string | null
|
||||||
|
verification_notes: string
|
||||||
|
diagram_count: number
|
||||||
|
private_entity_count: number
|
||||||
|
submission_count: number
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
fromUserId: row.from_user_id,
|
||||||
|
toUserId: row.to_user_id,
|
||||||
|
fromUsername: row.from_username,
|
||||||
|
toUsername: row.to_username,
|
||||||
|
performedBy: row.performed_by,
|
||||||
|
verificationNotes: row.verification_notes,
|
||||||
|
diagramCount: row.diagram_count,
|
||||||
|
privateEntityCount: row.private_entity_count,
|
||||||
|
submissionCount: row.submission_count,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Announcement, AnnouncementRepository } from './AnnouncementRepository'
|
||||||
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
|
interface AnnouncementRow {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backs the app with the `announcements`/`announcement_dismissals` tables
|
||||||
|
* directly — no privileged function needed, since every read/write here is
|
||||||
|
* either publicly visible (the current announcement) or already scoped to
|
||||||
|
* your own rows (your dismissals) by RLS. */
|
||||||
|
export class SupabaseAnnouncementRepository implements AnnouncementRepository {
|
||||||
|
private async currentUserId(): Promise<string | null> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
return user?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrent(): Promise<Announcement | null> {
|
||||||
|
const { data: row, error } = await supabase
|
||||||
|
.from('announcements')
|
||||||
|
.select('id, message, created_at')
|
||||||
|
.is('retired_at', null)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to load the current announcement from Supabase', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!row) return null
|
||||||
|
const current = row as AnnouncementRow
|
||||||
|
|
||||||
|
// RLS already scopes announcement_dismissals to your own rows, so
|
||||||
|
// finding any row at all for this announcement id means you dismissed it.
|
||||||
|
const { data: dismissal, error: dismissalError } = await supabase
|
||||||
|
.from('announcement_dismissals')
|
||||||
|
.select('announcement_id')
|
||||||
|
.eq('announcement_id', current.id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (dismissalError) console.error('Failed to check announcement dismissal in Supabase', dismissalError)
|
||||||
|
|
||||||
|
return { id: current.id, message: current.message, createdAt: current.created_at, dismissed: !!dismissal }
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(message: string): Promise<void> {
|
||||||
|
const createdBy = await this.currentUserId()
|
||||||
|
const { error } = await supabase.from('announcements').insert({ message, created_by: createdBy })
|
||||||
|
if (error) console.error('Failed to post announcement to Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async retireCurrent(): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('announcements')
|
||||||
|
.update({ retired_at: new Date().toISOString() })
|
||||||
|
.is('retired_at', null)
|
||||||
|
if (error) console.error('Failed to retire the current announcement in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss(announcementId: string): Promise<void> {
|
||||||
|
const userId = await this.currentUserId()
|
||||||
|
if (!userId) return
|
||||||
|
// Upsert-and-ignore rather than a plain insert — a double-click (or the
|
||||||
|
// banner re-rendering before its own dismissed state lands) would
|
||||||
|
// otherwise hit the (user_id, announcement_id) primary key and error.
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('announcement_dismissals')
|
||||||
|
.upsert({ user_id: userId, announcement_id: announcementId }, { onConflict: 'user_id,announcement_id', ignoreDuplicates: true })
|
||||||
|
if (error) console.error('Failed to record announcement dismissal in Supabase', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AdminUserRepository, AdminUserSummary, UserRole } from '../data/AdminUserRepository'
|
import type {
|
||||||
|
AccountMigrationImpact,
|
||||||
|
AccountMigrationRecord,
|
||||||
|
AdminUserRepository,
|
||||||
|
AdminUserSummary,
|
||||||
|
UserRole,
|
||||||
|
} from '../data/AdminUserRepository'
|
||||||
import { SupabaseAdminUserRepository } from '../data/SupabaseAdminUserRepository'
|
import { SupabaseAdminUserRepository } from '../data/SupabaseAdminUserRepository'
|
||||||
|
|
||||||
const repository: AdminUserRepository = new SupabaseAdminUserRepository()
|
const repository: AdminUserRepository = new SupabaseAdminUserRepository()
|
||||||
@@ -7,17 +13,28 @@ const repository: AdminUserRepository = new SupabaseAdminUserRepository()
|
|||||||
interface AdminUserStoreState {
|
interface AdminUserStoreState {
|
||||||
users: AdminUserSummary[]
|
users: AdminUserSummary[]
|
||||||
isLoaded: boolean
|
isLoaded: boolean
|
||||||
|
/** The account-migration audit log — loaded on demand (opening the
|
||||||
|
* migration UI), not alongside users on every app load. */
|
||||||
|
migrations: AccountMigrationRecord[]
|
||||||
|
|
||||||
loadUsers: () => Promise<void>
|
loadUsers: () => Promise<void>
|
||||||
updateRole: (userId: string, role: UserRole) => Promise<void>
|
updateRole: (userId: string, role: UserRole) => Promise<void>
|
||||||
|
updateCanPostAnnouncements: (userId: string, canPostAnnouncements: boolean) => Promise<void>
|
||||||
banUser: (userId: string) => Promise<void>
|
banUser: (userId: string) => Promise<void>
|
||||||
unbanUser: (userId: string) => Promise<void>
|
unbanUser: (userId: string) => Promise<void>
|
||||||
deleteUser: (userId: string) => Promise<void>
|
deleteUser: (userId: string) => Promise<void>
|
||||||
|
|
||||||
|
previewMigration: (fromUserId: string, toUserId: string) => Promise<AccountMigrationImpact>
|
||||||
|
/** Throws on failure (see AdminUserRepository.migrateAccount) — the
|
||||||
|
* caller is expected to catch and surface the reason. */
|
||||||
|
migrateAccount: (fromUserId: string, toUserId: string, verificationNotes: string) => Promise<AccountMigrationImpact>
|
||||||
|
loadMigrations: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminUserStore = create<AdminUserStoreState>((set) => ({
|
export const useAdminUserStore = create<AdminUserStoreState>((set) => ({
|
||||||
users: [],
|
users: [],
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
migrations: [],
|
||||||
|
|
||||||
loadUsers: async () => {
|
loadUsers: async () => {
|
||||||
const users = await repository.listUsers()
|
const users = await repository.listUsers()
|
||||||
@@ -29,6 +46,13 @@ export const useAdminUserStore = create<AdminUserStoreState>((set) => ({
|
|||||||
set((state) => ({ users: state.users.map((u) => (u.id === userId ? { ...u, role } : u)) }))
|
set((state) => ({ users: state.users.map((u) => (u.id === userId ? { ...u, role } : u)) }))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateCanPostAnnouncements: async (userId, canPostAnnouncements) => {
|
||||||
|
await repository.updateCanPostAnnouncements(userId, canPostAnnouncements)
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.map((u) => (u.id === userId ? { ...u, canPostAnnouncements } : u)),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
banUser: async (userId) => {
|
banUser: async (userId) => {
|
||||||
await repository.banUser(userId)
|
await repository.banUser(userId)
|
||||||
// Re-fetch rather than guessing bannedUntil client-side — the Edge
|
// Re-fetch rather than guessing bannedUntil client-side — the Edge
|
||||||
@@ -47,4 +71,21 @@ export const useAdminUserStore = create<AdminUserStoreState>((set) => ({
|
|||||||
await repository.deleteUser(userId)
|
await repository.deleteUser(userId)
|
||||||
set((state) => ({ users: state.users.filter((u) => u.id !== userId) }))
|
set((state) => ({ users: state.users.filter((u) => u.id !== userId) }))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
previewMigration: (fromUserId, toUserId) => repository.previewAccountMigration(fromUserId, toUserId),
|
||||||
|
|
||||||
|
migrateAccount: async (fromUserId, toUserId, verificationNotes) => {
|
||||||
|
const impact = await repository.migrateAccount(fromUserId, toUserId, verificationNotes)
|
||||||
|
// Re-fetch rather than patch client-side — migratedToUserId/Username
|
||||||
|
// both need the fresh server-computed join, and the migration log needs
|
||||||
|
// its new row.
|
||||||
|
const [users, migrations] = await Promise.all([repository.listUsers(), repository.listAccountMigrations()])
|
||||||
|
set({ users, migrations })
|
||||||
|
return impact
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMigrations: async () => {
|
||||||
|
const migrations = await repository.listAccountMigrations()
|
||||||
|
set({ migrations })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Announcement, AnnouncementRepository } from '../data/AnnouncementRepository'
|
||||||
|
import { SupabaseAnnouncementRepository } from '../data/SupabaseAnnouncementRepository'
|
||||||
|
|
||||||
|
const repository: AnnouncementRepository = new SupabaseAnnouncementRepository()
|
||||||
|
|
||||||
|
interface AnnouncementStoreState {
|
||||||
|
current: Announcement | null
|
||||||
|
isLoaded: boolean
|
||||||
|
|
||||||
|
load: () => Promise<void>
|
||||||
|
/** Dismisses the current announcement for the signed-in user — updates
|
||||||
|
* local state immediately rather than re-fetching, since the only thing
|
||||||
|
* that changed is this user's own dismissed flag. */
|
||||||
|
dismiss: () => Promise<void>
|
||||||
|
/** Posts a new announcement (retiring whichever was current) and reloads,
|
||||||
|
* so the poster immediately sees their own banner like everyone else. */
|
||||||
|
post: (message: string) => Promise<void>
|
||||||
|
retireCurrent: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAnnouncementStore = create<AnnouncementStoreState>((set, get) => ({
|
||||||
|
current: null,
|
||||||
|
isLoaded: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
const current = await repository.getCurrent()
|
||||||
|
set({ current, isLoaded: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss: async () => {
|
||||||
|
const { current } = get()
|
||||||
|
if (!current || current.dismissed) return
|
||||||
|
set({ current: { ...current, dismissed: true } })
|
||||||
|
await repository.dismiss(current.id)
|
||||||
|
},
|
||||||
|
|
||||||
|
post: async (message) => {
|
||||||
|
await repository.post(message)
|
||||||
|
await get().load()
|
||||||
|
},
|
||||||
|
|
||||||
|
retireCurrent: async () => {
|
||||||
|
await repository.retireCurrent()
|
||||||
|
await get().load()
|
||||||
|
},
|
||||||
|
}))
|
||||||
+11
-1
@@ -8,6 +8,10 @@ interface AuthStoreState {
|
|||||||
userId: string | null
|
userId: string | null
|
||||||
username: string | null
|
username: string | null
|
||||||
role: UserRole | null
|
role: UserRole | null
|
||||||
|
/** Lets a specific Admin post/retire site-wide announcements without the
|
||||||
|
* whole Admin role gaining that ability — a Super Admin can always post
|
||||||
|
* regardless of this flag (see can_manage_announcements() in the DB). */
|
||||||
|
canPostAnnouncements: boolean
|
||||||
isLoaded: boolean
|
isLoaded: boolean
|
||||||
|
|
||||||
/** One-time fetch of your own profile — nothing else in the app changes
|
/** One-time fetch of your own profile — nothing else in the app changes
|
||||||
@@ -22,6 +26,7 @@ export const useAuthStore = create<AuthStoreState>((set) => ({
|
|||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
role: null,
|
role: null,
|
||||||
|
canPostAnnouncements: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
@@ -32,11 +37,16 @@ export const useAuthStore = create<AuthStoreState>((set) => ({
|
|||||||
set({ isLoaded: true })
|
set({ isLoaded: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { data } = await supabase.from('profiles').select('username, role').eq('id', user.id).single()
|
const { data } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('username, role, can_post_announcements')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
set({
|
set({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: data?.username ?? null,
|
username: data?.username ?? null,
|
||||||
role: (data?.role as UserRole | undefined) ?? null,
|
role: (data?.role as UserRole | undefined) ?? null,
|
||||||
|
canPostAnnouncements: data?.can_post_announcements ?? false,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -176,7 +176,10 @@ enable_signup = true
|
|||||||
# Allow/disallow anonymous sign-ins to your project.
|
# Allow/disallow anonymous sign-ins to your project.
|
||||||
enable_anonymous_sign_ins = false
|
enable_anonymous_sign_ins = false
|
||||||
# Allow/disallow testing manual linking of accounts
|
# Allow/disallow testing manual linking of accounts
|
||||||
enable_manual_linking = false
|
# Enabled for organized-ideas.md §2's self-service Google-account migration:
|
||||||
|
# link a new Google identity to your existing account (ProfileModal), then
|
||||||
|
# unlink the old one, all while signed in as the account being migrated.
|
||||||
|
enable_manual_linking = true
|
||||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||||
minimum_password_length = 6
|
minimum_password_length = 6
|
||||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$;
|
||||||
+377
-3
@@ -25,7 +25,7 @@ begin;
|
|||||||
|
|
||||||
create extension if not exists pgtap with schema extensions;
|
create extension if not exists pgtap with schema extensions;
|
||||||
|
|
||||||
select plan(53);
|
select plan(103);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
-- Fixtures (as postgres — RLS does not apply)
|
-- 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"}'),
|
('11111111-1111-1111-1111-111111111111', 'alice@example.com', '{"username":"alice"}'),
|
||||||
('22222222-2222-2222-2222-222222222222', 'bob@example.com', '{"username":"bob"}'),
|
('22222222-2222-2222-2222-222222222222', 'bob@example.com', '{"username":"bob"}'),
|
||||||
('33333333-3333-3333-3333-333333333333', 'carol@example.com', '{"username":"carol_admin"}'),
|
('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 = 'admin' where id = '33333333-3333-3333-3333-333333333333';
|
||||||
update public.profiles set role = 'super_admin' where id = '44444444-4444-4444-4444-444444444444';
|
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(
|
select lives_ok(
|
||||||
$$ insert into public.diagrams (id, name, owner_id, data)
|
$$ insert into public.diagrams (id, name, owner_id, data)
|
||||||
values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111',
|
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'
|
'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'
|
'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 is(
|
||||||
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
|
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
|
||||||
0,
|
0,
|
||||||
'the impact count is zero for an entity id referenced by nothing'
|
'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
|
-- Batched submitter-username lookup (gated the same way as the impact
|
||||||
-- function above — an Admin reviewing a submission can see who submitted
|
-- 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)'
|
'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();
|
select * from finish();
|
||||||
|
|
||||||
rollback;
|
rollback;
|
||||||
|
|||||||
Reference in New Issue
Block a user