fix(ui): extract meaningful error messages from API responses
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m28s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 21:10:28 +02:00
parent 73e41e5607
commit 088bc34e67
11 changed files with 95 additions and 40 deletions

View File

@@ -6,6 +6,42 @@ export function setTokenProvider(provider: (() => Promise<string | undefined>) |
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = tokenProvider ? await tokenProvider() : null;
@@ -24,20 +60,20 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
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<T>(path, { method: 'PUT', body }),
delete: <T>(path: string) => apiFetch<T>(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);
}

View File

@@ -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 {

View File

@@ -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 }}

View File

@@ -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);
}
}

View File

@@ -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 }}

View File

@@ -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}

View File

@@ -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' });
}
}

View File

@@ -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' });
}
}

View File

@@ -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' });
}
}

View File

@@ -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' });
}
}

View File

@@ -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' });
}
}