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 ( +