Gate admin menu and authoring with shared JWT

This commit is contained in:
2026-08-15 09:29:08 +02:00
parent eeaa4138fa
commit 8f1f5a8743
16 changed files with 366 additions and 52 deletions
+61 -6
View File
@@ -25,6 +25,7 @@ type TemporalItem = { id: string; sourceTemporalId: string; date: string; label:
export function App() {
const [caseState, setCaseState] = useState<CaseState | null>(null)
const [isAdmin, setIsAdmin] = useState(false)
const [noLevels, setNoLevels] = useState(false)
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
const [selected, setSelected] = useState<string | null>(null)
@@ -32,6 +33,7 @@ export function App() {
const [docsOpen, setDocsOpen] = useState(false)
const [documentQuery, setDocumentQuery] = useState('')
const [helpOpen, setHelpOpen] = useState(false)
const [adminMenuOpen, setAdminMenuOpen] = useState(false)
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
const [clock, setClock] = useState('')
const [draggingFiles, setDraggingFiles] = useState(false)
@@ -52,10 +54,12 @@ export function App() {
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const adminMenuRef = useRef<HTMLDivElement>(null)
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
useEffect(() => {
const params = new URLSearchParams(window.location.search)
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
fetch('/api/levels').then(r => {
if (!r.ok) throw new Error('Server unavailable')
return r.json()
@@ -80,6 +84,13 @@ export function App() {
return () => clearInterval(timer)
}, [])
useEffect(() => {
if (!adminMenuOpen) return
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [adminMenuOpen])
useEffect(() => {
if (!recentlyCreatedExhibitId) return
const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400)
@@ -277,6 +288,12 @@ export function App() {
window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
}
const enterLevelEditor = () => {
const params = new URLSearchParams(window.location.search)
params.set('edit', '1')
window.location.assign(`${window.location.pathname}?${params.toString()}`)
}
const uploadFiles = async (files: FileList | File[]) => {
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
const queue = Array.from(files)
@@ -297,13 +314,14 @@ export function App() {
}
}
if (noLevels) return <EmptyArchive canEdit={requestedEditMode} onCreated={level => { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
const temporalItems: TemporalItem[] = [
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
@@ -324,7 +342,21 @@ export function App() {
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
<button onClick={() => setHelpOpen(true)}>HELP</button>
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
{adminMenuOpen && <div className="admin-menu-items" role="menu">
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF &amp; CONCEPTS</button>
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
</>}
</div>}
</div>}
</nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header>
@@ -333,7 +365,7 @@ export function App() {
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
{requestedEditMode && caseState.editingAllowed && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
<div className="doc-list">
{filteredDocuments.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
@@ -344,14 +376,14 @@ export function App() {
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
</aside>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
canEdit={canAuthor}
onClose={closeBrief}
onEdit={() => setEditingBrief(true)}
onClassify={classifyConcept}
@@ -382,7 +414,30 @@ export function App() {
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
{editingFolderId && <FolderEditor
key={editingFolderId}
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!}
memberIds={containedIds(caseState, editingFolderId)}
documents={caseState.documents}
canManageContents={canAuthor}
onClose={() => setEditingFolderId(null)}
onSave={(folder, members) => {
update(state => ({
...state,
evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget),
relations: [
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id),
...members.map((documentId, index) => {
const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId)
const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index })
return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position }
}),
],
}))
setEditingFolderId(null)
setStatus('FOLDER UPDATED')
}}
/>}
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
{editingEventId && <EventEditor
key={editingEventId}
+1
View File
@@ -13,6 +13,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.menubar nav { display: flex; height: 100%; align-items: stretch; }
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
.menubar nav button:hover, .menubar nav button.active { background: #112f2a; color: white; }
.admin-menu { position: relative; display: flex; align-items: stretch; }.admin-menu > button { height: 100%; }.admin-menu-items { position: absolute; z-index: 30; top: calc(100% - 1px); right: 0; width: 235px; padding: 5px; display: grid; background: #c5c9c2; border: 2px outset #e5e8e2; box-shadow: 6px 8px 0 #020907aa; }.menubar nav .admin-menu-items button { min-height: 37px; padding: 0 12px; color: #263d36; border: 1px solid transparent; text-align: left; font-size: 9px; }.menubar nav .admin-menu-items button:hover, .menubar nav .admin-menu-items button:focus-visible { background: #173d34; border-color: #526c64; color: #f1f3ef; }
.brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; }
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }