feat(catalyst-ui): inline error UX when Hetzner rejects token (#123)

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.
This commit is contained in:
hatiyildiz 2026-04-28 13:57:00 +02:00
parent 3440bf70f0
commit a0ff764736

View File

@ -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<FailureKind, { summary: string; hint: string }> = {
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<CloudProvider, string> = {
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<ValidationState>(
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<FailureDetail | null>(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({
</button>
</div>
{error && <span style={{ fontSize: 11, color: '#F87171' }}>{error}</span>}
{/* Feedback */}
{state === 'valid' && (
<div style={{ display: 'flex', gap: 6, fontSize: 11, color: '#4ADE80', alignItems: 'center' }}>
<CheckCircle2 size={12} /> Token validated access confirmed
</div>
)}
{state === 'invalid' && (
<div style={{ display: 'flex', gap: 6, fontSize: 11, color: '#F87171', alignItems: 'center' }}>
<XCircle size={12} /> Token rejected check permissions and try again
</div>
{failure && state === 'invalid' && (
<ValidationErrorCard
id={`${provider}-validation-err`}
failure={failure}
onRetry={validate}
disabled={state !== 'invalid' || token.trim().length === 0}
/>
)}
{/* Hetzner project ID required for resource attribution. The Hetzner Cloud API token
@ -301,3 +435,170 @@ export function StepCredentials() {
</StepShell>
)
}
/**
* 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<void>
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 (
<div
id={id}
role="alert"
style={{
borderRadius: 8,
border: '1px solid rgba(248,113,113,0.35)',
background: 'rgba(248,113,113,0.05)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<AlertCircle size={14} style={{ color: '#F87171', flexShrink: 0, marginTop: 1 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#F87171' }}>{failure.summary}</span>
{failure.status !== undefined && (
<span
style={{
fontSize: 9,
fontWeight: 700,
fontFamily: 'JetBrains Mono, monospace',
color: '#F87171',
background: 'rgba(248,113,113,0.12)',
padding: '1px 6px',
borderRadius: 3,
}}
>
HTTP {failure.status}
</span>
)}
</div>
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.5 }}>
{failure.hint}
</p>
{failure.rawMessage && (
<pre
style={{
margin: '6px 0 0',
padding: '6px 8px',
fontSize: 10.5,
fontFamily: 'JetBrains Mono, monospace',
color: 'var(--wiz-text-md)',
background: 'rgba(0,0,0,0.25)',
borderRadius: 5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{failure.rawMessage}
</pre>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="button"
onClick={retry}
disabled={disabled || retrying}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
height: 28,
padding: '0 10px',
borderRadius: 6,
border: '1px solid rgba(56,189,248,0.4)',
background: 'rgba(56,189,248,0.1)',
color: 'var(--wiz-accent)',
fontSize: 11,
fontWeight: 600,
cursor: disabled || retrying ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
fontFamily: 'Inter, sans-serif',
}}
>
{retrying ? <Loader2 size={11} className="animate-spin" /> : <RotateCw size={11} />}
Retry validation
</button>
<button
type="button"
onClick={copyDiagnostic}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
height: 28,
padding: '0 10px',
borderRadius: 6,
border: '1px solid var(--wiz-border)',
background: 'transparent',
color: 'var(--wiz-text-md)',
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'Inter, sans-serif',
}}
>
<Copy size={11} />
{copied ? 'Copied' : 'Copy diagnostic'}
</button>
<span style={{ fontSize: 10, color: 'var(--wiz-text-hint)', marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<XCircle size={10} /> Token will not be persisted on our servers either way
</span>
</div>
</div>
)
}