import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavLink, Route, Routes, useLocation } from "react-router-dom";
import { Menu, RefreshCw, X } from "lucide-react";
import { getPlugins, subscribePlugins, type PluginManifest } from "@/plugin-api";
import { PluginHost } from "@/components/PluginHost";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { probeSession, signOut, WhoamiResponse } from "@/lib/api";
import { reloadThirdPartyPlugins } from "@/lib/plugin-loader";
import { useToast } from "@/lib/toast";
import { Install } from "@/pages/Install";
import { Login } from "@/pages/Login";
/**
* Hook that subscribes to plugin-registry changes and returns the
* current snapshot. The registry is mutated by `registerPlugin`; this
* hook forces a rerender whenever that fires so the shell's nav
* picks up third-party plugins added after boot.
*/
function usePlugins() {
const [, force] = useState(0);
useEffect(() => subscribePlugins(() => force((n) => n + 1)), []);
return getPlugins();
}
export default function App() {
const allPlugins = usePlugins();
const { pathname } = useLocation();
const [navOpen, setNavOpen] = useState(false);
const qc = useQueryClient();
const toast = useToast();
// De-dupe back-to-back expiry events: one expired session can fire
// 401s on every in-flight query in parallel. The ref clears once
// the whoami probe has flipped to null so a fresh sign-in can be
// detected again on its next expiry.
const expiryNotifiedRef = useRef(false);
// Auto-close the mobile nav on route change — operators expect the
// sheet to dismiss after they pick a destination.
useEffect(() => {
setNavOpen(false);
}, [pathname]);
// Global session-expiry handler. `lib/api` dispatches
// `vtc-session-expired` whenever any authenticated request returns
// 401/403; when there's actually a cached whoami payload (i.e. the
// operator *was* signed in), we invalidate it so App re-renders
// into <Login> and show a friendly toast. The check on
// `getQueryData(["whoami"])` filters out 401s emitted during the
// login ceremony itself (no session present yet).
useEffect(() => {
const onExpired = () => {
const current = qc.getQueryData(["whoami"]);
if (!current) return;
if (expiryNotifiedRef.current) return;
expiryNotifiedRef.current = true;
toast.push("info", "Your session expired. Sign in again to continue.");
qc.setQueryData(["whoami"], null);
void qc.invalidateQueries({ queryKey: ["whoami"] });
};
window.addEventListener("vtc-session-expired", onExpired);
return () => {
window.removeEventListener("vtc-session-expired", onExpired);
};
}, [qc, toast]);
// Probe the session cookie via `/v1/auth/whoami`. Returning the
// claim payload (not just a bool) lets the navbar show "Signed
// in as …" without a second round trip. 401/403 → show Login.
//
// Rules-of-Hooks discipline: every `use*` hook in this function
// body lives *above* every conditional early return. A previous
// version short-circuited `/install` before reaching this
// `useQuery`, which flipped the hook count between routes and
// would trip React's mount-time hook-order check on the first
// navigation away from `/install`. Conditional rendering moves
// strictly after the hook block.
const probe = useQuery({
queryKey: ["whoami"],
queryFn: probeSession,
staleTime: 30_000,
retry: false,
// Skip the network call when we're on the install ceremony —
// there's no session yet and the 401 would just trigger the
// expired-session toast we already filter out. The hook is
// still invoked unconditionally; `enabled` is a runtime
// property, not a hook-shape change.
enabled: !pathname.startsWith("/install"),
});
// Re-arm the session-expiry guard whenever a fresh session lands.
// Without this, a second expiry inside the same browser tab would
// be silently ignored.
useEffect(() => {
if (probe.data) {
expiryNotifiedRef.current = false;
}
}, [probe.data]);
// Once the operator is signed in, watch for new plugins:
// - On window focus (operator alt-tabs back after dropping a
// plugin into the daemon's plugin_dir).
// - On a short interval as a fallback for browsers that don't
// reliably fire `focus`.
// Already-loaded plugins are skipped by `reloadThirdPartyPlugins`,
// so the cost on the steady-state path is one HEAD-like JSON fetch.
useEffect(() => {
if (!probe.data) return;
let cancelled = false;
const tick = () => {
if (cancelled) return;
void reloadThirdPartyPlugins();
};
window.addEventListener("focus", tick);
return () => {
cancelled = true;
window.removeEventListener("focus", tick);
};
}, [probe.data]);
// ── Conditional rendering only happens after every hook above. ──
// `/install` is the unauthenticated install-claim ceremony. It
// renders standalone (no nav, no plugins) because the operator
// who hits it doesn't have a session yet.
if (pathname.startsWith("/install")) {
return <Install />;
}
if (probe.isPending) {
return <SignInLoading />;
}
if (!probe.data) {
return <Login />;
}
// A "super admin" is Admin role with no context restrictions.
// Scope-filtered plugins surface server errors as 403s anyway, but
// hiding them from the nav keeps the UX coherent.
const isSuperAdmin =
probe.data.role === "admin" && probe.data.allowedContexts.length === 0;
const plugins = allPlugins.filter((p) => {
if (!p.scopes || p.scopes.length === 0) return true;
if (p.scopes.includes("super-admin")) return isSuperAdmin;
return true;
});
return (
<div className={`layout${navOpen ? " nav-open" : ""}`}>
<button
type="button"
className="nav-toggle"
aria-label={navOpen ? "Close navigation" : "Open navigation"}
aria-expanded={navOpen}
aria-controls="admin-nav"
onClick={() => setNavOpen((v) => !v)}
>
<span className="button-icon" aria-hidden="true">
{navOpen ? <X /> : <Menu />}
</span>
Menu
</button>
<aside className="nav" id="admin-nav">
<header>
<h1>VTC Admin</h1>
<SessionBadge whoami={probe.data} />
<ThemeSwitcher />
</header>
<ul>
{plugins.map((p) => (
<li key={p.id}>
<NavLink to={p.path}>
<span className="nav-icon" aria-hidden="true">
<PluginIcon plugin={p} />
</span>
<span className="nav-label">{p.label}</span>
</NavLink>
</li>
))}
</ul>
<ReloadPluginsButton />
</aside>
<main className="content">
<Routes>
{plugins.map((p) => (
<Route
key={p.id}
path={`${p.path}/*`}
element={<PluginHost plugin={p} />}
/>
))}
{/* Default route: first plugin (Dashboard). */}
{plugins[0] && (
<Route path="/" element={<PluginHost plugin={plugins[0]} />} />
)}
{/* Fallback for unknown URLs under /admin/ */}
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
);
}
function PluginIcon({ plugin }: { plugin: PluginManifest }) {
// Built-in plugins ship a lucide-react component; third-party
// plugins fall back to the `icon` string (inline SVG or single
// glyph). If neither is set, fall back to the label's first
// letter so the nav row stays balanced.
if (plugin.iconComponent) {
const Icon = plugin.iconComponent;
return <Icon aria-hidden="true" />;
}
if (plugin.icon) {
if (plugin.icon.trim().startsWith("<")) {
return (
<span
className="plugin-icon-raw"
dangerouslySetInnerHTML={{ __html: plugin.icon }}
/>
);
}
return <span aria-hidden="true">{plugin.icon}</span>;
}
return <span aria-hidden="true">{plugin.label.charAt(0).toUpperCase()}</span>;
}
function SessionBadge({ whoami }: { whoami: WhoamiResponse }) {
const qc = useQueryClient();
const toast = useToast();
const signOutMut = useMutation({
mutationFn: signOut,
onError: (err) => toast.pushFromError(err, "Sign-out failed"),
onSettled: () => {
// Whether the server-side revoke succeeded or not, the
// cookies are gone now — force the query cache to refetch
// so the shell flips back to the Login screen.
qc.invalidateQueries({ queryKey: ["whoami"] });
},
});
return (
<div className="session-badge">
<div className="session-did" title={whoami.did}>
<span className="session-label">Signed in as</span>
<code>{shortDid(whoami.did)}</code>
</div>
<button
type="button"
className="link"
onClick={() => signOutMut.mutate()}
disabled={signOutMut.isPending}
aria-busy={signOutMut.isPending}
>
{signOutMut.isPending ? "Signing out…" : "Sign out"}
</button>
</div>
);
}
function ReloadPluginsButton() {
const toast = useToast();
const [pending, setPending] = useState(false);
return (
<div className="nav-footer">
<button
type="button"
className="link"
disabled={pending}
aria-busy={pending}
title="Refetch /admin/plugins.json and import any new plugins"
onClick={async () => {
setPending(true);
try {
const added = await reloadThirdPartyPlugins();
if (added.length === 0) {
toast.push("info", "No new plugins.");
} else {
toast.push(
"success",
`Loaded ${added.length} new plugin${added.length === 1 ? "" : "s"}: ${added.join(", ")}`,
);
}
} catch (err) {
toast.pushFromError(err, "Plugin reload failed");
} finally {
setPending(false);
}
}}
>
<span className="button-icon" aria-hidden="true">
<RefreshCw />
</span>
{pending ? "Reloading plugins…" : "Reload plugins"}
</button>
</div>
);
}
function shortDid(did: string): string {
// `did:key:z6Mk…XYZ` — keep the method prefix readable + the
// last 6 chars so two distinct admins are still visually
// distinguishable in the navbar.
if (did.length <= 20) return did;
return `${did.slice(0, 12)}…${did.slice(-6)}`;
}
function SignInLoading() {
return (
<section className="page login-page">
<div className="login-card">
<h2>VTC Admin</h2>
<p className="lead">Checking session…</p>
</div>
</section>
);
}
function NotFound() {
return (
<section className="page">
<h2>Not found</h2>
<p className="lead">
The URL didn't match a registered plugin. The nav on the left
shows what's available.
</p>
</section>
);
}