Closes the customer loop: /start signup → tenant + KC org + IT_ADMIN invite → portal dashboard with trial banner → /[slug]/catalog with Request + Start trial server actions wired to tenant-registry. Refs: M11.1 + M12.1
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
import { createTenant } from "@/lib/tenant-registry";
|
|
|
|
// Public self-serve signup. Apex-level; no slug. Creates a trial tenant
|
|
// via tenant-registry, which also provisions a Keycloak organization +
|
|
// invites the user as IT_ADMIN. The portal middleware rewrites
|
|
// signup.<apex> here, but the bare path also works for dev.
|
|
//
|
|
// After success the user lands at /<slug>/dashboard. In prod they'd
|
|
// follow the KC invite email to set a password; in dev (no Stalwart yet)
|
|
// the invite_url is logged for the operator to share manually.
|
|
|
|
export default async function StartPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ err?: string }>;
|
|
}) {
|
|
const flash = await searchParams;
|
|
|
|
async function submit(formData: FormData) {
|
|
"use server";
|
|
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
|
|
const name = String(formData.get("name") ?? "").trim();
|
|
const email = String(formData.get("email") ?? "").trim();
|
|
const plan =
|
|
(String(formData.get("plan") ?? "starter") as "starter" | "professional" | "enterprise") ||
|
|
"starter";
|
|
|
|
if (!slug || !name || !email) {
|
|
redirect("/start?err=missing_fields");
|
|
}
|
|
const res = await createTenant({
|
|
slug,
|
|
name,
|
|
plan,
|
|
admin_email: email,
|
|
});
|
|
if (!res.ok) {
|
|
redirect(`/start?err=${res.error}`);
|
|
}
|
|
redirect(`/${res.tenant.slug}/dashboard?ok=created`);
|
|
}
|
|
|
|
return (
|
|
<main style={{ maxWidth: 480, margin: "10vh auto", padding: "0 24px" }}>
|
|
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Start a 14-day trial</h1>
|
|
<p style={{ color: "#444", marginBottom: 24 }}>
|
|
Spin up your tenant. You'll get an email invite from Keycloak with
|
|
a link to set your password.
|
|
</p>
|
|
|
|
{flash.err && (
|
|
<div
|
|
role="status"
|
|
style={{
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
marginBottom: 16,
|
|
background: "#fdecea",
|
|
color: "#a82626",
|
|
border: "1px solid #e8a5a5",
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
{flash.err === "slug_taken" && "That slug is already in use. Pick another."}
|
|
{flash.err === "invalid_input" && "Slug must be 3+ chars, lowercase letters / digits / hyphens."}
|
|
{flash.err === "missing_fields" && "All fields are required."}
|
|
{!["slug_taken", "invalid_input", "missing_fields"].includes(flash.err) &&
|
|
`Something broke: ${flash.err}`}
|
|
</div>
|
|
)}
|
|
|
|
<form action={submit} style={{ display: "grid", gap: 12 }}>
|
|
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
|
|
<span>Tenant slug</span>
|
|
<input
|
|
name="slug"
|
|
required
|
|
placeholder="acme"
|
|
pattern="^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$"
|
|
style={inputStyle}
|
|
/>
|
|
<small style={{ color: "#666" }}>
|
|
Becomes <code><slug>.breakpilot.com</code>. Lowercase, hyphens allowed.
|
|
</small>
|
|
</label>
|
|
|
|
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
|
|
<span>Company name</span>
|
|
<input name="name" required placeholder="Acme Inc." style={inputStyle} />
|
|
</label>
|
|
|
|
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
|
|
<span>Admin email</span>
|
|
<input
|
|
name="email"
|
|
type="email"
|
|
required
|
|
placeholder="you@acme.test"
|
|
style={inputStyle}
|
|
/>
|
|
</label>
|
|
|
|
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
|
|
<span>Plan</span>
|
|
<select name="plan" defaultValue="starter" style={inputStyle}>
|
|
<option value="starter">Starter</option>
|
|
<option value="professional">Professional</option>
|
|
<option value="enterprise">Enterprise</option>
|
|
</select>
|
|
</label>
|
|
|
|
<button
|
|
type="submit"
|
|
style={{
|
|
marginTop: 8,
|
|
padding: "10px 16px",
|
|
background: "#0070f3",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
fontSize: 14,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Start trial
|
|
</button>
|
|
</form>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
padding: "8px 10px",
|
|
border: "1px solid #ddd",
|
|
borderRadius: 6,
|
|
fontSize: 14,
|
|
};
|