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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
953 lines
39 KiB
TypeScript
953 lines
39 KiB
TypeScript
"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, we’ll 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>
|
||
);
|
||
}
|