Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4) #219

Merged
logan merged 3 commits from feat/213-discord-admin into main 2026-10-04 22:05:36 -04:00
3 changed files with 32 additions and 14 deletions
Showing only changes of commit e574bd924a - Show all commits
+4
View File
@@ -91,6 +91,9 @@ export default function NetworkPage() {
count={systemsLoading ? null : systems.length} count={systemsLoading ? null : systems.length}
countLabel={systemsLoading ? "" : "configured"} countLabel={systemsLoading ? "" : "configured"}
/> />
{/* The radio-bot pool is platform-admin only (server-26#204 item 6);
an operator following this card only hit a 403 (server-26#213). */}
{isAdmin && (
<HubCard <HubCard
href="/tokens" href="/tokens"
title="Bot Tokens" title="Bot Tokens"
@@ -98,6 +101,7 @@ export default function NetworkPage() {
count={null} count={null}
countLabel="manage" countLabel="manage"
/> />
)}
</div> </div>
</div> </div>
); );
+14 -2
View File
@@ -1220,13 +1220,16 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const [tokens, setTokens] = useState<TokenOption[] | null>(null); const [tokens, setTokens] = useState<TokenOption[] | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() { async function load() {
if (tokens !== null) return; if (tokens !== null) return;
try { try {
const data = await c2api.getTokens(); const data = await c2api.getTokens();
setTokens(data as TokenOption[]); setTokens(data as TokenOption[]);
} catch { } catch (err) {
// An error used to render as "No tokens in pool." — a false empty state.
setError(err instanceof Error ? err.message : "Could not load radio bots.");
setTokens([]); setTokens([]);
} }
} }
@@ -1241,6 +1244,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
try { try {
await c2api.setPreferredToken(tokenId, systemId); await c2api.setPreferredToken(tokenId, systemId);
setPreferredId(tokenId); setPreferredId(tokenId);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the preference.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1252,6 +1257,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
try { try {
await c2api.setPreferredToken(preferredId, "_none"); await c2api.setPreferredToken(preferredId, "_none");
setPreferredId(null); setPreferredId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not clear the preference.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1276,6 +1283,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<div className="mt-3 space-y-2 font-mono text-xs"> <div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? ( {tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p> <p className="text-gray-600 italic">Loading tokens…</p>
) : error ? (
<p className="text-red-400">{error}</p>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p> <p className="text-gray-600 italic">No tokens in pool.</p>
) : ( ) : (
@@ -2016,7 +2025,10 @@ export default function SystemsPage() {
Delete Delete
</button> </button>
</div> </div>
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} /> {/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(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). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
<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} />
+7 -5
View File
@@ -15,7 +15,9 @@ interface TokenRecord {
} }
export default function TokensPage() { export default function TokensPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth(); // Platform admin only: every /tokens route is require_admin_token since
// server-26#204 item 6, so an operator here only ever saw a failed fetch.
const { isAdmin, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tokens, setTokens] = useState<TokenRecord[]>([]); const [tokens, setTokens] = useState<TokenRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -26,8 +28,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/"); if (!authLoading && !isAdmin) router.replace("/network");
}, [authLoading, isAdmin, isOperator, router]); }, [authLoading, isAdmin, router]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -38,7 +40,7 @@ export default function TokensPage() {
} }
}, []); }, []);
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { if (isAdmin) refresh(); }, [isAdmin, refresh]);
async function handleAdd(e: React.FormEvent) { async function handleAdd(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -67,7 +69,7 @@ export default function TokensPage() {
} }
} }
if (authLoading || (!isAdmin && !isOperator)) return null; if (authLoading || !isAdmin) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">