From a0ff764736764e20795f44b360ac558f604143b2 Mon Sep 17 00:00:00 2001 From: hatiyildiz Date: Tue, 28 Apr 2026 13:57:00 +0200 Subject: [PATCH] feat(catalyst-ui): inline error UX when Hetzner rejects token (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the silently-swallow-on-error branch in TokenSection.validate() with a real failure-mode taxonomy and an inline error card that surfaces the exact reason the token failed plus a remediation hint and a retry button. Failure modes the validator now distinguishes: - rejected backend confirmed token is wrong (Read-only, expired, …) - too-short client- or server-side length validation - unreachable could not reach the cloud provider's API (HTTP 503) - network could not reach catalyst-api (offline, CORS, DNS) - parse backend response was malformed - http any other unhandled non-2xx status Each kind has its own remediation hint pre-baked into FAILURE_HINTS; the inline ValidationErrorCard renders kind + summary + HTTP status + hint + raw backend message verbatim + retry / copy-diagnostic buttons. The previous implementation flipped to state=valid on network failure ("backend doesn't reach Hetzner → assume token is good"), violating docs/INVIOLABLE-PRINCIPLES.md #1 ("never compromise from quality"): the wizard would let the user proceed with a token that may or may not work, then fail at provisioning time. Now any non-success path surfaces a specific, actionable error and blocks Next. Closes #123. --- .../pages/wizard/steps/StepCredentials.tsx | 355 ++++++++++++++++-- 1 file changed, 328 insertions(+), 27 deletions(-) diff --git a/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepCredentials.tsx b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepCredentials.tsx index 54ffaa02..207d90df 100644 --- a/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepCredentials.tsx +++ b/products/catalyst/bootstrap/ui/src/pages/wizard/steps/StepCredentials.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Eye, EyeOff, CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react' +import { Eye, EyeOff, CheckCircle2, XCircle, Loader2, ExternalLink, AlertCircle, RotateCw, Copy } from 'lucide-react' import { useWizardStore } from '@/entities/deployment/store' import type { CloudProvider } from '@/entities/deployment/model' import { useBreakpoint } from '@/shared/lib/useBreakpoint' @@ -8,6 +8,77 @@ import { StepShell, useStepNav } from './_shared' type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid' +/** + * Specific failure mode reported by the validator. Used to render a + * targeted error UI per docs/INVIOLABLE-PRINCIPLES.md #2 ("never compromise + * from quality") — generic "rejected" messages cost the user a support + * ticket; a specific reason lets them self-recover. + * + * Closes #123 ([I] ux: error handling — what happens if Hetzner API rejects token). + */ +type FailureKind = + | 'rejected' // backend confirmed token is wrong (401/403 path) + | 'too-short' // client- or server-side length validation + | 'unreachable' // could not reach the cloud provider's API (503 path) + | 'network' // could not reach catalyst-api (CORS, offline, DNS) + | 'parse' // backend response was malformed + | 'http' // any other non-2xx HTTP status + +interface FailureDetail { + kind: FailureKind + /** Short human-readable summary (one line). */ + summary: string + /** Detailed remediation hint (multi-line, may include link). */ + hint: string + /** Raw backend message verbatim, when available. */ + rawMessage?: string + /** HTTP status code, when relevant. */ + status?: number +} + +const FAILURE_HINTS: Record = { + rejected: { + summary: 'Token rejected by Hetzner Cloud', + hint: + 'The token authenticated but does not have the permissions OpenTofu needs. ' + + 'Generate a new token in Hetzner Cloud Console → Security → API Tokens, ' + + 'pick the same project, and select "Read & Write" — never "Read only".', + }, + 'too-short': { + summary: 'Token is too short', + hint: + 'Hetzner API tokens are at least 64 characters long. ' + + 'Make sure you copied the full token from the Hetzner Cloud Console — ' + + "the token is only shown once at creation time, so if you've lost it, generate a new one.", + }, + unreachable: { + summary: 'Could not reach Hetzner Cloud', + hint: + 'The catalyst-api could not establish a TLS connection to api.hetzner.cloud. ' + + 'This is usually transient — wait a few seconds and retry. ' + + 'If it persists, check the Hetzner status page at status.hetzner.com.', + }, + network: { + summary: 'Could not reach the validation service', + hint: + 'The wizard could not POST to /api/v1/credentials/validate. ' + + 'You may be offline, behind a captive portal, or the catalyst-api is down. ' + + 'Reload the page and try again — the wizard preserves your inputs.', + }, + parse: { + summary: 'Validation service returned a malformed response', + hint: + "The backend's response could not be parsed as JSON. This is a backend bug — " + + 'open a support ticket with the diagnostic JSON below and the wizard team will investigate.', + }, + http: { + summary: 'Validation service returned an unexpected status', + hint: + 'The validation endpoint returned a non-2xx status that the wizard does not handle. ' + + 'Retry — if it persists, copy the diagnostic and file a support ticket.', + }, +} + const PROVIDER_NAMES: Record = { hetzner: 'Hetzner Cloud', huawei: 'Huawei Cloud', @@ -35,53 +106,113 @@ function TokenSection({ const store = useWizardStore() const [token, setToken] = useState(store.providerTokens[provider] ?? '') const [show, setShow] = useState(false) - const [error, setError] = useState('') const [state, setState] = useState( store.providerValidated[provider] ? 'valid' : 'idle' ) + /** + * Specific failure detail — populated when validate() determines the + * token is invalid OR validation itself failed. Displays a targeted + * error card with remediation steps + retry button. + */ + const [failure, setFailure] = useState(null) const [focused, setFocused] = useState(false) function handleChange(v: string) { setToken(v) - setError('') + setFailure(null) setState('idle') store.setProviderValidated(provider, false) } + /** + * validate() — POSTs to /api/v1/credentials/validate and surfaces the + * exact failure mode the backend reports. Replaces the previous + * silently-swallow-on-error behaviour: per docs/INVIOLABLE-PRINCIPLES.md + * #1, ANY validation error is a hard "invalid" — the user explicitly + * needs to know what went wrong, not be told everything is fine when + * the backend returned 503. + * + * Closes #123. + */ async function validate() { if (token.trim().length < 64) { - setError('Token must be at least 64 characters') + setFailure({ + kind: 'too-short', + summary: FAILURE_HINTS['too-short'].summary, + hint: FAILURE_HINTS['too-short'].hint, + }) + setState('invalid') return } setState('validating') + setFailure(null) store.setProviderToken(provider, token) + + let res: Response try { - const res = await fetch(`${API_BASE}/v1/credentials/validate`, { + res = await fetch(`${API_BASE}/v1/credentials/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, provider }), }) - const data = await res.json() - if (data.valid) { - setState('valid') - store.setProviderValidated(provider, true) - if (provider === 'hetzner') { - store.setHetznerToken(token) - store.setCredentialValidated(true) - } - } else { - setState('invalid') - store.setProviderValidated(provider, false) - } - } catch { + } catch (err) { + setFailure({ + kind: 'network', + summary: FAILURE_HINTS.network.summary, + hint: FAILURE_HINTS.network.hint, + rawMessage: String(err), + }) + setState('invalid') + store.setProviderValidated(provider, false) + return + } + + let data: { valid?: boolean; message?: string } | null = null + try { + data = (await res.json()) as { valid?: boolean; message?: string } + } catch (err) { + setFailure({ + kind: 'parse', + summary: FAILURE_HINTS.parse.summary, + hint: FAILURE_HINTS.parse.hint, + rawMessage: String(err), + status: res.status, + }) + setState('invalid') + store.setProviderValidated(provider, false) + return + } + + // Backend wire format (handler/credentials.go): + // 200 + valid=true → token good, set state=valid + // 200 + valid=false → token rejected by Hetzner + // 400 + valid=false → too-short (server-side check) + // 503 + valid=false → Hetzner API unreachable + // anything else → unhandled + if (res.ok && data?.valid === true) { setState('valid') store.setProviderValidated(provider, true) - store.setProviderToken(provider, token) if (provider === 'hetzner') { store.setHetznerToken(token) store.setCredentialValidated(true) } + return } + + let kind: FailureKind = 'rejected' + if (res.status === 503) kind = 'unreachable' + else if (res.status === 400) kind = 'too-short' + else if (!res.ok) kind = 'http' + + setFailure({ + kind, + summary: FAILURE_HINTS[kind].summary, + hint: FAILURE_HINTS[kind].hint, + rawMessage: data?.message, + status: res.status, + }) + setState('invalid') + store.setProviderValidated(provider, false) } function skipDemo() { @@ -134,13 +265,15 @@ function TokenSection({ onBlur={() => setFocused(false)} onChange={e => handleChange(e.target.value)} placeholder="Paste your credential here…" + aria-invalid={state === 'invalid'} + aria-describedby={failure ? `${provider}-validation-err` : undefined} style={{ width: '100%', height: 38, borderRadius: 7, - border: `1.5px solid ${error ? 'rgba(248,113,113,0.5)' : focused ? 'rgba(56,189,248,0.45)' : 'var(--wiz-border)'}`, + border: `1.5px solid ${failure ? 'rgba(248,113,113,0.5)' : focused ? 'rgba(56,189,248,0.45)' : 'var(--wiz-border)'}`, background: 'var(--wiz-bg-input)', color: 'var(--wiz-text-hi)', fontSize: 13, paddingLeft: 10, paddingRight: 38, outline: 'none', fontFamily: 'Inter, monospace', - boxShadow: focused ? `0 0 0 3px ${error ? 'rgba(248,113,113,0.07)' : 'rgba(56,189,248,0.07)'}` : 'none', + boxShadow: focused ? `0 0 0 3px ${failure ? 'rgba(248,113,113,0.07)' : 'rgba(56,189,248,0.07)'}` : 'none', transition: 'all 0.15s', }} /> @@ -177,18 +310,19 @@ function TokenSection({ - {error && {error}} - {/* Feedback */} {state === 'valid' && (
Token validated — access confirmed
)} - {state === 'invalid' && ( -
- Token rejected — check permissions and try again -
+ {failure && state === 'invalid' && ( + )} {/* Hetzner project ID — required for resource attribution. The Hetzner Cloud API token @@ -301,3 +435,170 @@ export function StepCredentials() { ) } + +/** + * ValidationErrorCard — inline error banner shown beneath the credential + * input when validate() determines the token is invalid OR validation + * itself failed. + * + * Per docs/INVIOLABLE-PRINCIPLES.md #2 ("never compromise from quality") + * we surface the exact failure mode + remediation hint + the raw backend + * message verbatim so the operator can self-recover without filing a + * support ticket. + * + * Closes #123 ([I] ux: error handling — what happens if Hetzner API + * rejects token). + */ +function ValidationErrorCard({ + id, + failure, + onRetry, + disabled, +}: { + id: string + failure: FailureDetail + onRetry: () => void | Promise + disabled: boolean +}) { + const [retrying, setRetrying] = useState(false) + const [copied, setCopied] = useState(false) + + async function retry() { + setRetrying(true) + try { + await onRetry() + } finally { + setRetrying(false) + } + } + + async function copyDiagnostic() { + const blob = JSON.stringify( + { + kind: failure.kind, + summary: failure.summary, + status: failure.status, + rawMessage: failure.rawMessage, + }, + null, + 2, + ) + await navigator.clipboard.writeText(blob) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +}