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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:00:54 +01:00
co-authored by Claude Opus 5
parent 3e447fd024
commit 7a8e683d99
10 changed files with 456 additions and 0 deletions
+25
View File
@@ -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 (
<RequireKind kind="customer">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
<Link href="/" className="text-accent">
Overview
</Link>
<Link href="/instances/link" className="text-ink-3">
Link an install
</Link>
<Link href="/billing" className="text-ink-3">
Billing
</Link>
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
</RequireKind>
);
}
+29
View File
@@ -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 (
<RequireKind kind="staff">
<nav className="border-b border-rule-soft bg-panel-2">
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
{LINKS.map(([href, label]) => (
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
{label}
</Link>
))}
</div>
</nav>
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
</RequireKind>
);
}
+89
View File
@@ -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<string | null>(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 (
<Main>
<NotConnectedPanel url={API_BASE} />
</Main>
);
return (
<Main>
<h1 className="text-3xl">Sign in</h1>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
<input
type="checkbox"
checked={staff}
onChange={(e) => setStaff(e.target.checked)}
/>
I work at Vantage
</label>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
<Link href="/signup" className="text-[0.82rem] text-accent underline">
Create an account for a self-hosted licence
</Link>
</div>
</form>
</Main>
);
}
function Main({ children }: { children: React.ReactNode }) {
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
}
+15
View File
@@ -0,0 +1,15 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<h1 className="text-3xl">Nothing here</h1>
<p className="mt-2 text-ink-2">
That page does not exist, or it belongs to an account you are not signed in to.
</p>
<Link href="/" className="mt-4 inline-block text-accent underline">
Back to your account
</Link>
</main>
);
}
+92
View File
@@ -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<string | null>(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 (
<main className="mx-auto max-w-rail px-5 py-12">
{state === "sent" ? (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Check your email</h1>
<p className="text-ink-2">
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.
</p>
</div>
) : (
<>
<h1 className="text-3xl">Create an account</h1>
<p className="mt-2 max-w-xl text-ink-2">
For self-hosted licences. If you run on our cloud, sign in with the same
details you use for your Vantage instance.
</p>
<form onSubmit={submit} className="mt-6 grid gap-4">
<Field
label="Organisation"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Field
label="Email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
<Field
label="Password"
type="password"
required
minLength={12}
hint="At least 12 characters."
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
error={error ?? undefined}
/>
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
className="absolute left-[-9999px] h-0 w-0"
/>
<Button type="submit" disabled={state === "busy"}>
{state === "busy" ? "Creating…" : "Create account"}
</Button>
</form>
</>
)}
</main>
);
}
+62
View File
@@ -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 (
<Message
title="That link is incomplete"
body="It is missing its token. Use the link in the email exactly as sent."
/>
);
if (isLoading) return <Message title="Verifying…" body="One moment." />;
if (error || !data?.verified)
return (
<Message
title="That link is invalid or has expired"
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
/>
);
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">Email verified</h1>
<p className="text-ink-2">Your account is ready.</p>
<Link href="/login" className="justify-self-start text-accent underline">
Sign in
</Link>
</div>
);
}
function Message({ title, body }: { title: string; body: string }) {
return (
<div className="grid max-w-xl gap-3">
<h1 className="text-3xl">{title}</h1>
<p className="text-ink-2">{body}</p>
</div>
);
}
export default function VerifyPage() {
return (
<main className="mx-auto max-w-rail px-5 py-12">
<Suspense fallback={null}>
<Verify />
</Suspense>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { 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 (
<button
{...rest}
className={clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid"
? "border-accent bg-accent text-accent-ink"
: "border-rule bg-panel text-ink hover:border-ink-3",
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
className,
)}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function Field({
label,
hint,
error,
...input
}: React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
hint?: React.ReactNode;
error?: string;
}) {
return (
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</span>
<input
{...input}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
/>
{error ? (
<span className="text-[0.82rem] text-expired">{error}</span>
) : hint ? (
<span className="text-[0.82rem] text-ink-3">{hint}</span>
) : null}
</label>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "./api";
import { RequireKind } from "./session";
const replace = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace, push: vi.fn() }),
}));
const me = vi.fn();
vi.mock("./api", async () => {
const actual = await vi.importActual<typeof import("./api")>("./api");
return { ...actual, api: { ...actual.api, me: () => me() } };
});
function wrap(ui: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("RequireKind", () => {
beforeEach(() => replace.mockClear());
it("renders staff screens for a staff session", async () => {
me.mockResolvedValue({ kind: "staff", email: "s@example.com" });
wrap(<RequireKind kind="staff">operations</RequireKind>);
expect(await screen.findByText("operations")).toBeInTheDocument();
expect(replace).not.toHaveBeenCalled();
});
it("sends a customer session away from staff screens without telling it anything", async () => {
me.mockResolvedValue({ kind: "customer", email: "c@example.com", account_id: "a1" });
wrap(<RequireKind kind="staff">operations</RequireKind>);
await waitFor(() => expect(replace).toHaveBeenCalledWith("/"));
expect(screen.queryByText("operations")).not.toBeInTheDocument();
});
it("sends an unauthenticated visitor to sign in", async () => {
me.mockRejectedValue(new ApiError(401, "not signed in"));
wrap(<RequireKind kind="customer">account</RequireKind>);
await waitFor(() => expect(replace).toHaveBeenCalledWith("/login"));
});
});
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { API_BASE, ApiError, NotConnected, api, type Session } from "./api";
import { NotConnectedPanel } from "@/components/NotConnected";
export function useSession() {
const { data, error, isLoading } = useQuery<Session>({
queryKey: ["me"],
queryFn: api.me,
staleTime: 60_000,
});
return { session: data, error, isLoading };
}
/*
* The route-group guard. This is UX, not security: admin enforces the same
* boundary with RequireStaff/RequireCustomer and returns 404 rather than 403
* for another account's data. A customer hitting a staff route is redirected
* rather than shown a refusal, because there is nothing to tell them about.
*/
export function RequireKind({
kind,
children,
}: {
kind: Session["kind"];
children: React.ReactNode;
}) {
const router = useRouter();
const { session, error, isLoading } = useSession();
useEffect(() => {
if (error instanceof ApiError && error.status === 401) {
router.replace("/login");
return;
}
if (session && session.kind !== kind) {
router.replace(session.kind === "staff" ? "/staff" : "/");
}
}, [error, session, kind, router]);
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
if (isLoading || !session || session.kind !== kind) return null;
return <>{children}</>;
}