From 0efe6319635cca39cb73da8493df97727f3a7e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20Attila?= Date: Sun, 6 Sep 2026 03:46:50 -0700 Subject: [PATCH] Separate admin page --- api/internal/httpapi/document.go | 19 +++++++++++++++++++ scripts/ci.sh | 19 +++++++++++++++++++ web/src/components/SiteHeader.tsx | 22 +++++++++++++++------- web/src/lib/admin-entry.ts | 9 +++++++++ web/src/router.tsx | 13 +++++++------ 5 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/admin-entry.ts diff --git a/api/internal/httpapi/document.go b/api/internal/httpapi/document.go index eca15f1..5f2d1db 100644 --- a/api/internal/httpapi/document.go +++ b/api/internal/httpapi/document.go @@ -19,6 +19,9 @@ const ( 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 // have to fetch what the server already loaded to build the meta tags. type bootstrap struct { @@ -74,6 +77,22 @@ func (s *Server) document(c *gin.Context) { case strings.HasPrefix(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": s.render(c, http.StatusOK, shell.Document{ Title: pageTitle("Belépés"), diff --git a/scripts/ci.sh b/scripts/ci.sh index ce35f2a..0dc660a 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -472,6 +472,25 @@ 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. check_draft_hidden() { local code diff --git a/web/src/components/SiteHeader.tsx b/web/src/components/SiteHeader.tsx index 505e0d2..d83674c 100644 --- a/web/src/components/SiteHeader.tsx +++ b/web/src/components/SiteHeader.tsx @@ -1,7 +1,8 @@ -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { Logo } from "./Logo"; import { ThemeToggle } from "./ThemeToggle"; import { useSession } from "@/lib/use-session"; +import { ADMIN_ENTRY_PATH } from "@/lib/admin-entry"; const nav = [ { href: "/kampanyok", label: "Projektek" }, @@ -11,6 +12,7 @@ const nav = [ export function SiteHeader() { const { isAdmin: admin } = useSession(); + const onAdminEntry = useLocation().pathname === ADMIN_ENTRY_PATH; return (
@@ -30,12 +32,18 @@ export function SiteHeader() {
- - {admin ? "Admin" : "Belépés"} - + {/* Signing in does nothing for a visitor: a session only unlocks + /api/admin/*, and backing a campaign is planned to need no + account at all. So the link is offered to an admin, or on the + admin entry page, and to nobody else. */} + {(admin || onAdminEntry) && ( + + {admin ? "Admin" : "Belépés"} + + )}
diff --git a/web/src/lib/admin-entry.ts b/web/src/lib/admin-entry.ts new file mode 100644 index 0000000..c003ccb --- /dev/null +++ b/web/src/lib/admin-entry.ts @@ -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"; diff --git a/web/src/router.tsx b/web/src/router.tsx index d199a1b..ae98de7 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -3,6 +3,7 @@ import { ApiError, api, type AdminCampaign, type Owner } from "./lib/api"; import { takeBootstrap } from "./lib/bootstrap"; import type { Campaign, CampaignSummary } from "./lib/types"; import type { PlatformStats } from "./lib/api"; +import { ADMIN_ENTRY_PATH } from "./lib/admin-entry"; import { RootLayout } from "./routes/RootLayout"; import { AdminLayout } from "./routes/admin/AdminLayout"; import Home from "./routes/Home"; @@ -14,6 +15,9 @@ import AdminHome from "./routes/admin/AdminHome"; import EditCampaign from "./routes/admin/EditCampaign"; /** The landing page shows the headline numbers, not the campaigns themselves. */ +const homeLoader = async (): Promise => + takeBootstrap("home") ?? { stats: await api.stats() }; + export interface HomeData { stats: PlatformStats; } @@ -26,12 +30,9 @@ export const router = createBrowserRouter([ { element: , children: [ - { - path: "/", - element: , - loader: async (): Promise => - takeBootstrap("home") ?? { stats: await api.stats() }, - }, + { path: "/", element: , loader: homeLoader }, + // The same page. The header notices the path and offers a sign-in link. + { path: ADMIN_ENTRY_PATH, element: , loader: homeLoader }, { path: "/kampanyok", element: ,