Merge pull request 'Frontend: hide admin-only node/system controls from non-admins (#222, #283) + popup contrast' (#291) from fix/222-283-admin-gating into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #291.
This commit is contained in:
2026-10-25 22:17:26 -04:00
4 changed files with 63 additions and 30 deletions
+7
View File
@@ -199,6 +199,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6); filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6);
} }
/* Leaflet popups stay white in both themes (their body text is a fixed
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
* white — is pinned to a grey that holds AA on white (~6:1). */
.leaflet-popup-content .text-ink-muted {
color: #5B6577;
}
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip /* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger * padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */ * than the video it holds. */
+12 -2
View File
@@ -160,7 +160,8 @@ export default function NodeDetailPage() {
<> No timeout is currently enforced (permanent override).</> <> No timeout is currently enforced (permanent override).</>
)} )}
</p> </p>
<div className="flex gap-2"> {/* Override routes are require_service_key_or_admin (server-26#222). */}
{isAdmin && <div className="flex gap-2">
{node.override_timeout_at && ( {node.override_timeout_at && (
<button <button
onClick={async () => { onClick={async () => {
@@ -189,7 +190,7 @@ export default function NodeDetailPage() {
> >
Force Revert Config Force Revert Config
</button> </button>
</div> </div>}
</div> </div>
)} )}
@@ -245,13 +246,21 @@ export default function NodeDetailPage() {
)} )}
{/* Actions */} {/* Actions */}
{/* Configure and Restart are admin-only on the server (server-26#222);
non-admins get a note instead of a button that 403s. */}
{!isAdmin && !node.configured && (
<p className="text-sm text-ink-2">This node isn&apos;t configured yet. An admin needs to assign it a radio system.</p>
)}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{isAdmin && (
<button <button
onClick={() => setShowConfig(true)} onClick={() => setShowConfig(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors" className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
> >
{node.configured ? "Reassign System" : "Configure"} {node.configured ? "Reassign System" : "Configure"}
</button> </button>
)}
{isAdmin && (
<button <button
disabled={sending} disabled={sending}
onClick={() => sendCommand("op25_restart", "Restart OP25")} onClick={() => sendCommand("op25_restart", "Restart OP25")}
@@ -259,6 +268,7 @@ export default function NodeDetailPage() {
> >
Restart OP25 Restart OP25
</button> </button>
)}
{isAdmin && ( {isAdmin && (
<button <button
disabled={sending} disabled={sending}
+6
View File
@@ -79,9 +79,15 @@ export default function NodesPage() {
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
// Configuring is admin-only on the server (server-26#222); for
// everyone else the card links to the node page instead.
isAdmin ? (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> <div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} /> <NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div> </div>
) : (
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
)
))} ))}
</div> </div>
</div> </div>
+12 -2
View File
@@ -1953,12 +1953,16 @@ export default function SystemsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1> <h1 className="text-xl font-bold text-white font-mono">Systems</h1>
{/* Every systems write route is require_admin_token (server-26#283);
operators get a read-only view. */}
{isAdmin && (
<button <button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }} onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors" className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
> >
+ New System + New System
</button> </button>
)}
</div> </div>
{editing && ( {editing && (
@@ -2006,7 +2010,7 @@ export default function SystemsPage() {
{kindLabel(s)} {kindLabel(s)}
</span> </span>
</div> </div>
<div className="mt-3 flex gap-3"> {isAdmin && <div className="mt-3 flex gap-3">
<button <button
onClick={() => openEdit(s)} onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
@@ -2025,15 +2029,21 @@ export default function SystemsPage() {
> >
Delete Delete
</button> </button>
</div> </div>}
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only {/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could (server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */} neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />} {isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
{/* All admin-only routes (server-26#283), incl. area-context and
talkgroup-pending GETs and every vocabulary write. */}
{isAdmin && (
<>
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} /> <AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} /> <AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} /> <TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} /> <VocabularyPanel systemId={s.system_id} />
</>
)}
</div> </div>
); );
})} })}