Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

953 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FormEvent, useEffect, useState } from "react";
import { Activity, SlidersHorizontal } from "lucide-react";
import { AlgaLogo } from "../AlgaLogo";
import { LogoutButton } from "../auth/LogoutButton";
import styles from "../status.module.css";
// The manual edition selector + airgap license-key entry are hidden for now: the
// install code is the only supported entitlement path (operators re-issue a fresh
// code rather than choosing an edition or pasting a signed license by hand). Kept
// in source (guarded, not deleted) so the airgap/manual flow is trivial to bring
// back — flip this to true.
const SHOW_MANUAL_EDITION_AND_LICENSE = false;
type SetupConfig = {
mode?: string;
defaults?: {
channel?: string;
appHostname?: string;
dnsMode?: string;
dnsServers?: string;
releaseRef?: string;
};
network?: {
addresses?: string[];
resolvers?: string[];
};
};
type FieldErrors = Partial<
Record<
| "installCode"
| "tenantName"
| "adminFirstName"
| "adminLastName"
| "adminEmail"
| "adminPassword"
| "adminPasswordConfirm"
| "appHostname"
| "dnsServers"
| "releaseRef"
| "licenseKey",
string
>
>;
function isValidIpv4(value: string) {
const parts = value.split(".");
return (
parts.length === 4 &&
parts.every(
(part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255,
)
);
}
function passwordValidationError(value: string) {
if (value.length < 8) return "Use at least 8 characters.";
if (!/[a-z]/.test(value)) return "Include a lowercase letter.";
if (!/[A-Z]/.test(value)) return "Include an uppercase letter.";
if (!/\d/.test(value)) return "Include a number.";
if (!/[!@#$%^&*(),.?":{}|<>]/.test(value))
return "Include a special character.";
return null;
}
function isWellFormedJwt(value: string) {
return /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(value);
}
function validateSetupForm(payload: {
installCode?: string;
tenantName: string;
adminFirstName: string;
adminLastName: string;
adminEmail: string;
adminPassword: string;
adminPasswordConfirm: string;
appHostname: string;
dnsMode: string;
dnsServers: string;
releaseRef: string;
licenseKey?: string;
}) {
const errors: FieldErrors = {};
// The install code is the only supported entitlement path (manual edition /
// airgap license entry is hidden), so it is required.
if (!payload.installCode?.trim())
errors.installCode = "Enter the install code from your registration email.";
if (!payload.tenantName.trim())
errors.tenantName = "Enter the company name for the initial tenant.";
if (!payload.adminFirstName.trim())
errors.adminFirstName = "Enter the admin first name.";
if (!payload.adminLastName.trim())
errors.adminLastName = "Enter the admin last name.";
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(payload.adminEmail.trim()))
errors.adminEmail = "Enter a valid admin email address.";
const passwordError = passwordValidationError(payload.adminPassword);
if (passwordError) errors.adminPassword = passwordError;
if (payload.adminPassword !== payload.adminPasswordConfirm)
errors.adminPasswordConfirm = "Passwords do not match.";
if (!payload.appHostname.trim()) {
errors.appHostname = "Enter the full URL users will open after setup.";
} else {
try {
const parsed = new URL(payload.appHostname.trim());
if (!["http:", "https:"].includes(parsed.protocol)) {
errors.appHostname = "Use an http or https URL.";
}
} catch {
errors.appHostname =
"Use a full URL, for example http://192.168.1.50:3000.";
}
}
if (payload.dnsMode === "custom") {
const servers = payload.dnsServers
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (servers.length === 0) {
errors.dnsServers = "Enter at least one DNS server for custom DNS.";
} else {
const invalid = servers.filter((server) => !isValidIpv4(server));
if (invalid.length)
errors.dnsServers = `Check these IPv4 addresses: ${invalid.join(", ")}.`;
}
}
if (payload.releaseRef && !/^[A-Za-z0-9._:@/-]+$/.test(payload.releaseRef)) {
errors.releaseRef =
"Use a release version (e.g. 1.0.3) or a digest (sha256:...).";
}
if (payload.licenseKey && !isWellFormedJwt(payload.licenseKey.trim())) {
errors.licenseKey =
"License key format is invalid. Paste the full key as provided.";
}
return errors;
}
function SkeletonCard() {
return (
<article className={`${styles.card} ${styles.full}`} aria-busy="true">
<div className={`${styles.skeleton} ${styles.skeletonTitle}`} />
<div className={`${styles.skeleton} ${styles.skeletonLine}`} />
<div className={`${styles.skeleton} ${styles.skeletonShort}`} />
<div className={`${styles.skeleton} ${styles.skeletonLine}`} />
</article>
);
}
export default function SetupPage() {
const [config, setConfig] = useState<SetupConfig | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [busy, setBusy] = useState(false);
const [channel, setChannel] = useState("stable");
const [tenantName, setTenantName] = useState("");
const [adminFirstName, setAdminFirstName] = useState("");
const [adminLastName, setAdminLastName] = useState("");
const [adminEmail, setAdminEmail] = useState("");
const [adminPassword, setAdminPassword] = useState("");
const [adminPasswordConfirm, setAdminPasswordConfirm] = useState("");
const [appHostname, setAppHostname] = useState("");
const [dnsMode, setDnsMode] = useState("system");
const [dnsServers, setDnsServers] = useState("");
const [releaseRef, setReleaseRef] = useState("");
const [editionChoice, setEditionChoice] = useState<"ee" | "ce">("ee");
const [licenseKey, setLicenseKey] = useState("");
const [installCode, setInstallCode] = useState("");
useEffect(() => {
let cancelled = false;
async function loadConfig() {
try {
const response = await fetch("/api/setup/config", {
cache: "no-store",
});
if (response.status === 401) {
window.location.reload();
return;
}
if (!response.ok) throw new Error("Unable to load setup defaults.");
const data = (await response.json()) as SetupConfig;
if (cancelled) return;
setConfig(data);
setChannel(data.defaults?.channel || "stable");
setAppHostname(data.defaults?.appHostname || "");
setDnsMode(data.defaults?.dnsMode || "system");
setDnsServers(data.defaults?.dnsServers || "");
setReleaseRef(data.defaults?.releaseRef || "");
setError(null);
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
}
}
loadConfig();
return () => {
cancelled = true;
};
}, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const payload = {
channel: String(formData.get("channel") || channel),
tenantName: String(formData.get("tenantName") || ""),
adminFirstName: String(formData.get("adminFirstName") || ""),
adminLastName: String(formData.get("adminLastName") || ""),
adminEmail: String(formData.get("adminEmail") || ""),
adminPassword: String(formData.get("adminPassword") || ""),
adminPasswordConfirm: String(formData.get("adminPasswordConfirm") || ""),
appHostname: String(formData.get("appHostname") || ""),
dnsMode: String(formData.get("dnsMode") || dnsMode),
dnsServers: String(formData.get("dnsServers") || ""),
releaseRef: String(formData.get("releaseRef") || releaseRef),
editionChoice,
licenseKey: licenseKey.trim() || undefined,
installCode: installCode.trim() || undefined,
};
const validation = validateSetupForm(payload);
setFieldErrors(validation);
if (Object.keys(validation).length > 0) return;
setBusy(true);
setError(null);
try {
const response = await fetch("/api/setup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (response.status === 401) {
window.location.reload();
return;
}
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "Unable to save setup.");
window.location.href = "/";
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}
function clearFieldError(field: keyof FieldErrors) {
setFieldErrors((current) => {
const next = { ...current };
delete next[field];
return next;
});
}
const resolvers = config?.network?.resolvers || [];
const addresses = config?.network?.addresses || [];
const disabled = busy || !config;
return (
<main className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<span className={styles.logo}>
<AlgaLogo className={styles.logoSvg} />
</span>
<span className={styles.brandText}>
<span>Alga PSA</span>
<small>Setup</small>
</span>
</div>
<nav className={styles.nav} aria-label="Alga PSA setup pages">
<a className={styles.setupLink} aria-current="page" href="/setup/">
<SlidersHorizontal className={styles.navIcon} aria-hidden="true" />
<span>Setup</span>
</a>
<a className={styles.setupLink} href="/">
<Activity className={styles.navIcon} aria-hidden="true" />
<span>Status</span>
</a>
</nav>
<LogoutButton />
</aside>
<section className={styles.workspace}>
<header className={styles.commandBar}>
<div>
<div className={styles.eyebrow}>Guided setup</div>
<h1>Configure your appliance</h1>
<p className={styles.muted}>
Start with the install code from your registration email. The
appliance details appear after that, with support-only release
settings tucked under Advanced.
</p>
</div>
<span className={`${styles.statusPill} ${styles.installing}`}>
{busy ? "Starting setup" : "Setup"}
</span>
</header>
<section className={styles.grid}>
{!config && !error ? <SkeletonCard /> : null}
<article className={`${styles.card} ${styles.full}`}>
<h2>Network detected from Ubuntu</h2>
{!config && !error ? (
<>
<div className={`${styles.skeleton} ${styles.skeletonLine}`} />
<div className={`${styles.skeleton} ${styles.skeletonShort}`} />
</>
) : (
<dl className={styles.kv}>
<div>
<dt>Node addresses</dt>
<dd>
{addresses.length
? addresses.join(", ")
: "No non-loopback address detected"}
</dd>
</div>
<div>
<dt>System resolvers</dt>
<dd>
{resolvers.length
? resolvers.join(", ")
: "No resolver detected"}
</dd>
</div>
</dl>
)}
</article>
<article className={`${styles.card} ${styles.full}`}>
<h2>Setup details</h2>
<form
id="appliance-setup-form"
className={styles.form}
onSubmit={submit}
noValidate
>
<div className={styles.setupStep}>
<div className={styles.stepHeader}>
<span className={styles.stepBadge}>Step 1</span>
<div>
<h3>Enter your install code</h3>
<p className={styles.muted}>
Find this code in the registration email from Nine Minds.
It binds this appliance to your tenant and applies the
correct edition automatically.
</p>
</div>
</div>
<div className={`${styles.field} ${styles.fullWidthField}`}>
<label htmlFor="setup-install-code">
Install code{" "}
<small
style={{
fontWeight: "normal",
color: "var(--muted, #6b7280)",
}}
>
(from your registration email)
</small>
</label>
<input
id="setup-install-code"
name="installCode"
value={installCode}
onChange={(event) => {
setInstallCode(event.target.value.toUpperCase());
clearFieldError("installCode");
}}
placeholder="K7QPM2RX"
disabled={disabled}
required
autoComplete="off"
aria-invalid={Boolean(fieldErrors.installCode)}
aria-describedby="setup-install-code-help setup-install-code-error"
style={{ fontFamily: "monospace", letterSpacing: "0.1em" }}
/>
<span
id="setup-install-code-help"
className={styles.helpText}
>
Lost or already-used code? Re-issue a fresh one from the
portal, then paste the new code here.
</span>
{fieldErrors.installCode ? (
<span
id="setup-install-code-error"
className={styles.fieldError}
>
{fieldErrors.installCode}
</span>
) : null}
</div>
</div>
{error ? (
<div className={styles.alert} role="alert">
{error}
</div>
) : null}
{installCode.trim() ? (
<>
<div className={styles.setupStep}>
<div className={styles.stepHeader}>
<span className={styles.stepBadge}>Step 2</span>
<div>
<h3>Configure the first tenant and network</h3>
<p className={styles.muted}>
Create the first admin account, confirm the URL users
will open, and keep default networking unless support
tells you otherwise.
</p>
</div>
</div>
<div className={styles.formGrid}>
{/* Hidden for now (SHOW_MANUAL_EDITION_AND_LICENSE) — manual edition
selection + airgap license-key entry. The install code is the
only supported path; kept guarded (not deleted) for easy revival. */}
{SHOW_MANUAL_EDITION_AND_LICENSE && (
<>
<div className={styles.field}>
<label>
Edition{" "}
<small
style={{
fontWeight: "normal",
color: "var(--muted, #6b7280)",
}}
>
(used only if no install code is entered)
</small>
</label>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.25rem",
}}
>
<label
style={{
display: "flex",
alignItems: "flex-start",
gap: "0.5rem",
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<input
type="radio"
name="editionChoice"
value="ee"
checked={editionChoice === "ee"}
onChange={() => setEditionChoice("ee")}
disabled={disabled}
style={{ marginTop: "0.2rem" }}
/>
<span>
<strong>Enterprise</strong> 15-day free
trial, then reverts to Essentials.
<br />
<small
style={{ color: "var(--muted, #6b7280)" }}
>
Includes all features. Enter a license key
below to extend beyond the trial.
</small>
</span>
</label>
<label
style={{
display: "flex",
alignItems: "flex-start",
gap: "0.5rem",
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<input
type="radio"
name="editionChoice"
value="ce"
checked={editionChoice === "ce"}
onChange={() => setEditionChoice("ce")}
disabled={disabled}
style={{ marginTop: "0.2rem" }}
/>
<span>
<strong>Essentials</strong> core open-source
feature set, no trial required.
<br />
<small
style={{ color: "var(--muted, #6b7280)" }}
>
You can start an Enterprise trial later from
the in-app License page.
</small>
</span>
</label>
</div>
</div>
{editionChoice === "ee" && (
<div className={styles.field}>
<label htmlFor="setup-license-key">
License key{" "}
<small
style={{
fontWeight: "normal",
color: "var(--muted, #6b7280)",
}}
>
(optional enter if you already have one)
</small>
</label>
<textarea
id="setup-license-key"
name="licenseKey"
value={licenseKey}
onChange={(event) => {
setLicenseKey(event.target.value);
clearFieldError("licenseKey");
}}
placeholder="eyJhbGci…"
rows={3}
disabled={disabled}
aria-invalid={Boolean(fieldErrors.licenseKey)}
aria-describedby="setup-license-key-help setup-license-key-error"
style={{
fontFamily: "monospace",
fontSize: "0.8rem",
resize: "vertical",
}}
/>
<span
id="setup-license-key-help"
className={styles.helpText}
>
Paste the signed key from Nine Minds. Leave
blank to start the 15-day trial.
</span>
{fieldErrors.licenseKey ? (
<span
id="setup-license-key-error"
className={styles.fieldError}
>
{fieldErrors.licenseKey}
</span>
) : null}
</div>
)}
</>
)}
<div className={styles.field}>
<label htmlFor="setup-tenant-name">Company name</label>
<input
id="setup-tenant-name"
name="tenantName"
value={tenantName}
onChange={(event) => {
setTenantName(event.target.value);
clearFieldError("tenantName");
}}
placeholder="Acme Managed Services"
disabled={disabled}
required
aria-invalid={Boolean(fieldErrors.tenantName)}
aria-describedby="setup-tenant-name-help setup-tenant-name-error"
/>
<span
id="setup-tenant-name-help"
className={styles.helpText}
>
This becomes the first tenant and default client
company.
</span>
{fieldErrors.tenantName ? (
<span
id="setup-tenant-name-error"
className={styles.fieldError}
>
{fieldErrors.tenantName}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-admin-first-name">
Admin first name
</label>
<input
id="setup-admin-first-name"
name="adminFirstName"
value={adminFirstName}
onChange={(event) => {
setAdminFirstName(event.target.value);
clearFieldError("adminFirstName");
}}
disabled={disabled}
required
aria-invalid={Boolean(fieldErrors.adminFirstName)}
aria-describedby="setup-admin-first-name-error"
/>
{fieldErrors.adminFirstName ? (
<span
id="setup-admin-first-name-error"
className={styles.fieldError}
>
{fieldErrors.adminFirstName}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-admin-last-name">
Admin last name
</label>
<input
id="setup-admin-last-name"
name="adminLastName"
value={adminLastName}
onChange={(event) => {
setAdminLastName(event.target.value);
clearFieldError("adminLastName");
}}
disabled={disabled}
required
aria-invalid={Boolean(fieldErrors.adminLastName)}
aria-describedby="setup-admin-last-name-error"
/>
{fieldErrors.adminLastName ? (
<span
id="setup-admin-last-name-error"
className={styles.fieldError}
>
{fieldErrors.adminLastName}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-admin-email">Admin email</label>
<input
id="setup-admin-email"
name="adminEmail"
type="email"
value={adminEmail}
onChange={(event) => {
setAdminEmail(event.target.value);
clearFieldError("adminEmail");
}}
placeholder="admin@example.com"
disabled={disabled}
required
aria-invalid={Boolean(fieldErrors.adminEmail)}
aria-describedby="setup-admin-email-help setup-admin-email-error"
/>
<span
id="setup-admin-email-help"
className={styles.helpText}
>
Use this email to sign in after setup completes.
</span>
{fieldErrors.adminEmail ? (
<span
id="setup-admin-email-error"
className={styles.fieldError}
>
{fieldErrors.adminEmail}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-admin-password">
Admin password
</label>
<input
id="setup-admin-password"
name="adminPassword"
type="password"
value={adminPassword}
onChange={(event) => {
setAdminPassword(event.target.value);
clearFieldError("adminPassword");
clearFieldError("adminPasswordConfirm");
}}
autoComplete="new-password"
disabled={disabled}
required
aria-invalid={Boolean(fieldErrors.adminPassword)}
aria-describedby="setup-admin-password-help setup-admin-password-error"
/>
<span
id="setup-admin-password-help"
className={styles.helpText}
>
At least 8 characters with uppercase, lowercase,
number, and special character.
</span>
{fieldErrors.adminPassword ? (
<span
id="setup-admin-password-error"
className={styles.fieldError}
>
{fieldErrors.adminPassword}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-admin-password-confirm">
Confirm admin password
</label>
<input
id="setup-admin-password-confirm"
name="adminPasswordConfirm"
type="password"
value={adminPasswordConfirm}
onChange={(event) => {
setAdminPasswordConfirm(event.target.value);
clearFieldError("adminPasswordConfirm");
}}
autoComplete="new-password"
disabled={disabled}
required
aria-invalid={Boolean(
fieldErrors.adminPasswordConfirm,
)}
aria-describedby="setup-admin-password-confirm-error"
/>
{fieldErrors.adminPasswordConfirm ? (
<span
id="setup-admin-password-confirm-error"
className={styles.fieldError}
>
{fieldErrors.adminPasswordConfirm}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-app-hostname">App URL</label>
<input
id="setup-app-hostname"
name="appHostname"
value={appHostname}
onChange={(event) => {
setAppHostname(event.target.value);
clearFieldError("appHostname");
}}
placeholder="http://192.168.1.50:3000"
disabled={disabled}
aria-invalid={Boolean(fieldErrors.appHostname)}
aria-describedby="setup-app-hostname-help setup-app-hostname-error"
/>
<span
id="setup-app-hostname-help"
className={styles.helpText}
>
Use the full URL users will enter in their browser.
The default local URL works out of the box.
</span>
{fieldErrors.appHostname ? (
<span
id="setup-app-hostname-error"
className={styles.fieldError}
>
{fieldErrors.appHostname}
</span>
) : null}
</div>
<div className={styles.field}>
<label htmlFor="setup-dns-mode">DNS mode</label>
<select
id="setup-dns-mode"
name="dnsMode"
value={dnsMode}
onChange={(event) => {
setDnsMode(event.target.value);
clearFieldError("dnsServers");
}}
disabled={disabled}
>
<option value="system">
Use DHCP/system resolvers
</option>
<option value="custom">Use custom DNS servers</option>
</select>
<span className={styles.helpText}>
Keep system DNS unless this site requires specific
internal resolvers.
</span>
</div>
<div className={styles.field}>
<label htmlFor="setup-dns-servers">
Custom DNS servers
</label>
<input
id="setup-dns-servers"
name="dnsServers"
value={dnsServers}
onChange={(event) => {
setDnsServers(event.target.value);
clearFieldError("dnsServers");
}}
placeholder="8.8.8.8,8.8.4.4"
disabled={disabled || dnsMode !== "custom"}
aria-invalid={Boolean(fieldErrors.dnsServers)}
aria-describedby="setup-dns-servers-help setup-dns-servers-error"
/>
<span
id="setup-dns-servers-help"
className={styles.helpText}
>
Comma-separated IPv4 addresses. Required only for
custom DNS.
</span>
{fieldErrors.dnsServers ? (
<span
id="setup-dns-servers-error"
className={styles.fieldError}
>
{fieldErrors.dnsServers}
</span>
) : null}
</div>
<details className={styles.advancedSupport}>
<summary className={styles.advancedSummary}>
Advanced
</summary>
<p className={styles.helpText}>
Support-directed options. Leave these defaults unless
Nine Minds support asks you to change them.
</p>
<div className={styles.formGrid}>
<div className={styles.field}>
<label htmlFor="setup-channel">
Release channel
</label>
<select
id="setup-channel"
name="channel"
value={channel}
onChange={(event) =>
setChannel(event.target.value)
}
disabled={disabled}
>
<option value="stable">
stable (recommended)
</option>
<option value="nightly">
nightly (testing/support-directed)
</option>
</select>
<span className={styles.helpText}>
Stable is recommended unless support asks you to
test nightly.
</span>
</div>
<div className={styles.field}>
<label htmlFor="setup-release-ref">
Release pin
</label>
<input
id="setup-release-ref"
name="releaseRef"
value={releaseRef}
onChange={(event) => {
setReleaseRef(event.target.value);
clearFieldError("releaseRef");
}}
placeholder="e.g. 1.0.3 or sha256:..."
disabled={disabled}
aria-invalid={Boolean(fieldErrors.releaseRef)}
aria-describedby="setup-release-ref-help setup-release-ref-error"
/>
<span
id="setup-release-ref-help"
className={styles.helpText}
>
Leave blank to use the selected release channel.
</span>
{fieldErrors.releaseRef ? (
<span
id="setup-release-ref-error"
className={styles.fieldError}
>
{fieldErrors.releaseRef}
</span>
) : null}
</div>
</div>
</details>
</div>
</div>
<div className={styles.formFooter}>
<a
className={`${styles.actionButton} ${styles.secondary}`}
href="/"
>
View status
</a>
<button
id="setup-submit"
className={styles.primaryButton}
type="submit"
disabled={busy || !config}
>
{busy ? "Starting setup…" : "Save and continue"}
</button>
</div>
</>
) : (
<div className={styles.lockedSetupNotice}>
<strong>Next: appliance configuration</strong>
<p>
After you enter the install code, well reveal the company,
admin, URL, and networking settings needed to finish setup.
</p>
<a
className={`${styles.actionButton} ${styles.secondary}`}
href="/"
>
View status instead
</a>
</div>
)}
</form>
</article>
</section>
</section>
</main>
);
}