updates
Server Deploy / deploy (push) Successful in 2m50s

This commit is contained in:
2026-07-24 09:24:03 +01:00
parent 693d59a3e2
commit 3b52bcbeb8
15 changed files with 527 additions and 588 deletions
+5 -2
View File
@@ -15,8 +15,11 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client
# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty
# and contact falls back to mailto while signup reports it is unavailable.
# bundle. SITE_API is the browser-reachable URL of sitesvc, which serves both
# forms. It is effectively required: leave it empty and both forms report they
# are not connected rather than submitting anywhere. Must be an origin the
# browser can reach (not the internal sitesvc:8082) and be listed in sitesvc's
# SITE_ORIGIN for CORS.
ARG NEXT_PUBLIC_SITE_API=""
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
+46 -48
View File
@@ -2,61 +2,59 @@ import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
};
const CHANNELS = [
{
title: "Support",
body: "Everything else, including anything urgent.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk",
},
{
title: "Security disclosure",
body: "Encrypted reports, acknowledged within 72 hours.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
link: "git.vantage.sh/vantage",
href: "https://git.vantage.sh/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
link: "status.vantage.sh",
href: "https://status.vantage.sh",
},
{
title: "Support",
body: "Everything else, including anything urgent.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk",
},
{
title: "Security disclosure",
body: "Encrypted reports, acknowledged within 72 hours.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
link: "git.vantage.hostxtra.co.uk/vantage",
href: "https://git.vantage.hostxtra.co.uk/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
link: "status.vantage.hostxtra.co.uk",
href: "https://status.vantage.hostxtra.co.uk",
},
];
export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Tell us what your fleet looks like.
</h1>
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Tell us what your fleet looks like.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
</div>
))}
</div>
</div>
))}
</div>
</div>
</section>
);
</section>
);
}
+28 -30
View File
@@ -5,37 +5,35 @@ import { Nav } from "@/components/Nav";
import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.sh"),
title: {
default: "Vantage — one control plane for the whole fleet",
template: "%s — Vantage",
},
description:
"Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage — one control plane for the whole fleet",
description:
"Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
},
icons: { icon: "/images/vantage_logo.svg" },
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
title: {
default: "Vantage — one control plane for the whole fleet",
template: "%s — Vantage",
},
description: "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage — one control plane for the whole fleet",
description: "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
}
+165 -195
View File
@@ -2,209 +2,179 @@ import Link from "next/link";
import { InstrumentPanel } from "@/components/InstrumentPanel";
export default function OverviewPage() {
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every
machine you manage. One agent per server, outbound connections only, all state in your own database.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
</section>
</div>
<InstrumentPanel />
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
Six tools, six sources of truth, one afternoon lost.
</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks
in a separate service, secrets in a chat thread, and no record of who ran what. None of those systems know
about each other, so every question who can reach this box, what ran on it last, is it even up gets
answered by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>
Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is
not the answer.
</p>
</div>
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every machine you manage. One agent per server, outbound connections only,
all state in your own database.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
</section>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>
Assignments, revocations, runs, console sessions and settings changes all land in the same log,
attributed to a person.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and
rewrites <code>authorized_keys</code> atomically.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
</ul>
</article>
<InstrumentPanel />
<article className="cap">
<span className="cap__k">Execution</span>
<h3>Workflows</h3>
<p>
A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into
workflows that target a set of servers.
</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
</ul>
</article>
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Six tools, six sources of truth, one afternoon lost.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks in a separate service, secrets in a chat thread, and no record of
who ran what. None of those systems know about each other, so every question who can reach this box, what ran on it last, is it even up gets answered by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is not the answer.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>Assignments, revocations, runs, console sessions and settings changes all land in the same log, attributed to a person.</p>
</div>
</div>
</div>
</div>
</section>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>
HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target
network.
</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
</ul>
</article>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and rewrites <code>authorized_keys</code> atomically.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Secrets</span>
<h3>Vault</h3>
<p>
Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never
written to logs.
</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Execution</span>
<h3>Workflows</h3>
<p>A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into workflows that target a set of servers.</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>
Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is
recorded in the audit log.
</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target network.</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>
CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can
apply from the interface.
</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
</ul>
</article>
</div>
</section>
<article className="cap">
<span className="cap__k">Secrets</span>
<h3>Vault</h3>
<p>Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never written to logs.</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
</ul>
</article>
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>
Four steps, and the first three take a minute.
</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an organisation</h3>
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
</div>
</div>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is recorded in the audit log.</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
</ul>
</article>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.sh/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.sh/install.ps1 | <b>iex</b>
</pre>
</section>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can apply from the interface.</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
</ul>
</article>
</div>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>
Create an organisation, install one agent, and watch a key land on a real box.
</p>
</div>
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</div>
</section>
</>
);
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>Four steps, and the first three take a minute.</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an organisation</h3>
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
</div>
</div>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.hostxtra.co.uk/install.ps1 | <b>iex</b>
</pre>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an organisation, install one agent, and watch a key land on a real box.</p>
</div>
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</div>
</section>
</>
);
}
+58 -64
View File
@@ -2,74 +2,68 @@ import type { Metadata } from "next";
import { OrgForm } from "@/components/OrgForm";
export const metadata: Metadata = {
title: "Create organisation",
description:
"An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
title: "Create organisation",
description: "An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
};
export default function StartPage() {
return (
<section className="rail band band--open">
<div className="split">
<div>
<span className="tag">Create organisation</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Set up your organisation.
</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any
other organisation. Confirm your email and it is created with you as its owner.
</p>
return (
<section className="rail band band--open">
<div className="split">
<div>
<span className="tag">Create organisation</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your organisation.</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other organisation. Confirm your email and it is created with you
as its owner.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
<OrgForm />
</div>
</div>
<div className="card" style={{ marginTop: "1.9rem" }}>
<OrgForm />
</div>
</div>
<div>
<span className="tag">What happens next</span>
<div className="specs">
<div className="spec">
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>
We send a link that works once. Your organisation is created when you open it, not before.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key</h3>
<p>
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses
duplicates.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a server</h3>
<p>Run the install command as root. It expires in an hour and works once.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it register</h3>
<p>The server moves from pending to active on first sync, usually inside 30 seconds.</p>
</div>
</div>
</div>
<div>
<span className="tag">What happens next</span>
<div className="specs">
<div className="spec">
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>We send a link that works once. Your organisation is created when you open it, not before.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key</h3>
<p>
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses duplicates.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a server</h3>
<p>Run the install command as root. It expires in an hour and works once.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it register</h3>
<p>The server moves from pending to active on first sync, usually inside 30 seconds.</p>
</div>
</div>
</div>
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.sh/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
</div>
</section>
);
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
</div>
</section>
);
}
+85 -103
View File
@@ -7,114 +7,96 @@ import { submitSignup, type SubmitResult } from "@/lib/submit";
const MIN_PASSWORD = 12;
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function OrgForm() {
const [slug, setSlug] = useState("");
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
const [slug, setSlug] = useState("");
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitSignup({
org_name: String(data.get("org_name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitSignup({
org_name: String(data.get("org_name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
}),
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its owner. The link works once and expires in 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its
owner. The link works once and expires in 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact
support@hostxtra.co.uk.
</p>
</div>
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="o-org">Organisation name</label>
<input id="o-org" name="org_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-org-err" />
<span className="hostline">
<b>{slug || "your-org"}</b>.vantage.hostxtra.co.uk
</span>
{fieldError("org_name") && (
<small id="o-org-err" className="field__err">
{fieldError("org_name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-email">Owner email</label>
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
<small>You become the first owner and can invite the rest of the team afterwards.</small>
{fieldError("email") && (
<small id="o-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-pass">Password</label>
<input id="o-pass" name="password" type="password" autoComplete="new-password" minLength={MIN_PASSWORD} required aria-describedby="o-pass-err" />
<small>At least {MIN_PASSWORD} characters. Use a manager you are about to manage SSH keys with it.</small>
{fieldError("password") && (
<small id="o-pass-err" className="field__err">
{fieldError("password")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Create organisation"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="o-org">Organisation name</label>
<input
id="o-org"
name="org_name"
type="text"
placeholder="Northgate Systems"
required
onChange={(e) => setSlug(slugify(e.target.value))}
aria-describedby="o-org-err"
/>
<span className="hostline">
<b>{slug || "your-org"}</b>.vantage.sh
</span>
{fieldError("org_name") && (
<small id="o-org-err" className="field__err">
{fieldError("org_name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-email">Owner email</label>
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
<small>You become the first owner and can invite the rest of the team afterwards.</small>
{fieldError("email") && (
<small id="o-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-pass">Password</label>
<input
id="o-pass"
name="password"
type="password"
autoComplete="new-password"
minLength={MIN_PASSWORD}
required
aria-describedby="o-pass-err"
/>
<small>At least {MIN_PASSWORD} characters. Use a manager you are about to manage SSH keys with it.</small>
{fieldError("password") && (
<small id="o-pass-err" className="field__err">
{fieldError("password")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Create organisation"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
+38 -58
View File
@@ -14,74 +14,54 @@ const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostx
export type SubmitState = "idle" | "sending" | "sent" | "error";
export type SubmitResult = {
state: SubmitState;
/** Message to show when the submission was refused. */
message?: string;
/** Per-field messages, keyed by field name. */
fields?: Record<string, string>;
state: SubmitState;
/** Message to show when the submission was refused. */
message?: string;
/** Per-field messages, keyed by field name. */
fields?: Record<string, string>;
};
type FieldProblem = { field: string; message: string };
async function post(url: string, payload: unknown): Promise<SubmitResult> {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) return { state: "sent" };
if (res.ok) return { state: "sent" };
const data = await res.json().catch(() => null);
const problems: FieldProblem[] = data?.fields ?? [];
const data = await res.json().catch(() => null);
const problems: FieldProblem[] = data?.fields ?? [];
return {
state: "error",
message: data?.error ?? "That did not go through. Try again in a moment.",
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
};
} catch {
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
}
return {
state: "error",
message: data?.error ?? "That did not go through. Try again in a moment.",
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
};
} catch {
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
}
}
export async function submitContact(fields: {
name: string;
email: string;
servers: string;
topic: string;
message: string;
website: string;
}): Promise<SubmitResult> {
if (!SITE_API) {
const body = [
`Name: ${fields.name}`,
`Email: ${fields.email}`,
`Servers: ${fields.servers}`,
`Topic: ${fields.topic}`,
"",
fields.message,
].join("\n");
window.location.href = `mailto:${FALLBACK_ADDRESS}?subject=${encodeURIComponent(
`Vantage enquiry — ${fields.topic}`
)}&body=${encodeURIComponent(body)}`;
return { state: "sent" };
}
return post(`${SITE_API}/api/contact`, fields);
export async function submitContact(fields: { name: string; email: string; servers: string; topic: string; message: string; website: string }): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
message: `The contact form is not connected yet. Email ${FALLBACK_ADDRESS} directly.`,
};
}
return post(`${SITE_API}/api/contact`, fields);
}
export async function submitSignup(fields: {
org_name: string;
email: string;
password: string;
website: string;
}): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
};
}
return post(`${SITE_API}/api/signup`, fields);
export async function submitSignup(fields: { org_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
};
}
return post(`${SITE_API}/api/signup`, fields);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.