diff --git a/src/components/admin/AdminUsersModal.tsx b/src/components/admin/AdminUsersModal.tsx new file mode 100644 index 0000000..1d4fbad --- /dev/null +++ b/src/components/admin/AdminUsersModal.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react' +import type { AdminUserSummary, UserRole } from '../../data/AdminUserRepository' +import { useAdminUserStore } from '../../state/adminUserStore' +import { useAuthStore } from '../../state/authStore' +import Modal from '../common/Modal' + +const ROLE_OPTIONS: UserRole[] = ['regular', 'admin', 'super_admin'] + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' }) +} + +function isBanned(user: AdminUserSummary): boolean { + return !!user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now() +} + +export default function AdminUsersModal({ onClose }: { onClose: () => void }) { + const currentUserId = useAuthStore((s) => s.userId) + const users = useAdminUserStore((s) => s.users) + const loadUsers = useAdminUserStore((s) => s.loadUsers) + const updateRole = useAdminUserStore((s) => s.updateRole) + const banUser = useAdminUserStore((s) => s.banUser) + const unbanUser = useAdminUserStore((s) => s.unbanUser) + const deleteUser = useAdminUserStore((s) => s.deleteUser) + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + const runAction = async (userId: string, action: () => Promise) => { + setError(null) + setBusyId(userId) + try { + await action() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setBusyId(null) + } + } + + const handleRoleChange = (user: AdminUserSummary, role: UserRole) => { + if (user.id === currentUserId && role !== 'super_admin') { + const confirmed = window.confirm( + `Change your own role to "${role}"? You'll lose Super Admin access immediately — another Super Admin (or direct DB access) would be needed to undo this.`, + ) + if (!confirmed) return + } + runAction(user.id, () => updateRole(user.id, role)) + } + + const handleBanToggle = (user: AdminUserSummary) => { + const banned = isBanned(user) + const confirmed = window.confirm( + banned ? `Unban "${user.username}"? They'll be able to sign in again.` : `Ban "${user.username}"? This blocks sign-in but keeps their data intact — reversible.`, + ) + if (!confirmed) return + runAction(user.id, () => (banned ? unbanUser(user.id) : banUser(user.id))) + } + + const handleDelete = (user: AdminUserSummary) => { + const confirmed = window.confirm( + `Permanently delete "${user.username}"? This deletes their account AND all their diagrams and private catalog entries. This cannot be undone.`, + ) + if (!confirmed) return + runAction(user.id, () => deleteUser(user.id)) + } + + return ( + + {error &&

{error}

} +
+ {users.map((user) => { + const busy = busyId === user.id + const self = user.id === currentUserId + const banned = isBanned(user) + return ( +
+
+
+ {user.username} + {self && ( + You + )} + {banned && ( + Banned + )} +
+
+ {user.email} · joined {formatDate(user.createdAt)} +
+
+
+ + + +
+
+ ) + })} + {users.length === 0 &&

No users found.

} +
+
+ +
+
+ ) +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index c2d1733..5576233 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -5,6 +5,7 @@ import { useAdminReviewStore } from '../../state/adminReviewStore' import { useAuthStore } from '../../state/authStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' +import AdminUsersModal from '../admin/AdminUsersModal' import AdminReviewModal from '../submissions/AdminReviewModal' import MySubmissionsModal from '../submissions/MySubmissionsModal' import DiagramManagerModal from './DiagramManagerModal' @@ -37,11 +38,13 @@ export default function TopBar() { const username = useAuthStore((s) => s.username) const role = useAuthStore((s) => s.role) const isAdmin = role === 'admin' || role === 'super_admin' + const isSuperAdmin = role === 'super_admin' const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length) const fileInputRef = useRef(null) const [diagramManagerOpen, setDiagramManagerOpen] = useState(false) const [submissionsOpen, setSubmissionsOpen] = useState(false) const [adminReviewOpen, setAdminReviewOpen] = useState(false) + const [adminUsersOpen, setAdminUsersOpen] = useState(false) const pendingSubmissionCount = useMemo( () => mySubmissions.filter((s) => s.status === 'pending').length, @@ -121,6 +124,14 @@ export default function TopBar() { )} )} + {isSuperAdmin && ( + + )} @@ -142,6 +153,7 @@ export default function TopBar() { {diagramManagerOpen && setDiagramManagerOpen(false)} />} {submissionsOpen && setSubmissionsOpen(false)} />} {adminReviewOpen && setAdminReviewOpen(false)} />} + {adminUsersOpen && setAdminUsersOpen(false)} />} ) } diff --git a/src/components/palette/CableTypeManager.tsx b/src/components/palette/CableTypeManager.tsx index fd3bf84..b39bfc8 100644 --- a/src/components/palette/CableTypeManager.tsx +++ b/src/components/palette/CableTypeManager.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react' import { allCableTypes, allPortTypes } from '../../domain/project' import type { CableType, PortType } from '../../domain/types' import { cableTypeToRow } from '../../data/catalogRowMapping' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useSubmissionStore } from '../../state/submissionStore' import { resolveCableTypeFields, type CableTypeFormFields } from './cableTypeFormFields' @@ -129,11 +131,17 @@ export default function CableTypesPanel() { const catalog = useCatalogStore((s) => s.catalog) const addCustomCableType = useCatalogStore((s) => s.addCustomCableType) const updateCustomCableType = useCatalogStore((s) => s.updateCustomCableType) + const adminUpdateCableType = useCatalogStore((s) => s.adminUpdateCableType) + const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const submitForReview = useSubmissionStore((s) => s.submitForReview) + const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) + const role = useAuthStore((s) => s.role) + const isAdmin = role === 'admin' || role === 'super_admin' const [creating, setCreating] = useState(false) const [editingId, setEditingId] = useState(null) const [suggestingId, setSuggestingId] = useState(null) + const [adminEditingId, setAdminEditingId] = useState(null) const portTypes = useMemo( () => [...allPortTypes(catalog)].sort((a, b) => a.name.localeCompare(b.name)), @@ -185,6 +193,24 @@ export default function CableTypesPanel() { setSuggestingId(null) } + const handleAdminSave = (id: string, fields: CableTypeFormFields) => { + const resolved = resolveCableTypeFields(fields, portTypes) + if (!resolved) return + adminUpdateCableType(id, resolved) + setAdminEditingId(null) + } + + const handleUnpublish = async (ct: CableType) => { + const impact = await getUsageImpact('cable_type', ct.id) + const impactNote = + impact.diagramCount > 0 + ? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.` + : '\n\nNot currently used in any diagram.' + const confirmed = window.confirm(`Unpublish "${ct.name}" from the public catalog?${impactNote}`) + if (!confirmed) return + adminUnpublish('cable_type', ct.id) + } + return (
@@ -263,22 +289,52 @@ export default function CableTypesPanel() { Built-in ({builtInTypes.length})
- {builtInTypes.map((ct) => - suggestingId === ct.id ? ( -
- setSuggestingId(null)} - onSave={(fields) => handleSuggestEdit(ct.id, fields)} - /> -
- ) : ( + {builtInTypes.map((ct) => { + if (isAdmin && adminEditingId === ct.id) { + return ( +
+ setAdminEditingId(null)} + onSave={(fields) => handleAdminSave(ct.id, fields)} + /> +
+ ) + } + if (!isAdmin && suggestingId === ct.id) { + return ( +
+ setSuggestingId(null)} + onSave={(fields) => handleSuggestEdit(ct.id, fields)} + /> +
+ ) + } + return (
{ct.name} · {describeEnds(ct)} - {pendingEntityIds.has(ct.id) ? ( + {isAdmin ? ( +
+ + +
+ ) : pendingEntityIds.has(ct.id) ? ( Edit pending review @@ -291,11 +347,13 @@ export default function CableTypesPanel() { )}
- ), - )} + ) + })}

- Built-in cable types can't be edited directly — "Suggest edit" sends a proposed change to Admins for review. + {isAdmin + ? 'As an Admin, you can edit or unpublish public cable types directly.' + : 'Built-in cable types can\'t be edited directly — "Suggest edit" sends a proposed change to Admins for review.'}

