mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@tria-plc/iamapi-common throws BadRequestException with a raw, untranslated code as the message (e.g. "user_role_not_found", "unit_employee_limit_reached:5") — useErrorHandler's extractMessage returned that string verbatim, so removing an org/unit admin whose role row didn't match, or inviting past a unit's employee cap, toasted the raw backend code instead of an explanation. Adds a small code→i18n map checked before the raw-message fallback, shared by both the async and sync error handlers so every caller (org-admin removal, employee invites, etc.) picks it up for free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
import { useCallback } from "react";
|
|
import { toast } from "sonner";
|
|
import axios from "axios";
|
|
|
|
// Logs an error with a flat one-liner header (method/url/status) when it's an axios error,
|
|
// then the full error object on the next line for DevTools expansion.
|
|
const logError = (err: unknown): void => {
|
|
if (axios.isAxiosError(err)) {
|
|
const method = err.config?.method?.toUpperCase() ?? "?";
|
|
const url = err.config?.url ?? "?";
|
|
const status = err.response?.status ?? "no-response";
|
|
console.error(
|
|
`[API ERROR] ${method} ${url} → ${status}`,
|
|
err.response?.data,
|
|
);
|
|
return;
|
|
}
|
|
console.error("Error caught:", err);
|
|
};
|
|
|
|
// Returns the first non-empty string found in value (string, array, or nested object with message/error/detail). Never throws.
|
|
const extractMessage = (value: unknown): string | null => {
|
|
try {
|
|
if (value == null) return null;
|
|
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
const parts: string[] = [];
|
|
for (const item of value) {
|
|
const part = extractMessage(item);
|
|
if (part) parts.push(part);
|
|
}
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
const obj = value as Record<string, unknown>;
|
|
return (
|
|
extractMessage(obj.message) ??
|
|
extractMessage(obj.error) ??
|
|
extractMessage(obj.detail) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
return null;
|
|
} catch (extractError) {
|
|
console.warn("extractMessage: unexpected failure", extractError);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw,
|
|
// untranslated code string as the message — no i18n on that side — so it
|
|
// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map
|
|
// known codes to a friendly, translated message before falling back to the
|
|
// raw text. `unit_employee_limit_reached` carries its configured limit after
|
|
// a colon (e.g. "unit_employee_limit_reached:5").
|
|
const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:";
|
|
|
|
const mapIamErrorCode = (
|
|
raw: string | null,
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
): string | null => {
|
|
if (!raw) return null;
|
|
if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) {
|
|
return t("msg.unitEmployeeLimitReached", {
|
|
limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length),
|
|
});
|
|
}
|
|
if (raw === "user_role_not_found") return t("msg.userRoleNotFound");
|
|
return null;
|
|
};
|
|
|
|
// Maps an HTTP status code to the i18n key used when no backend message is available.
|
|
const statusKeyFor = (status: number | undefined): string => {
|
|
if (status === 400 || status === 422) return "msg.validationError";
|
|
if (status === 401) return "msg.authError";
|
|
if (status === 403) return "msg.permissionError";
|
|
if (status === 404) return "msg.notFoundError";
|
|
if (status === 413) return "msg.fileTooLarge";
|
|
if (status !== undefined && status >= 500 && status < 600) return "msg.serverError";
|
|
return "msg.genericError";
|
|
};
|
|
|
|
// Detects the bulk-upload structured error shape returned by the backend:
|
|
// { message: "duplicate_values_found", errors: { fieldName: "code: value" } }
|
|
// Returns { title, description } when matched, null otherwise. Description is
|
|
// one "field: value" pair per line, suitable for a Sonner toast `description`.
|
|
const extractBulkUploadInfo = (
|
|
data: unknown,
|
|
t: (key: string) => string,
|
|
): { title: string; description: string } | null => {
|
|
if (!data || typeof data !== "object") return null;
|
|
const d = data as { message?: unknown; errors?: unknown };
|
|
if (d.message !== "duplicate_values_found") return null;
|
|
if (!d.errors || typeof d.errors !== "object") return null;
|
|
|
|
const lines: string[] = [];
|
|
for (const [field, value] of Object.entries(
|
|
d.errors as Record<string, unknown>,
|
|
)) {
|
|
if (typeof value === "string" && value.length > 0) {
|
|
lines.push(`${field}: ${value}`);
|
|
}
|
|
}
|
|
if (lines.length === 0) return null;
|
|
|
|
// If `msg.duplicateValuesFound` isn't in translation files yet, t() returns
|
|
// the raw key. Detect that and fall back to a readable English string.
|
|
const translatedTitle = t("msg.duplicateValuesFound");
|
|
const title =
|
|
translatedTitle === "msg.duplicateValuesFound"
|
|
? "Duplicate values found"
|
|
: translatedTitle;
|
|
|
|
return { title, description: lines.join("\n") };
|
|
};
|
|
|
|
// Reads a Blob as JSON. Returns null on any failure.
|
|
const parseBlobBody = async (blob: Blob): Promise<unknown> => {
|
|
try {
|
|
const text = await blob.text();
|
|
return JSON.parse(text);
|
|
} catch (parseError) {
|
|
console.warn("useErrorHandler: failed to parse Blob error body", parseError);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const useErrorHandler = (
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
) => {
|
|
const getErrorMessage = useCallback(
|
|
async (err: unknown): Promise<string> => {
|
|
try {
|
|
let status: number | undefined;
|
|
|
|
if (typeof err === "object" && err !== null && "response" in err) {
|
|
const response = (err as any).response;
|
|
status = response?.status;
|
|
let data: unknown = response?.data;
|
|
|
|
if (data instanceof Blob) {
|
|
data = await parseBlobBody(data);
|
|
}
|
|
|
|
// Bulk-upload structured error short-circuit (highest priority).
|
|
const bulk = extractBulkUploadInfo(data, t);
|
|
if (bulk) return bulk.title;
|
|
|
|
const fromException =
|
|
extractMessage((data as any)?.exception?.response) ??
|
|
extractMessage((data as any)?.exception);
|
|
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
|
|
|
const fromData = extractMessage(data);
|
|
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
|
}
|
|
|
|
if (err instanceof Error) {
|
|
const fromError = extractMessage(err.message);
|
|
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
|
}
|
|
|
|
return t(statusKeyFor(status));
|
|
} catch (handlerError) {
|
|
console.error(
|
|
"useErrorHandler: unexpected error while extracting message",
|
|
handlerError
|
|
);
|
|
return t("msg.genericError");
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const handleError = useCallback(
|
|
async (err: unknown): Promise<string> => {
|
|
logError(err);
|
|
|
|
// Bulk-upload short-circuit: emit one toast with title + description,
|
|
// skipping the normal extraction path.
|
|
if (typeof err === "object" && err !== null && "response" in err) {
|
|
let data: unknown = (err as any).response?.data;
|
|
if (data instanceof Blob) {
|
|
data = await parseBlobBody(data);
|
|
}
|
|
const bulk = extractBulkUploadInfo(data, t);
|
|
if (bulk) {
|
|
toast.error(bulk.title, { description: bulk.description });
|
|
return bulk.title;
|
|
}
|
|
}
|
|
|
|
const message = await getErrorMessage(err);
|
|
toast.error(message);
|
|
return message;
|
|
},
|
|
[getErrorMessage, t]
|
|
);
|
|
|
|
return { getErrorMessage, handleError };
|
|
};
|
|
|
|
export const useClientErrorHandler = (
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
) => {
|
|
const getErrorMessage = useCallback(
|
|
(err: unknown): string => {
|
|
try {
|
|
let status: number | undefined;
|
|
|
|
if (typeof err === "object" && err !== null) {
|
|
const response = (err as any).response;
|
|
status = response?.status;
|
|
const data = response?.data;
|
|
|
|
// Bulk-upload structured error short-circuit (highest priority).
|
|
const bulk = extractBulkUploadInfo(data, t);
|
|
if (bulk) return bulk.title;
|
|
|
|
const fromException =
|
|
extractMessage(data?.exception?.response) ??
|
|
extractMessage(data?.exception);
|
|
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
|
|
|
const fromData = extractMessage(data);
|
|
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
|
|
|
const fromError = extractMessage((err as any).message);
|
|
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
|
}
|
|
|
|
return t(statusKeyFor(status));
|
|
} catch (handlerError) {
|
|
console.error(
|
|
"useClientErrorHandler: unexpected error while extracting message",
|
|
handlerError
|
|
);
|
|
return t("msg.genericError");
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const handleError = useCallback(
|
|
(err: unknown): string => {
|
|
logError(err);
|
|
|
|
// Bulk-upload short-circuit: emit one toast with title + description,
|
|
// skipping the normal extraction path.
|
|
if (typeof err === "object" && err !== null) {
|
|
const data = (err as any).response?.data;
|
|
const bulk = extractBulkUploadInfo(data, t);
|
|
if (bulk) {
|
|
toast.error(bulk.title, { description: bulk.description });
|
|
return bulk.title;
|
|
}
|
|
}
|
|
|
|
const message = getErrorMessage(err);
|
|
toast.error(message);
|
|
return message;
|
|
},
|
|
[getErrorMessage, t]
|
|
);
|
|
|
|
return { getErrorMessage, handleError };
|
|
};
|