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: ,