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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user