diff --git a/src/components/palette/DevicePalette.tsx b/src/components/palette/DevicePalette.tsx index 8bbc7c8..2755554 100644 --- a/src/components/palette/DevicePalette.tsx +++ b/src/components/palette/DevicePalette.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from 'react' import { deviceTemplateToRow } from '../../data/catalogRowMapping' import { allDeviceCategories, allDeviceTemplates, hiddenDeviceTemplates } from '../../domain/project' import type { DeviceTemplate } from '../../domain/types' +import { useAdminReviewStore } from '../../state/adminReviewStore' +import { useAuthStore } from '../../state/authStore' import { useCatalogStore } from '../../state/catalogStore' import { useProjectStore } from '../../state/projectStore' import { useSubmissionStore } from '../../state/submissionStore' @@ -10,7 +12,11 @@ import Chevron from '../common/Chevron' import ConnectorLibraryModal from './ConnectorLibraryModal' import DeviceTemplateEditor from './DeviceTemplateEditor' -type EditorTarget = { mode: 'new' } | { mode: 'edit'; template: DeviceTemplate } | { mode: 'suggestEdit'; template: DeviceTemplate } +type EditorTarget = + | { mode: 'new' } + | { mode: 'edit'; template: DeviceTemplate } + | { mode: 'suggestEdit'; template: DeviceTemplate } + | { mode: 'adminEdit'; template: DeviceTemplate } export default function DevicePalette() { const project = useProjectStore((s) => s.project) @@ -20,8 +26,12 @@ export default function DevicePalette() { const removeCustomDeviceTemplate = useCatalogStore((s) => s.removeCustomDeviceTemplate) const hidePublicDeviceTemplate = useCatalogStore((s) => s.hidePublicDeviceTemplate) const restorePublicDeviceTemplate = useCatalogStore((s) => s.restorePublicDeviceTemplate) + const adminUnpublish = useCatalogStore((s) => s.adminUnpublish) const mySubmissions = useSubmissionStore((s) => s.mySubmissions) const submitForReview = useSubmissionStore((s) => s.submitForReview) + const getUsageImpact = useAdminReviewStore((s) => s.getUsageImpact) + const role = useAuthStore((s) => s.role) + const isAdmin = role === 'admin' || role === 'super_admin' const [editorTarget, setEditorTarget] = useState(null) const [portTypesOpen, setPortTypesOpen] = useState(false) const [hiddenListOpen, setHiddenListOpen] = useState(false) @@ -99,6 +109,17 @@ export default function DevicePalette() { submitForReview('device_template', template.id, deviceTemplateToRow(template)) } + const handleUnpublish = async (template: DeviceTemplate) => { + const impact = await getUsageImpact('device_template', template.id) + const impactNote = + impact.diagramCount > 0 + ? `\n\nUsed in ${impact.diagramCount} diagram${impact.diagramCount === 1 ? '' : 's'}: ${impact.sample.map((s) => `${s.name} (${s.ownerUsername})`).join(', ')}${impact.diagramCount > impact.sample.length ? ', …' : ''}. Existing diagrams keep working — this only removes it from new use.` + : '\n\nNot currently used in any diagram.' + const confirmed = window.confirm(`Unpublish "${template.name}" from the public catalog?${impactNote}`) + if (!confirmed) return + adminUnpublish('device_template', template.id) + } + return (