Group D fixes
Some checks failed
Build and Push / build (push) Failing after 37s

This commit is contained in:
2026-04-29 22:13:08 +02:00
parent 9c50c9f054
commit 219b4c8365
15 changed files with 914 additions and 55 deletions

View File

@@ -12,6 +12,14 @@ interface OnboardingFlowProps {
*/
userName?: string;
userEmail?: string;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
* shape and behavioural contract.
*/
editingRequest?: React.ComponentProps<
typeof OnboardingWizard
>["editingRequest"];
}
/**
@@ -29,6 +37,7 @@ export function OnboardingFlow({
orgName,
userName,
userEmail,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -37,6 +46,7 @@ export function OnboardingFlow({
orgName={orgName}
userName={userName}
userEmail={userEmail}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and

View File

@@ -1,6 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
@@ -14,6 +16,7 @@ interface RequestSummary {
status: string;
adminNotes?: string;
tenantName?: string;
dismissedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
@@ -36,21 +39,42 @@ interface SingleRequestState {
tenant: TenantSummary | null;
}
interface Props {
requestId: string;
/**
* Whether the viewer can act on this request — cancel a pending one,
* dismiss a rejected one, etc. True for owner + platform; false for
* `user`-role customers (who shouldn't see in-flight requests at all,
* but defence in depth — `canSeeInflightRequests` already gates the
* dashboard side).
*/
canAct: boolean;
}
/**
* 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.
* 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.
* Slice 7 / Bug 6 + 13:
* - pending → cancel + edit buttons
* - rejected → admin notes block + dismiss button
* - cancelled → small acknowledgement card + dismiss button
* - terminal Ready/Active states unchanged
*/
export function ProvisioningStatus({ requestId }: { requestId: string }) {
export function ProvisioningStatus({ requestId, canAct }: Props) {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => {
try {
@@ -67,11 +91,11 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
useEffect(() => {
poll();
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "cancelled" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
@@ -82,7 +106,54 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
return () => clearInterval(interval);
}, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) {
const handleCancel = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("cancelFailed"));
}
setConfirmCancel(false);
// Re-poll so the card transitions to "cancelled" state without a
// full route refresh — the dashboard's surrounding tenant cards
// are unaffected.
await poll();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
const handleDismiss = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("dismissFailed"));
}
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
// filters out dismissed rows — refresh to drop this card.
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
if (error && !data) {
return (
<Card>
<div className="text-xs text-red-400">{error}</div>
@@ -107,7 +178,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
data.request.tenantName ||
data.request.agentName;
// Pending admin approval
// ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") {
return (
<Card className="animate-in">
@@ -131,7 +202,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<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")}
@@ -150,12 +223,76 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
</span>
</p>
)}
{/* Bug 6 — owner-only edit + cancel actions while still
pending. Once admin acts, both buttons disappear (the
status branch changes). */}
{canAct && (
<div className="flex justify-center gap-2 mt-5">
<Link
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("editRequest")}
</Link>
<button
type="button"
onClick={() => setConfirmCancel(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("cancelRequest")}
</button>
</div>
)}
{error && (
<p className="text-xs text-red-400 mt-3">{error}</p>
)}
</div>
{confirmCancel && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmCancel(false);
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmRequestTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("cancelConfirmRequestDescription")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={handleCancel}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
>
{actionPending
? tCommon("loading")
: t("cancelRequestConfirm")}
</button>
</div>
</div>
</div>
)}
</Card>
);
}
// Rejected
// ─── Rejected: admin declined ───────────────────────────────────────
if (status === "rejected") {
return (
<Card className="animate-in">
@@ -179,22 +316,94 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<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 && (
<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>
<div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
<div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
{t("rejectionReason")}
</div>
<div className="whitespace-pre-wrap">
{data.request.adminNotes}
</div>
</div>
)}
{/* Bug 13: dismiss removes this card from the dashboard but
keeps the row in the DB for audit. The customer can also
just resubmit via the wizard — both paths are valid. */}
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready)
// ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
if (status === "cancelled") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelledTitle")}
</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("cancelledDescription")}
</p>
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Provisioning: approved, operator working ──────────────────────
if (
status === "approved" ||
status === "provisioning" ||
@@ -213,7 +422,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
@@ -249,7 +460,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
);
}
// Active / Ready
// ─── Active / Ready ─────────────────────────────────────────────────
if (status === "active") {
return (
<Card className="animate-in">
@@ -273,7 +484,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<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")}

View File

@@ -64,6 +64,35 @@ interface WizardProps {
*/
userName?: string;
userEmail?: string;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
* skipped (we trust the existing values), and the submit button
* PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding.
*
* Per-package secrets are deliberately NOT pre-filled, even if the
* customer originally supplied them — server-side decryption to
* the client would be a security regression. The user re-enters
* any secrets they want to change; if they leave them blank, the
* existing encrypted blob in the DB is preserved by the PATCH
* endpoint.
*/
editingRequest?: {
id: string;
instanceName: string;
agentName: string;
soulMd: string;
agentsMd: string;
packages: string[];
billingAddress: {
company?: string;
street?: string;
city?: string;
postalCode?: string;
country?: string;
};
billingNotes: string;
};
onComplete: () => void;
}
@@ -71,6 +100,7 @@ export function OnboardingWizard({
orgName,
userName,
userEmail,
editingRequest,
onComplete,
}: WizardProps) {
const t = useTranslations("onboarding");
@@ -91,30 +121,55 @@ export function OnboardingWizard({
orgName,
isPersonal,
});
const isEditing = Boolean(editingRequest);
const [step, setStep] = useState<Step>("welcome");
// Edit mode jumps straight to the configure step — the welcome step
// is a first-time onboarding affordance and only adds friction when
// the customer is fixing a typo.
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
// In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing);
const [config, setConfig] = useState({
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
// want to.
company: isPersonal ? "" : displayOrgName,
street: "",
city: "",
postalCode: "",
country: "CH",
},
billingNotes: "",
const [config, setConfig] = useState(() => {
if (editingRequest) {
return {
instanceName: editingRequest.instanceName,
agentName: editingRequest.agentName,
soulMd: editingRequest.soulMd,
agentsMd: editingRequest.agentsMd,
packages: editingRequest.packages,
billingAddress: {
company: editingRequest.billingAddress.company ?? "",
street: editingRequest.billingAddress.street ?? "",
city: editingRequest.billingAddress.city ?? "",
postalCode: editingRequest.billingAddress.postalCode ?? "",
country: editingRequest.billingAddress.country ?? "CH",
},
billingNotes: editingRequest.billingNotes,
};
}
return {
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
// want to.
company: isPersonal ? "" : displayOrgName,
street: "",
city: "",
postalCode: "",
country: "CH",
},
billingNotes: "",
};
});
// TOOLS.md preview — readonly, auto-generated
@@ -308,8 +363,17 @@ export function OnboardingWizard({
}
}
const res = await fetch("/api/onboarding", {
method: "POST",
// Bug 6: edit mode targets the per-row endpoint with PATCH;
// create mode targets the collection endpoint with POST. Body
// shape is the same — both routes parse it through
// onboardingSchema.
const url = editingRequest
? `/api/onboarding/${encodeURIComponent(editingRequest.id)}`
: "/api/onboarding";
const method = editingRequest ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...config,
@@ -1017,7 +1081,11 @@ export function OnboardingWizard({
disabled={submitting}
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? tCommon("loading") : t("submitRequest")}
{submitting
? tCommon("loading")
: isEditing
? t("saveChanges")
: t("submitRequest")}
</button>
</div>
</Card>

View File

@@ -15,6 +15,11 @@ const phaseStyles: Record<string, string> = {
Running: "bg-success/10 text-success border-success/20",
Ready: "bg-success/10 text-success border-success/20",
Provisioning: "bg-warning/10 text-warning border-warning/20",
// Reconfiguring shares the warning palette (yellow pulse) but renders
// a distinct label, so customers see it differently from first-time
// provisioning. Useful when packages or channel-users change and the
// pod restarts mid-life.
Reconfiguring: "bg-warning/10 text-warning border-warning/20",
Pending: "bg-text-muted/10 text-text-secondary border-border",
Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30",
Error: "bg-error/10 text-error border-error/20",
@@ -44,6 +49,9 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{phase === "Reconfiguring" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{label}
</span>
);