Multitenantperorg enabling
All checks were successful
Build and Push / build (push) Successful in 1m21s

This commit is contained in:
2026-04-26 22:09:26 +02:00
parent 7b22bc4087
commit 2c85bf8597
13 changed files with 584 additions and 225 deletions

View File

@@ -1,31 +1,36 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import { ProvisioningStatus } from "./provisioning-status";
interface OnboardingFlowProps {
orgName: string;
initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected";
}
/**
* Orchestrates the onboarding experience:
* - no_request → show wizard
* - pending/approved/provisioning/rejected → show status
* - After wizard submission → switch to status polling
* Wraps the onboarding wizard. On successful submission, refreshes the
* router so the parent server component re-renders with the new pending
* request visible in the dashboard list.
*
* Slice 3: this component used to manage the no_request → pending →
* provisioning → active state machine, with conditional rendering of
* `<ProvisioningStatus>`. That state is now reflected at the dashboard
* level (which renders one `<ProvisioningStatus>` per pending request),
* so this wrapper does just one thing: show the wizard, then navigate.
*/
export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) {
const [showWizard, setShowWizard] = useState(initialState === "no_request");
export function OnboardingFlow({ orgName }: OnboardingFlowProps) {
const router = useRouter();
if (showWizard) {
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => setShowWizard(false)}
/>
);
}
return <ProvisioningStatus />;
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and
// render its `<ProvisioningStatus>` card automatically.
router.push("/dashboard");
router.refresh();
}}
/>
);
}

View File

@@ -6,64 +6,81 @@ import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState {
state: string;
request?: {
id: string;
status: string;
companyName: string;
agentName: string;
adminNotes?: string;
createdAt?: string;
};
tenant?: {
name: string;
phase: string;
message?: string;
conditions?: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
};
interface RequestSummary {
id: string;
instanceName?: string | null;
agentName: string;
packages: string[];
status: string;
adminNotes?: string;
tenantName?: string;
createdAt?: string;
updatedAt?: string;
}
export function ProvisioningStatus() {
interface TenantSummary {
name: string;
displayName: string;
phase: string;
conditions: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
}
interface SingleRequestState {
request: RequestSummary;
tenant: TenantSummary | null;
}
/**
* ProvisioningStatus
*
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches
* a terminal state. Slice 3: takes a `requestId` prop so multiple of these
* can render on the same dashboard for different in-flight requests.
*
* The pre-Slice-3 version polled /api/onboarding with no params and
* assumed one-request-per-org — that endpoint shape is gone now.
*/
export function ProvisioningStatus({ requestId }: { requestId: string }) {
const t = useTranslations("onboarding");
const f = useFormatter();
const [data, setData] = useState<OnboardingState | null>(null);
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const poll = useCallback(async () => {
try {
const res = await fetch("/api/onboarding");
const res = await fetch(
`/api/onboarding?id=${encodeURIComponent(requestId)}`
);
if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message);
}
}, []);
}, [requestId]);
useEffect(() => {
poll();
// Poll every 5 seconds while not in a terminal state
const interval = setInterval(() => {
if (
data?.state === "provisioned" ||
data?.state === "rejected" ||
data?.state === "active"
) {
return;
}
poll();
}, 5000);
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
if (terminal) return;
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [poll, data?.state]);
}, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) {
return (
@@ -84,8 +101,14 @@ export function ProvisioningStatus() {
);
}
const status = data.request.status;
const label =
data.request.instanceName ||
data.request.tenantName ||
data.request.agentName;
// Pending admin approval
if (data.state === "pending") {
if (status === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -107,10 +130,13 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
{data.request?.createdAt && (
{data.request.createdAt && (
<p
className="text-xs text-text-muted mt-4"
title={formatDateTime(data.request.createdAt, f)}
@@ -130,7 +156,7 @@ export function ProvisioningStatus() {
}
// Rejected
if (data.state === "rejected") {
if (status === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -152,10 +178,13 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request?.adminNotes && (
{data.request.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes}
</p>
@@ -165,10 +194,11 @@ export function ProvisioningStatus() {
);
}
// Provisioning in progress
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready)
if (
data.state === "approved" ||
data.state === "provisioning"
status === "approved" ||
status === "provisioning" ||
(status === "active" && data.tenant && data.tenant.phase !== "Ready")
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
@@ -182,6 +212,9 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
@@ -216,8 +249,8 @@ export function ProvisioningStatus() {
);
}
// Provisioned / Running
if (data.state === "provisioned") {
// Active / Ready
if (status === "active") {
return (
<Card className="animate-in">
<div className="text-center py-6">
@@ -239,6 +272,9 @@ export function ProvisioningStatus() {
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>

View File

@@ -62,6 +62,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [config, setConfig] = useState({
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", orgName),
agentsMd: FALLBACK_AGENTS,
@@ -306,6 +307,24 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("instanceName")}
</label>
<input
type="text"
value={config.instanceName}
onChange={(e) =>
setConfig((prev) => ({ ...prev, instanceName: e.target.value }))
}
placeholder={t("instanceNamePlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-xs text-text-muted mt-1">
{t("instanceNameHint")}
</p>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
@@ -734,6 +753,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
{config.instanceName.trim() && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("instanceName")}</span>
<span className="text-text-primary font-mono">
{config.instanceName.trim()}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">