Three of the app's routes were referenced but never existed, so the redesign's
navigation pointed at 404s from several directions.
/dashboard was the post-login and fallback redirect target in nine places --
login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards,
and the marketing header -- but app/dashboard/ was never created. Signing in
normally dropped the user on a 404. The real signed-in home is "/", which
app/page.tsx already renders as LiveView for an authed user with an org, and
which the nav labels "Live"; all nine now point there.
Nav also linked /watch and /network, neither of which existed. /watch is the
alerts screen under its redesign name, so it re-exports app/alerts/page.tsx
and /alerts stays reachable for old links. /network is new: the "my equipment"
hub the redesign moved /nodes, /systems and /tokens behind and then never
built, which had left /systems and /tokens with no entry point in the UI at
all. Its hooks all run before the admin/operator guard, per d041c86.
Separately, the admin page's guard read isAdmin without authLoading, so every
cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away
while the Firebase claims were still resolving. Admin was only reachable by
clicking through from an already-mounted page. Now it waits, like every other
guarded route does.
And /incidents no longer lies about an empty list: a failed Firestore query
leaves `incidents` empty just as a quiet night does, and the page was printing
"No incidents recorded yet" over the top of a missing-composite-index error.
useIncidents already returned `error`; the page just ignored it. It now renders
an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a
failure rather than as silence on the radio.
Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and
indexes still have to be pushed to the live project by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { c2api } from "@/lib/c2api";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
|
|
interface TokenRecord {
|
|
token_id: string;
|
|
name: string;
|
|
token: string; // masked server-side
|
|
in_use: boolean;
|
|
assigned_node_id: string | null;
|
|
assigned_at: string | null;
|
|
}
|
|
|
|
export default function TokensPage() {
|
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
const [name, setName] = useState("");
|
|
const [token, setToken] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
|
}, [authLoading, isAdmin, isOperator, router]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const data = await c2api.getTokens();
|
|
setTokens(data as TokenRecord[]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { refresh(); }, [refresh]);
|
|
|
|
async function handleAdd(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await c2api.addToken({ name, token });
|
|
setName("");
|
|
setToken("");
|
|
setShowAdd(false);
|
|
await refresh();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to add token.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (!confirm("Remove this token from the pool?")) return;
|
|
try {
|
|
await c2api.deleteToken(id);
|
|
await refresh();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : "Delete failed.");
|
|
}
|
|
}
|
|
|
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
|
<p className="text-xs text-gray-500 font-mono mt-0.5">
|
|
Discord bot tokens assigned to nodes when they join a voice channel.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAdd(!showAdd)}
|
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
|
>
|
|
+ Add Token
|
|
</button>
|
|
</div>
|
|
|
|
{showAdd && (
|
|
<form onSubmit={handleAdd} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
|
<h3 className="text-white font-semibold text-sm">Add Token</h3>
|
|
<div>
|
|
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
placeholder="DRB Bot 1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-400 block mb-1">Discord Bot Token</label>
|
|
<input
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
required
|
|
type="password"
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
|
placeholder="MTxxxxxxxxxx.Gxxxxx.xxxxxxxxxx"
|
|
/>
|
|
</div>
|
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
|
>
|
|
{saving ? "Saving…" : "Save"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdd(false)}
|
|
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
|
) : tokens.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">No tokens in the pool. Add one to enable Discord voice streaming.</p>
|
|
) : (
|
|
<div className="border border-gray-800 rounded-lg overflow-hidden font-mono">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-900 text-xs text-gray-500 uppercase tracking-wider">
|
|
<th className="px-4 py-2.5 text-left">Label</th>
|
|
<th className="px-4 py-2.5 text-left">Token</th>
|
|
<th className="px-4 py-2.5 text-left">Status</th>
|
|
<th className="px-4 py-2.5 text-left">Node</th>
|
|
<th className="px-4 py-2.5 w-16"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tokens.map((t) => (
|
|
<tr key={t.token_id} className="border-t border-gray-800 hover:bg-gray-900/50">
|
|
<td className="px-4 py-2.5 text-white">{t.name}</td>
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs">{t.token}</td>
|
|
<td className="px-4 py-2.5">
|
|
{t.in_use ? (
|
|
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded">In use</span>
|
|
) : (
|
|
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">Free</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
|
{t.assigned_node_id ?? "—"}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<button
|
|
onClick={() => handleDelete(t.token_id)}
|
|
disabled={t.in_use}
|
|
className="text-xs text-red-600 hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|