docs(plan): match the admin site to site/'s design tokens
Replaces the petrol-and-serif direction with site/app/globals.css's token set copied verbatim: brand navy accent, the same neutrals, the same clamp type scale, 1200px rail, 4px radii, and site/'s heading treatment of the sans at weight 800 rather than a serif display face. The semantic three are aliased rather than renamed -- globals.css carries site/'s --up/--pend/--down, and Tailwind exposes them as valid/warn/expired so each app names them for what it actually shows. Same colours either way. Tailwind now holds var() references only, so no component or config can carry a hex value and drift. Buttons match site/'s .btn--solid and .btn--line, including the neutral border on the secondary variant. Records the caveat this creates: site/'s dark accent (#5b9be8) sits nearer web/'s indigo than the light navy does, so the "which app am I in" cue rests on the light ground and dark must not become the default. Drops the guilloche ornament -- site/ carries nothing like it. The ledger stays, because it is information design rather than decoration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,11 @@
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Design tokens are fixed by the approved direction.** Light ground primary with full dark support, petrol accent `#0d5f6e` / `#5cc6d8`, semantic `--valid #2f8a60` / `--warn #a4761b` / `--expired #c6462f` carried over from `site/`. Semantic hues are **never** reused as an accent, and the accent is never green, amber, red or indigo — indigo belongs to `web/`.
|
||||
- **Type roles:** serif display (`"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`) for page titles, counts and the wordmark only; system sans for UI; mono with `tabular-nums` for every UUID, timestamp, blob and limit.
|
||||
- **The token set is `site/`'s, copied verbatim.** `adminsite/app/globals.css` carries the same custom properties, with the same names and the same hex values, as `site/app/globals.css` — brand navy accent `#0b2a58` light / `#5b9be8` dark, `--up #2f8a60` / `--down #c6462f` / `--pend #b0801f`, the same `--shadow`, the same `--s--1`…`--s-4` clamp scale, `--rail: 1200px`. **When `site/`'s tokens change, change these in the same commit** — they are one visual system in two apps, and there is nothing that enforces the match automatically.
|
||||
- **Tailwind maps those variables, it does not redefine them.** Colours in `tailwind.config.ts` are `var(--…)` references only. Never write a hex value in a component or in the Tailwind config.
|
||||
- **Licence state uses `site/`'s semantic tokens.** Tailwind exposes them as `valid`/`warn`/`expired`, aliased onto `--up`/`--pend`/`--down` so this app names them for what it means while staying the same three colours the marketing site uses for up, pending and down. Semantic hues are **never** reused as an accent.
|
||||
- **Type roles:** `--sans` for everything, `--mono` with `tabular-nums` for every UUID, timestamp, blob and limit. **There is no display face** — matching `site/` means headings are the sans at `font-weight: 800`, `letter-spacing: -0.03em`, `line-height: 1.03`.
|
||||
- **Distinctness from `web/` rests on ground and hue together.** `web/` is locked to dark with an indigo `#6366f1` accent; this app defaults to light with navy. Note the caveat: in dark mode `site/`'s accent lifts to `#5b9be8`, which is nearer web/'s indigo, so the "which app am I in" cue leans on the light ground. Do not make dark the default.
|
||||
- **State never reads by colour alone.** Every licence state renders as a stripe, a shaped-and-labelled pill, and copy.
|
||||
- **Served at `vantage-hq.hostxtra.co.uk`, published on `3004`.** Spec 4 said 3002, but `deploy/docker-compose.site.yml` now maps `site` to `3002:3000`. Container port stays `3000`. Note the host is deliberately *not* under `*.vantage.hostxtra.co.uk`: that namespace is per-tenant instance subdomains, and the control plane's `APP_ROOT_LABEL` guard resolves an org from the label before `vantage`. A host like `hq.vantage.hostxtra.co.uk` would look like a tenant slug called `hq`.
|
||||
- **`ADMIN_ORIGIN` must contain `https://vantage-hq.hostxtra.co.uk`** exactly — scheme included, no trailing slash. Admin echoes only origins on that list, so a mismatch blocks every browser request while curl from the server keeps working, which is what makes it confusing to diagnose.
|
||||
@@ -75,7 +78,7 @@ Paddle portal deep-links are spec 5's. Until then the billing screen renders the
|
||||
| `adminsite/lib/session.ts` | `useSession`, `useRequireKind` |
|
||||
| `adminsite/lib/query-client.ts` | TanStack config, mirrors `web/` |
|
||||
| `adminsite/lib/format.ts` | dates, days-remaining, licence state derivation |
|
||||
| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Guilloche`, `Field`, `Button`, `ConfirmPlanChange` |
|
||||
| `adminsite/components/*` | `EnvBadge`, `StatePill`, `InstanceCard`, `Ledger`, `NotConnected`, `Field`, `Button`, `Queue`, `ConfirmPlanChange` |
|
||||
|
||||
**Modified:** `admin/internal/api/routes.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/auth/customer.go`, `admin/internal/models/models.go`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`.
|
||||
|
||||
@@ -469,7 +472,7 @@ git commit -m "feat(admin): staff instance detail, subscriptions and richer sear
|
||||
- Create: `adminsite/package.json`, `adminsite/tsconfig.json`, `adminsite/next.config.ts`, `adminsite/postcss.config.js`, `adminsite/tailwind.config.ts`, `adminsite/app/globals.css`, `adminsite/app/layout.tsx`, `adminsite/.gitignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-display font-sans font-mono`
|
||||
- Produces: Tailwind classes `bg-ground bg-panel bg-panel-2 text-ink text-ink-2 text-ink-3 border-rule border-rule-soft text-accent bg-accent text-valid text-warn text-expired`, fonts `font-sans font-mono` (no display face — headings are the sans at weight 800)
|
||||
|
||||
- [ ] **Step 1: package.json**
|
||||
|
||||
@@ -571,11 +574,21 @@ module.exports = {
|
||||
};
|
||||
```
|
||||
|
||||
`tailwind.config.ts` — colours point at CSS variables so one token set serves both themes:
|
||||
`tailwind.config.ts` — every colour is a `var()` reference, so `site/`'s token file is the single source of truth and one set serves both themes:
|
||||
|
||||
```ts
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
/*
|
||||
* Tokens are shared with site/ — same names, same values, copied verbatim into
|
||||
* app/globals.css. Nothing here may hold a hex value: if a colour needs to
|
||||
* change it changes in globals.css, in both apps, in one commit.
|
||||
*
|
||||
* The semantic three are aliased rather than renamed. site/ calls them up,
|
||||
* down and pend because it shows monitor state; this app calls them valid,
|
||||
* expired and warn because it shows licence state. Same colours, honest names
|
||||
* on both sides.
|
||||
*/
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
@@ -592,17 +605,18 @@ const config: Config = {
|
||||
accent: "var(--accent)",
|
||||
"accent-ink": "var(--accent-ink)",
|
||||
"accent-wash": "var(--accent-wash)",
|
||||
valid: "var(--valid)",
|
||||
warn: "var(--warn)",
|
||||
expired: "var(--expired)",
|
||||
archival: "var(--archival)",
|
||||
valid: "var(--up)",
|
||||
warn: "var(--pend)",
|
||||
expired: "var(--down)",
|
||||
archival: "var(--ink-3)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Iowan Old Style", "Palatino Linotype", "Palatino", "Book Antiqua", "Georgia", "serif"],
|
||||
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
|
||||
mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
|
||||
},
|
||||
borderRadius: { DEFAULT: "3px" },
|
||||
// site/ uses 4px on panels and buttons, 2px on focus rings.
|
||||
borderRadius: { DEFAULT: "4px" },
|
||||
maxWidth: { rail: "1200px" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
@@ -613,74 +627,166 @@ export default config;
|
||||
|
||||
- [ ] **Step 5: app/globals.css**
|
||||
|
||||
Copy the token block out of `site/app/globals.css` unchanged — same names, same
|
||||
values, all three theme selectors. The only addition is `--accent-wash`, which
|
||||
`site/` has no need for.
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Admin console tokens. Petrol accent, deliberately clear of indigo (web/) and
|
||||
* of the three semantic hues this app says constantly. Light is home because
|
||||
* web/ is locked to dark, and telling the two apps apart at a glance is what
|
||||
* stops a Reissue landing in the wrong tab.
|
||||
*/
|
||||
/* ==========================================================================
|
||||
Vantage admin console design tokens.
|
||||
|
||||
These are site/app/globals.css's tokens, copied verbatim: the marketing site
|
||||
and this console are one visual system. Change them in both apps in the same
|
||||
commit — nothing enforces the match automatically.
|
||||
|
||||
Light is the default because web/ is locked to dark, and telling the two
|
||||
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
|
||||
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
|
||||
distinction leans on the ground rather than the hue.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--ground: #eaeff0;
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f2f6f7;
|
||||
--ink: #0f2429;
|
||||
--ink-2: #43606a;
|
||||
--ink-3: #6d868e;
|
||||
--rule: #ccd9dc;
|
||||
--rule-soft: #dfe8ea;
|
||||
--accent: #0d5f6e;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-wash: rgba(13, 95, 110, 0.07);
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
|
||||
/* Semantic, shared with site/ so all three apps agree. Never an accent. */
|
||||
--valid: #2f8a60;
|
||||
--warn: #a4761b;
|
||||
--expired: #c6462f;
|
||||
--archival: #7d4a45;
|
||||
/* Not in site/: the hatched sandbox badge and hover washes need a tinted
|
||||
fill, and deriving it per-use would drift. */
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
|
||||
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
|
||||
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
|
||||
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
|
||||
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
|
||||
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
|
||||
|
||||
--rail: 1200px;
|
||||
}
|
||||
|
||||
/* Dark tokens are defined once and applied through three selectors: the OS
|
||||
preference, and both explicit values of data-theme so an in-page toggle wins
|
||||
in either direction. Same pattern as site/. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ground: #081a1f;
|
||||
--panel: #0e262d;
|
||||
--panel-2: #123039;
|
||||
--ink: #e2eff1;
|
||||
--ink-2: #9fbcc3;
|
||||
--ink-3: #74949c;
|
||||
--rule: #1e454f;
|
||||
--rule-soft: #17363f;
|
||||
--accent: #5cc6d8;
|
||||
--accent-ink: #04171c;
|
||||
--accent-wash: rgba(92, 198, 216, 0.1);
|
||||
--valid: #4fb484;
|
||||
--warn: #d6a63f;
|
||||
--expired: #e2705a;
|
||||
--archival: #c08a84;
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--ground);
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: var(--s-0);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* site/'s heading treatment, which is what replaces a display face. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
line-height: 1.03;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -693,6 +799,11 @@ body {
|
||||
}
|
||||
```
|
||||
|
||||
Because `h1`–`h3` carry the weight and tracking from this file, the Tailwind
|
||||
classes on headings in later tasks (`text-3xl font-extrabold tracking-[-0.03em]`)
|
||||
are belt-and-braces for elements that are not `h1`–`h3`. Leave them; they cost
|
||||
nothing and keep a `<p>` used as a title looking right.
|
||||
|
||||
- [ ] **Step 6: app/layout.tsx**
|
||||
|
||||
```tsx
|
||||
@@ -709,10 +820,10 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="font-sans text-[0.95rem] leading-relaxed">
|
||||
<body>
|
||||
<header className="border-b border-rule bg-panel">
|
||||
<div className="mx-auto flex max-w-[1180px] flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-8">
|
||||
<span className="flex items-baseline gap-2 font-display text-lg">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-8">
|
||||
<span className="flex items-baseline gap-2 text-lg font-extrabold tracking-[-0.03em]">
|
||||
<b className="font-semibold">Vantage</b>
|
||||
<span className="font-sans text-[0.72rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
Licensing
|
||||
@@ -854,7 +965,7 @@ Expected: FAIL — `Failed to resolve import "./NotConnected"`.
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
|
||||
<h2 className="font-display text-xl text-expired">
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em] text-expired">
|
||||
Not connected to the licensing service
|
||||
</h2>
|
||||
{url ? (
|
||||
@@ -1375,18 +1486,24 @@ export function EnvBadge() {
|
||||
```tsx
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "ghost" };
|
||||
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-3.5 py-2 text-[0.82rem] font-semibold",
|
||||
"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-accent bg-transparent text-accent",
|
||||
rest.disabled && "cursor-not-allowed border-rule bg-transparent text-ink-3",
|
||||
: "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,
|
||||
)}
|
||||
/>
|
||||
@@ -1470,7 +1587,7 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="font-display text-3xl font-semibold">Sign in</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
@@ -1507,7 +1624,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">{children}</main>;
|
||||
return <main className="mx-auto max-w-rail px-4 py-12 sm:px-8">{children}</main>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1546,10 +1663,10 @@ export default function SignupPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
|
||||
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="font-display text-3xl font-semibold">Check your email</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">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.
|
||||
@@ -1557,7 +1674,7 @@ export default function SignupPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-display text-3xl font-semibold">Create an account</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">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.
|
||||
@@ -1640,7 +1757,7 @@ function Verify() {
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="font-display text-3xl font-semibold">Email verified</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Email verified</h1>
|
||||
<p className="text-ink-2">Your account is ready.</p>
|
||||
<Link href="/login" className="text-accent underline">Sign in</Link>
|
||||
</div>
|
||||
@@ -1650,7 +1767,7 @@ function Verify() {
|
||||
function Message({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="font-display text-3xl font-semibold">{title}</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{title}</h1>
|
||||
<p className="text-ink-2">{body}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1658,7 +1775,7 @@ function Message({ title, body }: { title: string; body: string }) {
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
|
||||
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
|
||||
<Suspense fallback={null}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
@@ -1681,13 +1798,13 @@ 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-[1180px] flex-wrap gap-5 px-4 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em] sm:px-8">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-4 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em] sm:px-8">
|
||||
<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-[1180px] px-4 py-8 sm:px-8">{children}</main>
|
||||
<main className="mx-auto max-w-rail px-4 py-8 sm:px-8">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
@@ -1702,8 +1819,8 @@ import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[1180px] px-4 py-12 sm:px-8">
|
||||
<h1 className="font-display text-3xl font-semibold">Nothing here</h1>
|
||||
<main className="mx-auto max-w-rail px-4 py-12 sm:px-8">
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">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>
|
||||
@@ -2019,7 +2136,7 @@ export default function OverviewPage() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="font-display text-3xl font-semibold">{data.account.name}</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
|
||||
<p className="text-ink-2">{data.account.billing_email}</p>
|
||||
</header>
|
||||
|
||||
@@ -2039,7 +2156,7 @@ export default function OverviewPage() {
|
||||
|
||||
{data.instances.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="font-display text-xl">No instances yet</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
There are two ways to run Vantage. Buy a cloud instance and we host it, and
|
||||
your licence is applied automatically. Or buy a self-hosted licence, install
|
||||
@@ -2166,7 +2283,7 @@ export function LicenceDelivery({
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<h2 className="font-display text-xl">Your licence</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Your licence</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
@@ -2175,7 +2292,7 @@ export function LicenceDelivery({
|
||||
>
|
||||
Download licence
|
||||
</a>
|
||||
<Button variant="ghost" type="button" onClick={copy}>
|
||||
<Button variant="line" type="button" onClick={copy}>
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2232,7 +2349,7 @@ export function RelinkPanel({
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="font-display text-xl">Moved to a new server?</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
@@ -2249,7 +2366,7 @@ export function RelinkPanel({
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
@@ -2327,7 +2444,7 @@ export default function InstancePage() {
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-display text-3xl font-semibold">{instance.name}</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{instance.name}</h1>
|
||||
<StatePill state={state} />
|
||||
</div>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{instance.instance_id}</p>
|
||||
@@ -2585,7 +2702,7 @@ export default function LinkPage() {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="font-display text-3xl font-semibold">Link an install</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Link an install</h1>
|
||||
<p className="text-ink-2">
|
||||
Every licence is tied to one install, so we need its ID before we can issue
|
||||
yours. Paste it below and your licence is ready on the next screen.
|
||||
@@ -2639,7 +2756,7 @@ export default function BillingPage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Billing</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Billing</h1>
|
||||
|
||||
{!data || data.length === 0 ? (
|
||||
<p className="text-ink-2">
|
||||
@@ -2772,7 +2889,7 @@ export function Queue({
|
||||
return (
|
||||
<section className={clsx("grid gap-2 rounded border border-rule border-l-4 bg-panel p-4", TONE[tone])}>
|
||||
<h2 className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{title}</h2>
|
||||
<p className="font-display text-3xl leading-none tabular-nums">{count}</p>
|
||||
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">{count}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
|
||||
) : (
|
||||
@@ -2834,7 +2951,7 @@ export default function StaffDashboard() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Operations</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Operations</h1>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Queue
|
||||
title="Failed injections"
|
||||
@@ -3026,7 +3143,7 @@ import { AccountSearch } from "./AccountSearch";
|
||||
export default function AccountsPage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Accounts</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Accounts</h1>
|
||||
<AccountSearch />
|
||||
</div>
|
||||
);
|
||||
@@ -3056,7 +3173,7 @@ export default function AccountDetailPage() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-1">
|
||||
<h1 className="font-display text-3xl font-semibold">{data.account.name}</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.account.name}</h1>
|
||||
<p className="font-mono text-[0.82rem] text-ink-3">
|
||||
{data.account.billing_email} · {data.account.account_id}
|
||||
</p>
|
||||
@@ -3127,7 +3244,7 @@ export default function AccountDetailPage() {
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="font-display text-xl">{title}</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -3381,7 +3498,7 @@ export function IssuePanel({ instanceId, deployment }: { instanceId: string; dep
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button type="button" variant="ghost" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
@@ -3427,7 +3544,7 @@ export default function StaffInstancePage() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="font-display text-3xl font-semibold">{data.instance.name || data.instance.instance_id}</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">{data.instance.name || data.instance.instance_id}</h1>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">{data.instance.instance_id}</p>
|
||||
<p className="text-[0.82rem]">
|
||||
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
|
||||
@@ -3444,7 +3561,7 @@ export default function StaffInstancePage() {
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="font-display text-xl">Licence history</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Licence history</h2>
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</section>
|
||||
@@ -3491,7 +3608,7 @@ export default function LicensesPage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Licences</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Licences</h1>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="">All tiers</option>
|
||||
@@ -3564,7 +3681,7 @@ export default function AuditPage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Audit</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Audit</h1>
|
||||
<Field label="Filter" value={filter} onChange={(e) => setFilter(e.target.value)} hint="Action, actor or target." />
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
@@ -3703,7 +3820,7 @@ export function ConfirmPlanChange({
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
|
||||
<h2 className="font-display text-xl">Change what {plan.name} grants?</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">Change what {plan.name} grants?</h2>
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{rows.map((r) => (
|
||||
<li key={r.field} className="flex flex-wrap gap-2">
|
||||
@@ -3719,7 +3836,7 @@ export function ConfirmPlanChange({
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>Change plan</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>Keep as is</Button>
|
||||
<Button type="button" variant="line" onClick={onCancel}>Keep as is</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -3772,7 +3889,7 @@ export default function PlansPage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="font-display text-3xl font-semibold">Plans</h1>
|
||||
<h1 className="text-3xl font-extrabold tracking-[-0.03em]">Plans</h1>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
@@ -3787,7 +3904,7 @@ export default function PlansPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{(plans.data ?? []).map((p) => (
|
||||
<section key={p.tier} className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="font-display text-xl">{p.name}</h2>
|
||||
<h2 className="text-xl font-extrabold tracking-[-0.03em]">{p.name}</h2>
|
||||
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<div className="flex justify-between gap-2"><dt>servers</dt><dd>{limitLabel(p.limits.max_servers)}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt>secret groups</dt><dd>{limitLabel(p.limits.max_secret_groups)}</dd></div>
|
||||
@@ -3805,7 +3922,7 @@ export default function PlansPage() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({ ...p, limits: { ...p.limits, max_servers: 7 } })
|
||||
}
|
||||
@@ -3814,7 +3931,7 @@ export default function PlansPage() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="line"
|
||||
onClick={() => setDraft({ ...p, features: p.features.filter((f) => f !== "oidc") })}
|
||||
>
|
||||
Remove OIDC
|
||||
|
||||
Reference in New Issue
Block a user