feat(portal): M11.1 catalog flow + M12.1 self-serve trial
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
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user