From 088bc34e675ba729b671dffe2acbcdc1740af58a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:10:28 +0200 Subject: [PATCH] fix(ui): extract meaningful error messages from API responses Introduces ApiError class in client.ts that parses Spring Boot error bodies to extract human-readable messages (message, error, detail fields). Adds errorMessage() helper used by all toast descriptions instead of raw String(err) which dumped JSON blobs to the user. Affected: all 10 page components that display error toasts. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/client.ts | 52 +++++++++++++++++++-- ui/src/pages/OnboardingPage.tsx | 4 +- ui/src/pages/tenant/SettingsPage.tsx | 17 +++---- ui/src/pages/tenant/SsoPage.tsx | 13 +++--- ui/src/pages/tenant/TeamPage.tsx | 9 ++-- ui/src/pages/tenant/TenantDashboardPage.tsx | 5 +- ui/src/pages/vendor/CertificatesPage.tsx | 9 ++-- ui/src/pages/vendor/CreateTenantPage.tsx | 3 +- ui/src/pages/vendor/EmailConfigPage.tsx | 9 ++-- ui/src/pages/vendor/LicenseVerifyPage.tsx | 3 +- ui/src/pages/vendor/TenantDetailPage.tsx | 11 +++-- 11 files changed, 95 insertions(+), 40 deletions(-) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index aceb049..67e5ac1 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -6,6 +6,42 @@ export function setTokenProvider(provider: (() => Promise) | tokenProvider = provider; } +/** + * Custom error class that extracts a human-readable message from API responses. + * Spring Boot error bodies typically contain: { message, error, status, detail } + * Our own controllers may return: { error, message, detail } + */ +export class ApiError extends Error { + public readonly status: number; + public readonly detail: string | null; + + constructor(status: number, body: string) { + const parsed = ApiError.parseBody(body); + super(parsed.message); + this.name = 'ApiError'; + this.status = status; + this.detail = parsed.detail; + } + + private static parseBody(body: string): { message: string; detail: string | null } { + try { + const json = JSON.parse(body); + // Try common Spring Boot / custom error fields in priority order + const message = json.message || json.error || json.detail || json.reason || `Request failed`; + const detail = json.detail || json.path || null; + return { message, detail }; + } catch { + // Not JSON — use raw text, truncated + return { message: body.slice(0, 200) || 'Request failed', detail: null }; + } + } + + /** Returns just the human message (no "Error:" prefix, no status code, no JSON blob). */ + toString(): string { + return this.message; + } +} + async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = tokenProvider ? await tokenProvider() : null; @@ -24,20 +60,20 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise if (response.status === 401) { // Don't hard-redirect — let React Query retry (token may not be ready yet). // The ProtectedRoute handles unauthenticated state. - throw new Error('Unauthorized'); + throw new ApiError(401, '{"message":"Unauthorized"}'); } if (response.status === 403) { const errorHeader = response.headers.get('X-Cameleer-Error'); if (errorHeader === 'APP_MFA_REQUIRED') { window.location.href = '/platform/tenant/settings?mfa=required'; - throw new Error('MFA enrollment required'); + throw new ApiError(403, '{"message":"MFA enrollment required"}'); } } if (!response.ok) { const text = await response.text(); - throw new Error(`API error ${response.status}: ${text}`); + throw new ApiError(response.status, text); } if (response.status === 204) return undefined as T; @@ -57,3 +93,13 @@ export const api = { apiFetch(path, { method: 'PUT', body }), delete: (path: string) => apiFetch(path, { method: 'DELETE' }), }; + +/** + * Extract a human-readable error message for toast display. + * Works with ApiError (preferred) and plain Error/unknown. + */ +export function errorMessage(err: unknown): string { + if (err instanceof ApiError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/ui/src/pages/OnboardingPage.tsx b/ui/src/pages/OnboardingPage.tsx index 8337228..f78b16d 100644 --- a/ui/src/pages/OnboardingPage.tsx +++ b/ui/src/pages/OnboardingPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useLogto } from '@logto/react'; import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system'; import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; -import { api } from '../api/client'; +import { api, errorMessage } from '../api/client'; import { toSlug } from '../utils/slug'; import styles from './OnboardingPage.module.css'; @@ -56,7 +56,7 @@ export function OnboardingPage() { // auto-approves and redirects back with fresh tokens. await signIn(`${window.location.origin}/platform/callback`); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = err instanceof Error ? err.message : errorMessage(err); if (msg.includes('409')) { setError('This organization name is already taken. Try a different organization name.'); } else { diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index 87286f1..5f678f9 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; +import { errorMessage } from '../../api/client'; import { Alert, Badge, @@ -49,7 +50,7 @@ function MfaSection() { setSetupData(data); setVerifyCode(''); } catch (err) { - toast({ title: 'Failed to start MFA setup', description: String(err), variant: 'error' }); + toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' }); } } @@ -69,7 +70,7 @@ function MfaSection() { toast({ title: 'Invalid code. Please try again.', variant: 'error' }); } } catch (err) { - toast({ title: 'Verification failed', description: String(err), variant: 'error' }); + toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' }); } } @@ -80,7 +81,7 @@ function MfaSection() { setCodesSaved(false); toast({ title: 'Backup codes regenerated', variant: 'success' }); } catch (err) { - toast({ title: 'Failed to regenerate backup codes', description: String(err), variant: 'error' }); + toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' }); } } @@ -92,7 +93,7 @@ function MfaSection() { setSetupData(null); toast({ title: 'MFA removed', variant: 'success' }); } catch (err) { - toast({ title: 'Failed to remove MFA', description: String(err), variant: 'error' }); + toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' }); } } @@ -287,7 +288,7 @@ function MfaEnforcementToggle() { await updateSettings.mutateAsync({ mfaRequired: false }); toast({ title: 'MFA requirement disabled for all members', variant: 'success' }); } catch (err) { - toast({ title: 'Failed to update MFA setting', description: String(err), variant: 'error' }); + toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' }); } } @@ -297,7 +298,7 @@ function MfaEnforcementToggle() { setConfirmEnable(false); toast({ title: 'MFA is now required for all members', variant: 'success' }); } catch (err) { - toast({ title: 'Failed to update MFA setting', description: String(err), variant: 'error' }); + toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' }); } } @@ -365,7 +366,7 @@ export function SettingsPage() { setNewPassword(''); setConfirmPassword(''); } catch (err) { - toast({ title: 'Failed to change password', description: String(err), variant: 'error' }); + toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' }); } } @@ -475,7 +476,7 @@ export function SettingsPage() { toast({ title: 'Server admin password reset successfully', variant: 'success' }); setServerAdminPw(''); } catch (err) { - toast({ title: 'Failed to reset server admin password', description: String(err), variant: 'error' }); + toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' }); } }} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }} diff --git a/ui/src/pages/tenant/SsoPage.tsx b/ui/src/pages/tenant/SsoPage.tsx index 94e22ac..9313cac 100644 --- a/ui/src/pages/tenant/SsoPage.tsx +++ b/ui/src/pages/tenant/SsoPage.tsx @@ -1,4 +1,5 @@ import { useState, useRef } from 'react'; +import { errorMessage } from '../../api/client'; import { Alert, AlertDialog, Badge, Button, Card, DataTable, EmptyState, FileInput, FormField, Input, Spinner, useToast, @@ -87,7 +88,7 @@ export function SsoPage() { toast({ title: `SSO connector "${connectorName}" created`, variant: 'success' }); resetForm(); } catch (err) { - toast({ title: 'Failed to create connector', description: String(err), variant: 'error' }); + toast({ title: 'Failed to create connector', description: errorMessage(err), variant: 'error' }); } } @@ -98,7 +99,7 @@ export function SsoPage() { toast({ title: `Deleted "${deleteTarget.connectorName}"`, variant: 'success' }); setDeleteTarget(null); } catch (err) { - toast({ title: 'Delete failed', description: String(err), variant: 'error' }); + toast({ title: 'Delete failed', description: errorMessage(err), variant: 'error' }); setDeleteTarget(null); } } @@ -112,7 +113,7 @@ export function SsoPage() { toast({ title: 'Connection test failed', description: 'IdP metadata could not be resolved', variant: 'warning' }); } } catch (err) { - toast({ title: 'Connection test failed', description: String(err), variant: 'error' }); + toast({ title: 'Connection test failed', description: errorMessage(err), variant: 'error' }); } } @@ -351,7 +352,7 @@ function CaCertificatesSection() { certRef.current?.clear(); setLabel(''); } catch (err) { - toast({ title: 'Upload failed', description: String(err), variant: 'error' }); + toast({ title: 'Upload failed', description: errorMessage(err), variant: 'error' }); } } @@ -360,7 +361,7 @@ function CaCertificatesSection() { await activateMutation.mutateAsync(id); toast({ title: 'CA certificate activated', variant: 'success' }); } catch (err) { - toast({ title: 'Activation failed', description: String(err), variant: 'error' }); + toast({ title: 'Activation failed', description: errorMessage(err), variant: 'error' }); } } @@ -371,7 +372,7 @@ function CaCertificatesSection() { toast({ title: 'CA certificate removed', variant: 'success' }); setDeleteTarget(null); } catch (err) { - toast({ title: 'Delete failed', description: String(err), variant: 'error' }); + toast({ title: 'Delete failed', description: errorMessage(err), variant: 'error' }); setDeleteTarget(null); } } diff --git a/ui/src/pages/tenant/TeamPage.tsx b/ui/src/pages/tenant/TeamPage.tsx index 29394f2..d6fee91 100644 --- a/ui/src/pages/tenant/TeamPage.tsx +++ b/ui/src/pages/tenant/TeamPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { errorMessage } from '../../api/client'; import { Alert, AlertDialog, @@ -131,7 +132,7 @@ export function TeamPage() { setInviteRole('viewer'); setShowInvite(false); } catch (err) { - toast({ title: 'Invite failed', description: String(err), variant: 'error' }); + toast({ title: 'Invite failed', description: errorMessage(err), variant: 'error' }); } } @@ -142,7 +143,7 @@ export function TeamPage() { toast({ title: `Removed ${removeTarget.name}`, variant: 'success' }); setRemoveTarget(null); } catch (err) { - toast({ title: 'Remove failed', description: String(err), variant: 'error' }); + toast({ title: 'Remove failed', description: errorMessage(err), variant: 'error' }); setRemoveTarget(null); } } @@ -269,7 +270,7 @@ export function TeamPage() { toast({ title: `MFA reset for ${mfaResetTarget.name || mfaResetTarget.email}`, variant: 'success' }); setMfaResetTarget(null); } catch (err) { - toast({ title: 'Failed to reset MFA', description: String(err), variant: 'error' }); + toast({ title: 'Failed to reset MFA', description: errorMessage(err), variant: 'error' }); } }} loading={resetMfa.isPending} @@ -297,7 +298,7 @@ export function TeamPage() { setPwTarget(null); setPwValue(''); } catch (err) { - toast({ title: 'Reset failed', description: String(err), variant: 'error' }); + toast({ title: 'Reset failed', description: errorMessage(err), variant: 'error' }); } }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }} diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx index 594609d..5d51839 100644 --- a/ui/src/pages/tenant/TenantDashboardPage.tsx +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -10,6 +10,7 @@ import { } from '@cameleer/design-system'; import { ArrowUpCircle, ExternalLink, Key, RefreshCw, Settings } from 'lucide-react'; import { useTenantDashboard, useRestartServer, useUpgradeServer } from '../../api/tenant-hooks'; +import { errorMessage } from '../../api/client'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; @@ -107,7 +108,7 @@ export function TenantDashboardPage() { await restartServer.mutateAsync(); toast({ title: 'Server restarted', variant: 'success' }); } catch (err) { - toast({ title: 'Restart failed', description: String(err), variant: 'error' }); + toast({ title: 'Restart failed', description: errorMessage(err), variant: 'error' }); } }} loading={restartServer.isPending} @@ -122,7 +123,7 @@ export function TenantDashboardPage() { await upgradeServer.mutateAsync(); toast({ title: 'Server upgrade started — pulling latest images', variant: 'success' }); } catch (err) { - toast({ title: 'Upgrade failed', description: String(err), variant: 'error' }); + toast({ title: 'Upgrade failed', description: errorMessage(err), variant: 'error' }); } }} loading={upgradeServer.isPending} diff --git a/ui/src/pages/vendor/CertificatesPage.tsx b/ui/src/pages/vendor/CertificatesPage.tsx index 3c95557..818b5ca 100644 --- a/ui/src/pages/vendor/CertificatesPage.tsx +++ b/ui/src/pages/vendor/CertificatesPage.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { errorMessage } from '../../api/client'; import { Alert, Badge, @@ -163,7 +164,7 @@ export function CertificatesPage() { toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' }); } } catch (err) { - toast({ title: 'Upload failed', description: String(err), variant: 'error' }); + toast({ title: 'Upload failed', description: errorMessage(err), variant: 'error' }); } } @@ -172,7 +173,7 @@ export function CertificatesPage() { await activateMutation.mutateAsync(); toast({ title: 'Certificate activated', variant: 'success' }); } catch (err) { - toast({ title: 'Activation failed', description: String(err), variant: 'error' }); + toast({ title: 'Activation failed', description: errorMessage(err), variant: 'error' }); } } @@ -181,7 +182,7 @@ export function CertificatesPage() { await restoreMutation.mutateAsync(); toast({ title: 'Certificate restored from archive', variant: 'success' }); } catch (err) { - toast({ title: 'Restore failed', description: String(err), variant: 'error' }); + toast({ title: 'Restore failed', description: errorMessage(err), variant: 'error' }); } } @@ -190,7 +191,7 @@ export function CertificatesPage() { await discardMutation.mutateAsync(); toast({ title: 'Staged certificate discarded', variant: 'success' }); } catch (err) { - toast({ title: 'Discard failed', description: String(err), variant: 'error' }); + toast({ title: 'Discard failed', description: errorMessage(err), variant: 'error' }); } } diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 350c027..138bfb2 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { Button, Card, FormField, Input, useToast } from '@cameleer/design-system'; import { useCreateTenant } from '../../api/vendor-hooks'; +import { errorMessage } from '../../api/client'; import { toSlug } from '../../utils/slug'; const TIERS = ['STARTER', 'TEAM', 'BUSINESS', 'ENTERPRISE']; @@ -35,7 +36,7 @@ export function CreateTenantPage() { toast({ title: 'Tenant created — provisioning in progress', variant: 'success' }); navigate(`/vendor/tenants/${result.id}`); } catch (err) { - toast({ title: 'Failed to create tenant', description: String(err), variant: 'error' }); + toast({ title: 'Failed to create tenant', description: errorMessage(err), variant: 'error' }); } } diff --git a/ui/src/pages/vendor/EmailConfigPage.tsx b/ui/src/pages/vendor/EmailConfigPage.tsx index 3eb0b4a..2dce2e1 100644 --- a/ui/src/pages/vendor/EmailConfigPage.tsx +++ b/ui/src/pages/vendor/EmailConfigPage.tsx @@ -9,6 +9,7 @@ import { useToast, } from '@cameleer/design-system'; import { Send, Trash2, Save, Power } from 'lucide-react'; +import { errorMessage } from '../../api/client'; import { useEmailConnector, useSaveEmailConnector, @@ -70,7 +71,7 @@ export function EmailConfigPage() { setEditing(false); setPassword(''); } catch (err) { - toast({ title: 'Failed to save', description: String(err), variant: 'error' }); + toast({ title: 'Failed to save', description: errorMessage(err), variant: 'error' }); } } @@ -81,7 +82,7 @@ export function EmailConfigPage() { setConfirmDelete(false); setEditing(false); } catch (err) { - toast({ title: 'Failed to delete', description: String(err), variant: 'error' }); + toast({ title: 'Failed to delete', description: errorMessage(err), variant: 'error' }); } } @@ -98,7 +99,7 @@ export function EmailConfigPage() { toast({ title: 'Test failed', description: result.message, variant: 'error' }); } } catch (err) { - toast({ title: 'Test failed', description: String(err), variant: 'error' }); + toast({ title: 'Test failed', description: errorMessage(err), variant: 'error' }); } } @@ -112,7 +113,7 @@ export function EmailConfigPage() { variant: 'success', }); } catch (err) { - toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' }); + toast({ title: 'Failed to toggle registration', description: errorMessage(err), variant: 'error' }); } } diff --git a/ui/src/pages/vendor/LicenseVerifyPage.tsx b/ui/src/pages/vendor/LicenseVerifyPage.tsx index 4443824..cf72a29 100644 --- a/ui/src/pages/vendor/LicenseVerifyPage.tsx +++ b/ui/src/pages/vendor/LicenseVerifyPage.tsx @@ -7,6 +7,7 @@ import { } from '@cameleer/design-system'; import { Clipboard, Search, ShieldCheck } from 'lucide-react'; import { useVerifyLicense, usePublicKey } from '../../api/vendor-hooks'; +import { errorMessage } from '../../api/client'; import styles from '../../styles/platform.module.css'; import type { VerifyLicenseResponse } from '../../types/api'; @@ -49,7 +50,7 @@ export function LicenseVerifyPage() { const res = await verifyLicense.mutateAsync(token.trim()); setResult(res); } catch (err) { - toast({ title: 'Verification failed', description: String(err), variant: 'error' }); + toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' }); } } diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index 00225a2..8e3bfa1 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -20,6 +20,7 @@ import { useRestartServer, useUpgradeServer, } from '../../api/vendor-hooks'; +import { errorMessage } from '../../api/client'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; @@ -155,7 +156,7 @@ export function TenantDetailPage() { variant: 'success', }); } catch (err) { - toast({ title: 'Minting failed', description: String(err), variant: 'error' }); + toast({ title: 'Minting failed', description: errorMessage(err), variant: 'error' }); } } @@ -200,7 +201,7 @@ export function TenantDetailPage() { toast({ title: 'Tenant suspended', variant: 'warning' }); } } catch (err) { - toast({ title: 'Action failed', description: String(err), variant: 'error' }); + toast({ title: 'Action failed', description: errorMessage(err), variant: 'error' }); } } @@ -210,7 +211,7 @@ export function TenantDetailPage() { await restartServer.mutateAsync(id); toast({ title: 'Server restarted', variant: 'success' }); } catch (err) { - toast({ title: 'Restart failed', description: String(err), variant: 'error' }); + toast({ title: 'Restart failed', description: errorMessage(err), variant: 'error' }); } } @@ -220,7 +221,7 @@ export function TenantDetailPage() { await upgradeServer.mutateAsync(id); toast({ title: 'Server upgrade started — pulling latest images', variant: 'success' }); } catch (err) { - toast({ title: 'Upgrade failed', description: String(err), variant: 'error' }); + toast({ title: 'Upgrade failed', description: errorMessage(err), variant: 'error' }); } } @@ -231,7 +232,7 @@ export function TenantDetailPage() { toast({ title: 'Tenant deleted', variant: 'success' }); navigate('/vendor/tenants'); } catch (err) { - toast({ title: 'Delete failed', description: String(err), variant: 'error' }); + toast({ title: 'Delete failed', description: errorMessage(err), variant: 'error' }); } }