Move the staff dashboards into the hub

The staff pages are now part of the hub: /hub/team, /hub/mod, /hub/sign-in,
/hub/setup (Kira's password link) and /hub/staff-help, under the hub's tab
bar. In a browser that signed in, /hub shows the member's Team or
Moderation tab after the public ones (a localStorage hint only; every
dashboard still checks the session with Kira). /staff/* redirects to the
new addresses, fragment included, so links already sent keep working.

Also: an expired or used setup link now shows Kira's message instead of
"This page needs the link Kira DMs you".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 15:27:55 +01:00
1 parent db4b406030
commit 0be2a3bbc0
10 files changed
+179 -95

No files matched your search

+3 -1
View File
@@ -26,7 +26,9 @@ npm run lint
```
app/ the routes: /, /hub, /mods, /mods/<id>, /mods/submit, /mods/policy, /releases,
/benchmarks, /stats, /docs, /docs/<page>, /privacy, /staff, /apply
/benchmarks, /stats, /docs, /docs/<page>, /privacy, /apply, and the staff's
pages inside the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup, /hub/staff-help
(the old /staff/* addresses redirect there)
app/api/ the registry and players proxies, and release view counts
app/staff/api/ the staff dashboards' proxy to Kira's service
app/data/ /data/roadmap.json and /data/journal.json, which Kira reads
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { StaffApp } from "@/components/staff/staff-app";
// The staff's pages inside the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup (the link Kira DMs), /hub/staff-help.
// The old /staff/* addresses redirect here (next.config.ts).
const STAFF_VIEWS = ["team", "mod", "sign-in", "setup", "staff-help"];
export const metadata: Metadata = {
title: "Staff · Horizon hub",
robots: { index: false, follow: false },
referrer: "no-referrer",
};
export function generateStaticParams() {
return STAFF_VIEWS.map((view) => ({ view }));
}
export default async function StaffPage({ params }: { params: Promise<{ view: string }> }) {
const { view } = await params;
if (!STAFF_VIEWS.includes(view)) notFound();
return <StaffApp view={view} />;
}
-21
View File
@@ -1,21 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { StaffApp } from "@/components/staff/staff-app";
export const metadata: Metadata = {
title: "Staff · Horizon",
robots: { index: false, follow: false },
referrer: "no-referrer",
};
const VIEWS = ["team", "mod", "setup", "help"];
export function generateStaticParams() {
return [{ view: [] }, ...VIEWS.map((v) => ({ view: [v] }))];
}
export default async function StaffPage({ params }: { params: Promise<{ view?: string[] }> }) {
const { view = [] } = await params;
if (view.length > 1 || (view.length === 1 && !VIEWS.includes(view[0]))) notFound();
return <StaffApp view={view[0] ?? ""} />;
}
+72 -22
View File
@@ -1,6 +1,8 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from "react";
import { readStaffHint, staffDashboards, subscribeStaffHint, type Dashboard } from "@/components/staff/api";
export const TABS = [
{ id: "overview", label: "Overview" },
@@ -59,6 +61,50 @@ function setHash(id: string) {
const TAB_CLASS =
"inline-flex h-9 shrink-0 items-center rounded-lg px-3.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#a98fff]";
const TAB_ON = "bg-white text-background";
const TAB_OFF = "text-white/65 hover:bg-white/[0.08] hover:text-white";
const BAR = "sticky top-18 z-30 border-b border-white/[0.08] bg-background/80 backdrop-blur-md";
const BAR_ROW = "mx-auto flex w-full max-w-6xl flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center sm:justify-between lg:px-10";
export const STAFF_TABS: Record<Dashboard, string> = { team: "Team", mod: "Moderation" };
// The staff's own tabs (/hub/team, /hub/mod), after the public ones: only in a browser that signed in to them.
export function useStaffTabs(): Dashboard[] {
const hint = useSyncExternalStore(subscribeStaffHint, readStaffHint, () => "");
return useMemo(() => staffDashboards(hint), [hint]);
}
function StaffTabs({ dashboards, current }: { dashboards: Dashboard[]; current?: string }) {
if (!dashboards.length) return null;
return (
<nav aria-label="Staff" className="flex shrink-0 gap-1 border-l border-white/[0.12] pl-2">
{dashboards.map((d) => (
<Link key={d} href={`/hub/${d}`} aria-current={d === current ? "page" : undefined} className={`${TAB_CLASS} ${d === current ? TAB_ON : TAB_OFF}`}>
{STAFF_TABS[d]}
</Link>
))}
</nav>
);
}
// The hub's tab bar on the staff pages: the public tabs lead back to /hub.
export function HubStaffBar({ dashboards, current }: { dashboards: Dashboard[]; current?: string }) {
return (
<div className={BAR}>
<div className={BAR_ROW}>
<div className="flex min-w-0 items-center gap-1">
<nav aria-label="Hub" className="-mx-1 flex min-w-0 gap-1 overflow-x-auto px-1">
{TABS.map((t) => (
<a key={t.id} href={`/hub#${t.id}`} className={`${TAB_CLASS} ${TAB_OFF}`}>
{t.label}
</a>
))}
</nav>
<StaffTabs dashboards={dashboards} current={current} />
</div>
</div>
</div>
);
}
export function HubShell({
areas,
@@ -82,6 +128,7 @@ export function HubShell({
const hash = useSyncExternalStore(subscribeHash, readHash, () => "");
const held = anchors[hash];
const tab: Tab = isTab(hash) ? hash : held && isTab(held) ? held : "overview";
const staff = useStaffTabs();
useEffect(() => {
if (!hash || isTab(hash)) return;
@@ -114,28 +161,31 @@ export function HubShell({
return (
<div ref={root} className="hub" data-tab={tab} data-detail={detail}>
<div className="sticky top-18 z-30 border-b border-white/[0.08] bg-background/80 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center sm:justify-between lg:px-10">
<div role="tablist" aria-label="Hub" className="-mx-1 flex gap-1 overflow-x-auto px-1">
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
id={`tab-${t.id}`}
type="button"
role="tab"
aria-selected={active}
aria-controls={t.id}
tabIndex={active ? 0 : -1}
onClick={() => choose(t.id)}
onKeyDown={onKeyDown}
className={`${TAB_CLASS} ${active ? "bg-white text-background" : "text-white/65 hover:bg-white/[0.08] hover:text-white"}`}
>
{t.label}
</button>
);
})}
<div className={BAR}>
<div className={BAR_ROW}>
<div className="flex min-w-0 items-center gap-1">
<div role="tablist" aria-label="Hub" className="-mx-1 flex gap-1 overflow-x-auto px-1">
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
id={`tab-${t.id}`}
type="button"
role="tab"
aria-selected={active}
aria-controls={t.id}
tabIndex={active ? 0 : -1}
onClick={() => choose(t.id)}
onKeyDown={onKeyDown}
className={`${TAB_CLASS} ${active ? TAB_ON : TAB_OFF}`}
>
{t.label}
</button>
);
})}
</div>
<StaffTabs dashboards={staff} />
</div>
<div role="group" aria-label="Detail" className="flex shrink-0 rounded-lg bg-white/[0.06] p-1">
+30
View File
@@ -74,6 +74,36 @@ export type Overview = {
decisions?: Decision[];
};
// Which dashboards this browser last signed in to, so the hub can show their tabs without asking Kira on every visit.
// Only a hint for links: every dashboard checks the session itself.
const STAFF_HINT = "hub:staff";
const hintListeners = new Set<() => void>();
export function rememberStaff(dashboards: Dashboard[]) {
try {
if (dashboards.length) localStorage.setItem(STAFF_HINT, dashboards.join(","));
else localStorage.removeItem(STAFF_HINT);
} catch {}
hintListeners.forEach((onChange) => onChange());
}
export function subscribeStaffHint(onChange: () => void) {
hintListeners.add(onChange);
return () => {
hintListeners.delete(onChange);
};
}
export function readStaffHint() {
try {
return localStorage.getItem(STAFF_HINT) ?? "";
} catch {
return "";
}
}
export const staffDashboards = (hint: string) => hint.split(",").filter((d): d is Dashboard => d === "team" || d === "mod");
let csrf = "";
export const setCsrf = (token?: string) => {
csrf = token || "";
+1 -1
View File
@@ -305,7 +305,7 @@ export function Applications({ apps, people, dashboard, onDone }: { apps: Applic
<div className="space-y-4">
<p className="text-[15px] text-white/60">
Verified contributors come first.{" "}
<Link href="/staff/help#applications" className={LINK}>
<Link href="/hub/staff-help#applications" className={LINK}>
How the vote works
</Link>
.
+1 -1
View File
@@ -113,7 +113,7 @@ export function ModDashboard({ who }: { who: Who }) {
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs tabs={tabs} active={tab} onSelect={selectTab} label="Moderation dashboard" />
<Link href="/staff/help" className="text-sm text-white/55 hover:text-white">
<Link href="/hub/staff-help" className="text-sm text-white/55 hover:text-white">
How this works
</Link>
</div>
+35 -39
View File
@@ -3,18 +3,19 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
import { ApiError, api, setCsrf, type Dashboard, type Who } from "@/components/staff/api";
import { HubStaffBar, STAFF_TABS, useStaffTabs } from "@/components/hub-shell";
import { ApiError, api, rememberStaff, setCsrf, type Dashboard, type Who } from "@/components/staff/api";
import { ModDashboard } from "@/components/staff/mod";
import { TeamDashboard } from "@/components/staff/team";
import { CARD, Field, GHOST_SM, INPUT, LINK, PRIMARY, SECONDARY_SM, Status } from "@/components/staff/ui";
const NAMES: Record<Dashboard, string> = { team: "Team", mod: "Moderation" };
const NAMES = STAFF_TABS;
const TITLES: Record<string, [string, string]> = {
team: ["Team", "Team dashboard"],
mod: ["Moderation", "Moderation dashboard"],
setup: ["Staff", "Your staff password"],
help: ["Help", "How this works"],
"": ["Horizon Team and Mods", "Staff dashboards"],
"staff-help": ["Staff help", "How this works"],
"sign-in": ["Staff", "Staff sign-in"],
};
const isDashboard = (v: string): v is Dashboard => v === "team" || v === "mod";
const never = () => () => {};
@@ -23,7 +24,7 @@ function readSetupToken() {
try {
if (window.location.hash.length > 1) {
sessionStorage.setItem("staff:setup", window.location.hash.slice(1));
window.history.replaceState(null, "", "/staff/setup");
window.history.replaceState(null, "", "/hub/setup");
}
return sessionStorage.getItem("staff:setup") ?? "";
} catch {
@@ -38,11 +39,11 @@ function forgetSetupToken() {
}
function Header({ view, message }: { view: string; message?: string }) {
const [kicker, title] = TITLES[view] ?? TITLES[""];
const [kicker, title] = TITLES[view] ?? TITLES["sign-in"];
return (
<div className="mb-8">
<div>
<p className="text-sm font-medium text-white/60">
Staff <span className="mx-1.5 text-white/30">·</span> {kicker}
Hub <span className="mx-1.5 text-white/30">·</span> {kicker}
</p>
<h1 className="mt-3 font-display text-[clamp(2rem,5vw,3.25rem)] leading-[1.05] font-semibold tracking-[-0.03em] text-white">{title}</h1>
{message && (
@@ -67,6 +68,7 @@ function Login({ onSignedIn }: { onSignedIn: (who: Who) => void }) {
try {
const who = await api<Who>("POST", "login", { name, password: password.value });
setCsrf(who.csrf);
rememberStaff(who.dashboards);
form.reset();
setStatus("");
onSignedIn(who);
@@ -132,8 +134,9 @@ function Setup() {
const signed = await api<Who>("POST", "setup", { token, password });
forgetSetupToken();
setCsrf(signed.csrf);
rememberStaff(signed.dashboards);
form.reset();
router.replace(`/staff/${signed.dashboards[0] ?? ""}`);
router.replace(`/hub/${signed.dashboards[0] ?? "sign-in"}`);
} catch (err) {
setStatus((err as Error).message);
setBusy(false);
@@ -141,9 +144,8 @@ function Setup() {
};
if (token === null) return <p className="text-sm text-white/50">Checking your link…</p>;
const intro = !token
? "This page needs the link Kira DMs you. Run /staff password on Discord to get one."
: error || (!link ? "Checking your link…" : null);
const intro =
error || (!token ? "This page needs the link Kira DMs you. Run /staff password on Discord to get one." : !link ? "Checking your link…" : null);
if (intro) return <p className="max-w-xl text-[15px] leading-relaxed text-white/70">{intro}</p>;
return (
<section aria-labelledby="set-password" className="max-w-md">
@@ -190,7 +192,7 @@ function Help() {
<section className="max-w-3xl">
<p className="text-lg leading-relaxed text-white/70">
The Horizon Team and the Mods run these two dashboards together. Here is how they work.{" "}
<Link href="/staff" className={LINK}>
<Link href="/hub/sign-in" className={LINK}>
Back to the dashboards
</Link>
.
@@ -292,22 +294,7 @@ function Help() {
function Signed({ view, who, onSignedOut }: { view: Dashboard; who: Who; onSignedOut: (all: boolean) => void }) {
return (
<>
<div className="mb-8 flex flex-col gap-4 rounded-2xl bg-[#18112c] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Dashboards" className="flex gap-1">
{who.dashboards.length > 1 &&
who.dashboards.map((d) => (
<Link
key={d}
href={`/staff/${d}`}
aria-current={d === view ? "page" : undefined}
className={`inline-flex h-9 items-center rounded-lg px-3.5 text-sm font-medium transition-colors ${
d === view ? "bg-white text-[#100a1f]" : "text-white/65 hover:bg-white/[0.08] hover:text-white"
}`}
>
{NAMES[d]}
</Link>
))}
</nav>
<div className="mb-8 flex flex-col gap-4 rounded-2xl bg-[#18112c] px-4 py-3 sm:flex-row sm:items-center sm:justify-end">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-white/60">Signed in as {who.name}</span>
<button type="button" onClick={() => onSignedOut(false)} className={SECONDARY_SM}>
@@ -324,7 +311,7 @@ function Signed({ view, who, onSignedOut }: { view: Dashboard; who: Who; onSigne
{who.dashboards.map((d, i) => (
<span key={d}>
{i > 0 && " or "}
<Link href={`/staff/${d}`} className={LINK}>
<Link href={`/hub/${d}`} className={LINK}>
Open the {NAMES[d]} dashboard
</Link>
</span>
@@ -344,29 +331,34 @@ export function StaffApp({ view }: { view: string }) {
const router = useRouter();
const [who, setWho] = useState<Who | null | undefined>(undefined);
const [message, setMessage] = useState("");
const needsSession = view !== "setup" && view !== "help";
const needsSession = view !== "setup" && view !== "staff-help";
const hinted = useStaffTabs();
useEffect(() => {
if (!needsSession) return;
api<Who>("GET", "me").then(
(w) => {
setCsrf(w.csrf);
rememberStaff(w.dashboards);
setWho(w);
},
(e: ApiError) => {
if (e.status === 401) setWho(null);
if (e.status === 401) {
rememberStaff([]);
setWho(null);
}
else setMessage(e.message);
},
);
}, [needsSession]);
useEffect(() => {
if (who && !isDashboard(view) && who.dashboards[0]) router.replace(`/staff/${who.dashboards[0]}`);
if (who && !isDashboard(view) && who.dashboards[0]) router.replace(`/hub/${who.dashboards[0]}`);
}, [who, view, router]);
useEffect(() => {
const [, title] = TITLES[view] ?? TITLES[""];
document.title = `${title} · Horizon`;
const [, title] = TITLES[view] ?? TITLES["sign-in"];
document.title = `${title} · Horizon hub`;
}, [view]);
const signOut = async (all: boolean) => {
@@ -374,12 +366,13 @@ export function StaffApp({ view }: { view: string }) {
await api("POST", all ? "logout-all" : "logout");
} catch {}
setCsrf("");
rememberStaff([]);
setWho(null);
setMessage(all ? "Signed out on every device." : "Signed out.");
};
let body: ReactNode;
if (view === "help") body = <Help />;
if (view === "staff-help") body = <Help />;
else if (view === "setup") body = <Setup />;
else if (message && who === undefined) body = null;
else if (who === undefined) body = <p className="text-sm text-white/50">Checking your session…</p>;
@@ -396,9 +389,12 @@ export function StaffApp({ view }: { view: string }) {
else body = <Signed view={view} who={who} onSignedOut={signOut} />;
return (
<main className="mx-auto w-full max-w-6xl px-6 pt-32 pb-24 sm:pt-36 lg:px-10">
<Header view={view} message={message} />
{body}
<main>
<div className="mx-auto w-full max-w-6xl px-6 pt-32 pb-8 sm:pt-36 lg:px-10">
<Header view={view} message={message} />
</div>
<HubStaffBar dashboards={who ? who.dashboards : hinted} current={view} />
<div className="mx-auto w-full max-w-6xl px-6 pt-10 pb-24 lg:px-10">{body}</div>
</main>
);
}
+9 -9
View File
@@ -282,9 +282,9 @@ function Board({ ctx }: { ctx: Ctx }) {
<h2 className="font-display text-2xl font-semibold tracking-[-0.02em] text-white">Work board</h2>
<Note>
Kira&apos;s board from GitHub, as on{" "}
<a href="/hub" className={LINK}>
<Link href="/hub" className={LINK}>
the hub
</a>{" "}
</Link>{" "}
(read-only here: move cards with /move on Discord or the MCP tools).
</Note>
<Guard what="Kira's work board">
@@ -305,9 +305,9 @@ function Board({ ctx }: { ctx: Ctx }) {
{cards.length > 12 && (
<p className="mt-2 text-xs text-white/45">
and {cards.length - 12} more on{" "}
<a href="/hub" className={LINK}>
<Link href="/hub" className={LINK}>
the hub
</a>
</Link>
</p>
)}
</section>
@@ -463,9 +463,9 @@ function PublicItemRow({ it, ctx }: { it: PublicItem; ctx: Ctx }) {
return (
<li className="py-3">
<p className="text-[15px]">
<a href="/hub" className="text-white hover:underline">
<Link href="/hub" className="text-white hover:underline">
{it.title}
</a>{" "}
</Link>{" "}
<span className="text-sm text-white/45">{area}</span>
{t && (
<span className="text-sm text-white/45">
@@ -525,9 +525,9 @@ function Roadmap({ ctx }: { ctx: Ctx }) {
<h2 className="font-display text-2xl font-semibold tracking-[-0.02em] text-white">Public roadmap</h2>
<Note>
What everyone sees on{" "}
<a href="/hub" className={LINK}>
<Link href="/hub" className={LINK}>
the hub
</a>
</Link>
, from roadmap/roadmap.toml{r.available === false ? " (not loaded right now)" : ""}. The Team&apos;s owner, priority and notes on each item stay
here.
</Note>
@@ -852,7 +852,7 @@ export function TeamDashboard({ who }: { who: Who }) {
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs tabs={tabs} active={tab} onSelect={selectTab} label="Team dashboard" />
<Link href="/staff/help" className="text-sm text-white/55 hover:text-white">
<Link href="/hub/staff-help" className="text-sm text-white/55 hover:text-white">
How this works
</Link>
</div>
+5 -1
View File
@@ -15,6 +15,10 @@ const nextConfig: NextConfig = {
{ source: "/server", destination: "/docs/hosting", permanent: true },
{ source: "/server.html", destination: "/docs/hosting", permanent: true },
{ source: "/roadmap", destination: "/hub#roadmap", permanent: true },
// The staff dashboards moved into the hub (2026-11-01). Browsers keep the #fragment of old setup links across the redirect.
{ source: "/staff", destination: "/hub/sign-in", permanent: true },
{ source: "/staff/help", destination: "/hub/staff-help", permanent: true },
{ source: "/staff/:view(team|mod|setup)", destination: "/hub/:view", permanent: true },
];
},
async headers() {
@@ -27,8 +31,8 @@ const nextConfig: NextConfig = {
{ key: "X-Content-Type-Options", value: "nosniff" },
];
return [
{ source: "/staff", headers: privateHeaders },
{ source: "/staff/:path*", headers: privateHeaders },
{ source: "/hub/:view(team|mod|sign-in|setup|staff-help)", headers: privateHeaders },
{ source: "/apply", headers: privateHeaders },
{ source: "/mods/submit", headers: [{ key: "X-Frame-Options", value: "DENY" }, { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }] },
];