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:
parent
3440bf70f0
commit
a0ff764736
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user