fix(onboarding): explain blocked Next, humanise errors, de-jargon provisioning

This commit is contained in:
2026-05-29 23:28:45 +02:00
parent 08f28aeb93
commit 3110b40cf9
2 changed files with 99 additions and 42 deletions

View File

@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
<span className="text-xs text-text-muted">{t("phase")}</span> <span className="text-xs text-text-muted">{t("phase")}</span>
<StatusBadge phase={phase} /> <StatusBadge phase={phase} />
</div> </div>
{conditions.map((c, i) => ( {/* Setup progress. The operator reports readiness as a list of
<div internal K8s conditions (OpenBao policy, LiteLLM key, network
key={i} policy, …) — meaningful to operators, jargon to customers.
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2" We surface the *shape* of that progress (how many steps are
> done) without leaking the internal names. */}
<span className="text-xs text-text-muted">{c.type}</span> {conditions.length > 0 &&
<span (() => {
className={`text-xs font-mono ${ const done = conditions.filter((c) => c.status === "True").length;
c.status === "True" const total = conditions.length;
? "text-emerald-400" const pct = Math.round((done / total) * 100);
: c.status === "False" return (
? "text-red-400" <div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
: "text-text-muted" <div className="flex items-center justify-between mb-2">
}`} <span className="text-xs text-text-muted">
> {t("setupProgress")}
{c.reason || c.status} </span>
</span> <span className="text-xs font-medium text-text-secondary tabular-nums">
</div> {t("setupStepsComplete", { done, total })}
))} </span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
<div
className="h-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})()}
</div> </div>
</Card> </Card>
); );

View File

@@ -420,18 +420,51 @@ export function OnboardingWizard({
[] []
); );
// Validate that all secret-requiring enabled packages have complete credentials // Enabled packages that still need something from the user before the
const packageCredentialsValid = (): boolean => { // configure step can advance — a missing credential field or an
// unaccepted disclaimer. Returns the package defs so the UI can name
// exactly what's blocking the (otherwise silently disabled) Next
// button instead of greying it out with no explanation.
const incompletePackages = (): PackageDef[] => {
const out: PackageDef[] = [];
for (const pkgId of config.packages) { for (const pkgId of config.packages) {
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
if (!def?.requiresSecrets) continue; if (!def) continue;
const secrets = packageSecrets[pkgId] || {}; let incomplete = false;
for (const field of def.secrets || []) { if (def.requiresSecrets) {
if (!secrets[field.key]?.trim()) return false; const secrets = packageSecrets[pkgId] || {};
for (const field of def.secrets || []) {
if (!secrets[field.key]?.trim()) {
incomplete = true;
break;
}
}
} }
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false; if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
if (incomplete) out.push(def);
} }
return true; return out;
};
const packageCredentialsValid = (): boolean =>
incompletePackages().length === 0;
// Map zod field paths to human labels for the confirm-step error
// summary, so a stray validation failure reads "Postal code" rather
// than "billingAddress.postalCode". Unknown paths fall back to the
// raw path (this defence-in-depth list should rarely render at all).
const fieldLabel = (path: string): string => {
const map: Record<string, string> = {
instanceName: t("instanceName"),
agentName: t("agentName"),
"billingAddress.company": t("billingCompany"),
"billingAddress.street": t("billingStreet"),
"billingAddress.postalCode": t("billingPostalCode"),
"billingAddress.city": t("billingCity"),
"billingAddress.country": t("billingCountry"),
"billingAddress.vatNumber": t("billingVatNumber"),
};
return map[path] ?? path;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -984,20 +1017,33 @@ export function OnboardingWizard({
</div> </div>
</div> </div>
<div className="flex justify-between mt-6"> <div className="mt-6">
<button {(() => {
onClick={goBack} const blocking = incompletePackages();
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors" if (blocking.length === 0) return null;
> return (
{t("back")} <p className="text-xs text-amber-400/90 mb-3 text-right">
</button> {t("packagesIncompleteHint", {
<button packages: blocking.map((p) => p.name).join(", "),
onClick={goNext} })}
disabled={!packageCredentialsValid()} </p>
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed" );
> })()}
{t("next")} <div className="flex justify-between">
</button> <button
onClick={goBack}
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("back")}
</button>
<button
onClick={goNext}
disabled={!packageCredentialsValid()}
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("next")}
</button>
</div>
</div> </div>
</Card> </Card>
)} )}
@@ -1380,7 +1426,8 @@ export function OnboardingWizard({
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
{Object.entries(errors).map(([path, msg]) => ( {Object.entries(errors).map(([path, msg]) => (
<li key={path}> <li key={path}>
<span className="font-mono">{path}</span>: {msg} <span className="font-medium">{fieldLabel(path)}</span>:{" "}
{msg}
</li> </li>
))} ))}
</ul> </ul>