From 7a8e683d9935caaa1190aa28623e62ddc884fcce Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:00:54 +0100 Subject: [PATCH] feat(adminsite): session guards and the auth screens Route-group layouts do the guarding. A customer session on /staff/* is redirected to its own home rather than shown a refusal -- there is nothing to tell them about. This is UX only: admin enforces the same boundary with RequireStaff/RequireCustomer and answers 404 rather than 403 for another account's data, which is the layer that actually matters. Signup carries the honeypot the backend expects and reports "check your email" rather than claiming an account exists, matching a backend that creates nothing until the link is opened. Buttons match site/'s .btn--solid and .btn--line, neutral border included. Co-Authored-By: Claude Opus 5 --- adminsite/app/(customer)/layout.tsx | 25 +++++++ adminsite/app/(staff)/staff/layout.tsx | 29 ++++++++ adminsite/app/login/page.tsx | 89 +++++++++++++++++++++++++ adminsite/app/not-found.tsx | 15 +++++ adminsite/app/signup/page.tsx | 92 ++++++++++++++++++++++++++ adminsite/app/verify/page.tsx | 62 +++++++++++++++++ adminsite/components/Button.tsx | 25 +++++++ adminsite/components/Field.tsx | 27 ++++++++ adminsite/lib/session.test.tsx | 45 +++++++++++++ adminsite/lib/session.tsx | 47 +++++++++++++ 10 files changed, 456 insertions(+) create mode 100644 adminsite/app/(customer)/layout.tsx create mode 100644 adminsite/app/(staff)/staff/layout.tsx create mode 100644 adminsite/app/login/page.tsx create mode 100644 adminsite/app/not-found.tsx create mode 100644 adminsite/app/signup/page.tsx create mode 100644 adminsite/app/verify/page.tsx create mode 100644 adminsite/components/Button.tsx create mode 100644 adminsite/components/Field.tsx create mode 100644 adminsite/lib/session.test.tsx create mode 100644 adminsite/lib/session.tsx diff --git a/adminsite/app/(customer)/layout.tsx b/adminsite/app/(customer)/layout.tsx new file mode 100644 index 0000000..edc19f6 --- /dev/null +++ b/adminsite/app/(customer)/layout.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { RequireKind } from "@/lib/session"; + +export default function CustomerLayout({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+
+ ); +} diff --git a/adminsite/app/(staff)/staff/layout.tsx b/adminsite/app/(staff)/staff/layout.tsx new file mode 100644 index 0000000..2e8c8ce --- /dev/null +++ b/adminsite/app/(staff)/staff/layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Link from "next/link"; +import { RequireKind } from "@/lib/session"; + +const LINKS = [ + ["/staff", "Operations"], + ["/staff/accounts", "Accounts"], + ["/staff/licenses", "Licences"], + ["/staff/plans", "Plans"], + ["/staff/audit", "Audit"], +] as const; + +export default function StaffLayout({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+
+ ); +} diff --git a/adminsite/app/login/page.tsx b/adminsite/app/login/page.tsx new file mode 100644 index 0000000..dc30b16 --- /dev/null +++ b/adminsite/app/login/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import Link from "next/link"; +import { API_BASE, ApiError, NotConnected, api } from "@/lib/api"; +import { NotConnectedPanel } from "@/components/NotConnected"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [staff, setStaff] = useState(false); + const [error, setError] = useState(null); + const [offline, setOffline] = useState(false); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const s = staff + ? await api.staffLogin(email, password) + : await api.login(email, password); + router.replace(s.kind === "staff" ? "/staff" : "/"); + } catch (err) { + if (err instanceof NotConnected) setOffline(true); + else if (err instanceof ApiError) setError(err.message); + else setError("Sign in failed. Try again."); + } finally { + setBusy(false); + } + } + + if (offline) + return ( +
+ +
+ ); + + return ( +
+

Sign in

+
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + error={error ?? undefined} + /> + +
+ + + Create an account for a self-hosted licence + +
+ +
+ ); +} + +function Main({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/adminsite/app/not-found.tsx b/adminsite/app/not-found.tsx new file mode 100644 index 0000000..b262a3c --- /dev/null +++ b/adminsite/app/not-found.tsx @@ -0,0 +1,15 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

Nothing here

+

+ That page does not exist, or it belongs to an account you are not signed in to. +

+ + Back to your account + +
+ ); +} diff --git a/adminsite/app/signup/page.tsx b/adminsite/app/signup/page.tsx new file mode 100644 index 0000000..7761171 --- /dev/null +++ b/adminsite/app/signup/page.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { ApiError, NotConnected, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +export default function SignupPage() { + const [form, setForm] = useState({ name: "", email: "", password: "", website: "" }); + const [state, setState] = useState<"idle" | "busy" | "sent">("idle"); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setState("busy"); + setError(null); + try { + await api.signup(form); + setState("sent"); + } catch (err) { + setState("idle"); + setError( + err instanceof NotConnected + ? "The licensing service is not reachable from this page." + : err instanceof ApiError + ? err.message + : "Could not create the account. Try again.", + ); + } + } + + return ( +
+ {state === "sent" ? ( +
+

Check your email

+

+ We sent a link to {form.email}. Open it to finish setting up your account — + it expires in 24 hours. Nothing is created until you do. +

+
+ ) : ( + <> +

Create an account

+

+ For self-hosted licences. If you run on our cloud, sign in with the same + details you use for your Vantage instance. +

+
+ setForm({ ...form, name: e.target.value })} + /> + setForm({ ...form, email: e.target.value })} + /> + setForm({ ...form, password: e.target.value })} + error={error ?? undefined} + /> + {/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */} + setForm({ ...form, website: e.target.value })} + className="absolute left-[-9999px] h-0 w-0" + /> + + + + )} +
+ ); +} diff --git a/adminsite/app/verify/page.tsx b/adminsite/app/verify/page.tsx new file mode 100644 index 0000000..6f6d45a --- /dev/null +++ b/adminsite/app/verify/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Suspense } from "react"; +import { api } from "@/lib/api"; + +function Verify() { + const token = useSearchParams().get("token") ?? ""; + const { data, error, isLoading } = useQuery({ + queryKey: ["verify", token], + queryFn: () => api.verify(token), + enabled: token !== "", + retry: false, + }); + + if (!token) + return ( + + ); + if (isLoading) return ; + if (error || !data?.verified) + return ( + + ); + + return ( +
+

Email verified

+

Your account is ready.

+ + Sign in + +
+ ); +} + +function Message({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

+
+ ); +} + +export default function VerifyPage() { + return ( +
+ + + +
+ ); +} diff --git a/adminsite/components/Button.tsx b/adminsite/components/Button.tsx new file mode 100644 index 0000000..a388377 --- /dev/null +++ b/adminsite/components/Button.tsx @@ -0,0 +1,25 @@ +import clsx from "clsx"; + +type Props = React.ButtonHTMLAttributes & { variant?: "solid" | "line" }; + +/* + * Matches site/'s .btn--solid and .btn--line exactly, including the neutral + * border on the secondary variant. site/ does not have an accent-outlined + * button and this app should not invent one. + */ +export function Button({ variant = "solid", className, ...rest }: Props) { + return ( +