Separate admin page

This commit is contained in:
Kovács Attila 2026-09-06 03:46:50 -07:00
parent cf37ccc649
commit 0efe631963
5 changed files with 69 additions and 13 deletions

View File

@ -19,6 +19,9 @@ const (
func pageTitle(s string) string { return s + " · Szabadpálya" } func pageTitle(s string) string { return s + " · Szabadpálya" }
// adminEntryPath must match ADMIN_ENTRY_PATH in web/src/lib/admin-entry.ts.
const adminEntryPath = "/admin-belepes"
// bootstrap is what gets inlined into the document so the first route does not // bootstrap is what gets inlined into the document so the first route does not
// have to fetch what the server already loaded to build the meta tags. // have to fetch what the server already loaded to build the meta tags.
type bootstrap struct { type bootstrap struct {
@ -74,6 +77,22 @@ func (s *Server) document(c *gin.Context) {
case strings.HasPrefix(path, "/kampanyok/"): case strings.HasPrefix(path, "/kampanyok/"):
s.campaignDocument(c, strings.TrimPrefix(path, "/kampanyok/")) s.campaignDocument(c, strings.TrimPrefix(path, "/kampanyok/"))
// The way in for an admin. It is the landing page — the client only adds a
// sign-in link to the header — so it loads exactly what "/" loads. Not
// linked from anywhere public, hence noindex; the actual gate is still the
// magic link and the admin allowlist, never this path being hard to guess.
case path == adminEntryPath:
stats, err := s.store.PlatformStats(ctx)
if err != nil {
s.failDocument(c, err)
return
}
c.Header("X-Robots-Tag", "noindex")
s.render(c, http.StatusOK, shell.Document{
Title: siteTitle,
Description: siteDesc,
}, bootstrap{Route: "home", Data: gin.H{"stats": stats}})
case path == "/belepes": case path == "/belepes":
s.render(c, http.StatusOK, shell.Document{ s.render(c, http.StatusOK, shell.Document{
Title: pageTitle("Belépés"), Title: pageTitle("Belépés"),

View File

@ -472,6 +472,25 @@ check_404() {
} }
step "unknown routes are 404" check_404 step "unknown routes are 404" check_404
# The admin's way in is the landing page with a sign-in link. Nothing links to
# it, so the only thing holding it up is the server case that answers it: every
# unrecognised path renders a 404 document, and it would fall into that.
check_admin_entry() {
local out
out=$(curl -s -o /dev/null -D - -w '%{http_code}' "http://localhost:${APP_PORT}/admin-belepes")
case "$out" in
*200) ;;
*) echo "the admin entry page returned something other than 200"; return 1 ;;
esac
printf '%s' "$out" | grep -qi 'X-Robots-Tag: *noindex' \
|| { echo "the admin entry page is missing its noindex header"; return 1; }
# And it must not be advertised on the public landing page.
curl -s -D - -o /dev/null "http://localhost:${APP_PORT}/" | grep -qi 'X-Robots-Tag' \
&& { echo "the landing page should not be noindex"; return 1; }
return 0
}
step "admin entry page is served and unindexed" check_admin_entry
# A draft campaign must be as invisible as one that does not exist. # A draft campaign must be as invisible as one that does not exist.
check_draft_hidden() { check_draft_hidden() {
local code local code

View File

@ -1,7 +1,8 @@
import { Link } from "react-router"; import { Link, useLocation } from "react-router";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { useSession } from "@/lib/use-session"; import { useSession } from "@/lib/use-session";
import { ADMIN_ENTRY_PATH } from "@/lib/admin-entry";
const nav = [ const nav = [
{ href: "/kampanyok", label: "Projektek" }, { href: "/kampanyok", label: "Projektek" },
@ -11,6 +12,7 @@ const nav = [
export function SiteHeader() { export function SiteHeader() {
const { isAdmin: admin } = useSession(); const { isAdmin: admin } = useSession();
const onAdminEntry = useLocation().pathname === ADMIN_ENTRY_PATH;
return ( return (
<header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center gap-7 px-5 py-3.5"> <div className="mx-auto flex max-w-6xl items-center gap-7 px-5 py-3.5">
@ -30,12 +32,18 @@ export function SiteHeader() {
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<ThemeToggle /> <ThemeToggle />
<Link {/* Signing in does nothing for a visitor: a session only unlocks
to={admin ? "/admin" : "/belepes"} /api/admin/*, and backing a campaign is planned to need no
className="hidden text-sm font-medium text-muted transition-colors hover:text-fg sm:block" account at all. So the link is offered to an admin, or on the
> admin entry page, and to nobody else. */}
{admin ? "Admin" : "Belépés"} {(admin || onAdminEntry) && (
</Link> <Link
to={admin ? "/admin" : "/belepes"}
className="hidden text-sm font-medium text-muted transition-colors hover:text-fg sm:block"
>
{admin ? "Admin" : "Belépés"}
</Link>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@ -0,0 +1,9 @@
/**
* The unadvertised way in for an admin: the landing page with a sign-in link in
* the header. Nothing public links to it.
*
* This is a convenience, not a control — the gate is the magic link and the
* admin allowlist. Must match adminEntryPath in api/internal/httpapi/document.go,
* which serves the document for it.
*/
export const ADMIN_ENTRY_PATH = "/admin-belepes";

View File

@ -3,6 +3,7 @@ import { ApiError, api, type AdminCampaign, type Owner } from "./lib/api";
import { takeBootstrap } from "./lib/bootstrap"; import { takeBootstrap } from "./lib/bootstrap";
import type { Campaign, CampaignSummary } from "./lib/types"; import type { Campaign, CampaignSummary } from "./lib/types";
import type { PlatformStats } from "./lib/api"; import type { PlatformStats } from "./lib/api";
import { ADMIN_ENTRY_PATH } from "./lib/admin-entry";
import { RootLayout } from "./routes/RootLayout"; import { RootLayout } from "./routes/RootLayout";
import { AdminLayout } from "./routes/admin/AdminLayout"; import { AdminLayout } from "./routes/admin/AdminLayout";
import Home from "./routes/Home"; import Home from "./routes/Home";
@ -14,6 +15,9 @@ import AdminHome from "./routes/admin/AdminHome";
import EditCampaign from "./routes/admin/EditCampaign"; import EditCampaign from "./routes/admin/EditCampaign";
/** The landing page shows the headline numbers, not the campaigns themselves. */ /** The landing page shows the headline numbers, not the campaigns themselves. */
const homeLoader = async (): Promise<HomeData> =>
takeBootstrap<HomeData>("home") ?? { stats: await api.stats() };
export interface HomeData { export interface HomeData {
stats: PlatformStats; stats: PlatformStats;
} }
@ -26,12 +30,9 @@ export const router = createBrowserRouter([
{ {
element: <RootLayout />, element: <RootLayout />,
children: [ children: [
{ { path: "/", element: <Home />, loader: homeLoader },
path: "/", // The same page. The header notices the path and offers a sign-in link.
element: <Home />, { path: ADMIN_ENTRY_PATH, element: <Home />, loader: homeLoader },
loader: async (): Promise<HomeData> =>
takeBootstrap<HomeData>("home") ?? { stats: await api.stats() },
},
{ {
path: "/kampanyok", path: "/kampanyok",
element: <Campaigns />, element: <Campaigns />,