Separate admin page
This commit is contained in:
parent
cf37ccc649
commit
0efe631963
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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 />,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue