improvement: error handeling structure changed and unified all across the app. user no longer sees inappropriate messages.

This commit is contained in:
2026-07-12 18:27:38 +03:30
parent 901d838a2c
commit fab5111aa8
45 changed files with 977 additions and 241 deletions

View File

@@ -828,5 +828,66 @@
"monthOctober": "October",
"monthNovember": "November",
"monthDecember": "December"
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",
"TIMEOUT": "The request took too long. Please try again.",
"AUTH_INVALID_CREDENTIALS": "Incorrect email or password.",
"AUTH_UNAUTHORIZED": "Your session has expired. Please sign in again.",
"AUTH_REFRESH_TOKEN_NOT_FOUND": "Your session has expired. Please sign in again.",
"AUTH_REFRESH_TOKEN_INVALID": "Your session has expired. Please sign in again.",
"AUTH_SESSION_EXPIRED": "Your session has expired. Please sign in again.",
"AUTH_USER_NOT_FOUND": "We could not find your account.",
"AUTH_ORG_NOT_SELECTED": "Please select an organization to continue.",
"AUTH_ORG_ACCESS_DENIED": "You do not have access to this organization.",
"AUTH_INVITATION_PENDING": "Your invitation is still pending. Ask an administrator to activate your account.",
"AUTH_REGISTRATION_EMAIL_EXISTS": "An account with this email already exists. Sign in to add a new organization from your account.",
"AUTH_REGISTRATION_MOBILE_EXISTS": "This mobile number is already registered.",
"AUTH_REGISTRATION_MOBILE_INVALID": "Please enter a valid mobile number.",
"AUTH_REGISTRATION_FAILED": "Registration could not be completed. Please try again.",
"AUTH_PASSWORD_INCORRECT": "Current password is incorrect.",
"AUTH_PASSWORD_RESET_EXPIRED": "Password reset verification expired. Please verify your mobile number again.",
"AUTH_VERIFICATION_CODE_INVALID": "Invalid or expired verification code.",
"AUTH_VERIFICATION_RATE_LIMIT": "Please wait a moment before requesting another code.",
"AUTH_SELECT_ORG_FIRST": "Select an organization before creating a new one.",
"AUTH_CREATE_ORG_OWNER_ONLY": "Only owners of the current organization can create new organizations.",
"AUTH_PASSWORD_CURRENT_REQUIRED": "Current password is required.",
"PERMISSION_DENIED": "You do not have permission to perform this action.",
"PERMISSION_ORG_MANAGE": "You do not have permission to manage organizations.",
"PERMISSION_CLINIC_ONLY": "This action is only available for clinic organizations.",
"PERMISSION_LAB_ONLY": "This action is only available for lab organizations.",
"PERMISSION_NOT_MEMBER": "You are not a member of this organization.",
"PERMISSION_OWNER_ONLY": "Only organization owners can perform this action.",
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "An active subscription is required to participate in treatments or tasks.",
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "Enable treatment participation before setting working hours.",
"PERMISSION_CLINIC_WORKING_HOURS": "Working hours are only available for clinic organizations.",
"PERMISSION_ACCESS_APPOINTMENTS": "You do not have access to appointments.",
"PERMISSION_EDIT_APPOINTMENTS": "You cannot create or modify appointments.",
"PERMISSION_ACCESS_TREATMENTS": "You do not have access to treatments.",
"PERMISSION_EDIT_TREATMENTS": "You cannot edit treatments.",
"PERMISSION_ACCESS_TASKS": "You do not have access to tasks.",
"PERMISSION_EDIT_TASKS": "You cannot edit tasks.",
"PERMISSION_ACCESS_CASES": "You do not have access to cases.",
"PERMISSION_ACCESS_STAFF": "You do not have access to staff management.",
"PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.",
"PERMISSION_ORG_NOT_FOUND": "Organization not found.",
"VALIDATION_FAILED": "Please check the form and try again.",
"VALIDATION_EMAIL_INVALID": "Please enter a valid email address.",
"VALIDATION_PASSWORD_TOO_SHORT": "Password is too short.",
"VALIDATION_PASSWORD_REQUIRED": "Password is required.",
"VALIDATION_MOBILE_INVALID": "Please enter a valid mobile number.",
"VALIDATION_NAME_TOO_SHORT": "Name is too short.",
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organization name is required.",
"VALIDATION_ORGANIZATION_TYPE_INVALID": "Please select a valid organization type.",
"VALIDATION_TOKEN_REQUIRED": "This link is invalid or incomplete.",
"VALIDATION_FIELD_REQUIRED": "Please fill in all required fields.",
"VALIDATION_LANGUAGE_INVALID": "Please select a supported language.",
"VALIDATION_INVALID_REQUEST": "The request contains invalid data.",
"NOT_FOUND": "The requested item was not found.",
"CONFLICT": "This action conflicts with existing data.",
"CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.",
"BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
}
}

View File

@@ -829,5 +829,66 @@
"monthOctober": "اکتبر",
"monthNovember": "نوامبر",
"monthDecember": "دسامبر"
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
"TIMEOUT": "درخواست بیش از حد طول کشید. لطفاً دوباره تلاش کنید.",
"AUTH_INVALID_CREDENTIALS": "ایمیل یا رمز عبور نادرست است.",
"AUTH_UNAUTHORIZED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
"AUTH_REFRESH_TOKEN_NOT_FOUND": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
"AUTH_REFRESH_TOKEN_INVALID": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
"AUTH_SESSION_EXPIRED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
"AUTH_USER_NOT_FOUND": "حساب کاربری یافت نشد.",
"AUTH_ORG_NOT_SELECTED": "لطفاً یک سازمان را انتخاب کنید.",
"AUTH_ORG_ACCESS_DENIED": "به این سازمان دسترسی ندارید.",
"AUTH_INVITATION_PENDING": "دعوت‌نامه شما هنوز فعال نشده است. از مدیر بخواهید حساب شما را فعال کند.",
"AUTH_REGISTRATION_EMAIL_EXISTS": "حسابی با این ایمیل وجود دارد. وارد شوید و از حساب خود سازمان جدید بسازید.",
"AUTH_REGISTRATION_MOBILE_EXISTS": "این شماره موبایل قبلاً ثبت شده است.",
"AUTH_REGISTRATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.",
"AUTH_REGISTRATION_FAILED": "ثبت‌نام انجام نشد. لطفاً دوباره تلاش کنید.",
"AUTH_PASSWORD_INCORRECT": "رمز عبور فعلی نادرست است.",
"AUTH_PASSWORD_RESET_EXPIRED": "زمان بازیابی رمز عبور تمام شده است. دوباره شماره موبایل را تأیید کنید.",
"AUTH_VERIFICATION_CODE_INVALID": "کد تأیید نامعتبر یا منقضی شده است.",
"AUTH_VERIFICATION_RATE_LIMIT": "لطفاً کمی صبر کنید و دوباره درخواست کد دهید.",
"AUTH_SELECT_ORG_FIRST": "قبل از ایجاد سازمان جدید، یک سازمان را انتخاب کنید.",
"AUTH_CREATE_ORG_OWNER_ONLY": "فقط مالک سازمان فعلی می‌تواند سازمان جدید ایجاد کند.",
"AUTH_PASSWORD_CURRENT_REQUIRED": "رمز عبور فعلی الزامی است.",
"PERMISSION_DENIED": "اجازه انجام این کار را ندارید.",
"PERMISSION_ORG_MANAGE": "اجازه مدیریت سازمان‌ها را ندارید.",
"PERMISSION_CLINIC_ONLY": "این عمل فقط برای کلینیک‌ها در دسترس است.",
"PERMISSION_LAB_ONLY": "این عمل فقط برای لابراتوارها در دسترس است.",
"PERMISSION_NOT_MEMBER": "عضو این سازمان نیستید.",
"PERMISSION_OWNER_ONLY": "فقط مالک سازمان می‌تواند این کار را انجام دهد.",
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "برای شرکت در درمان یا وظایف، اشتراک فعال لازم است.",
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "قبل از تنظیم ساعات کاری، مشارکت در درمان را فعال کنید.",
"PERMISSION_CLINIC_WORKING_HOURS": "ساعات کاری فقط برای کلینیک‌ها در دسترس است.",
"PERMISSION_ACCESS_APPOINTMENTS": "به نوبت‌ها دسترسی ندارید.",
"PERMISSION_EDIT_APPOINTMENTS": "نمی‌توانید نوبت ایجاد یا ویرایش کنید.",
"PERMISSION_ACCESS_TREATMENTS": "به درمان‌ها دسترسی ندارید.",
"PERMISSION_EDIT_TREATMENTS": "نمی‌توانید درمان‌ها را ویرایش کنید.",
"PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.",
"PERMISSION_EDIT_TASKS": "نمی‌توانید وظایف را ویرایش کنید.",
"PERMISSION_ACCESS_CASES": "به پرونده‌ها دسترسی ندارید.",
"PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.",
"PERMISSION_EDIT_STAFF": "نمی‌توانید ساعات کاری پرسنل را مدیریت کنید.",
"PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.",
"VALIDATION_FAILED": "لطفاً فرم را بررسی و دوباره تلاش کنید.",
"VALIDATION_EMAIL_INVALID": "لطفاً یک ایمیل معتبر وارد کنید.",
"VALIDATION_PASSWORD_TOO_SHORT": "رمز عبور کوتاه است.",
"VALIDATION_PASSWORD_REQUIRED": "رمز عبور الزامی است.",
"VALIDATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.",
"VALIDATION_NAME_TOO_SHORT": "نام کوتاه است.",
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "نام سازمان الزامی است.",
"VALIDATION_ORGANIZATION_TYPE_INVALID": "نوع سازمان معتبر انتخاب کنید.",
"VALIDATION_TOKEN_REQUIRED": "این لینک نامعتبر یا ناقص است.",
"VALIDATION_FIELD_REQUIRED": "لطفاً همه فیلدهای الزامی را پر کنید.",
"VALIDATION_LANGUAGE_INVALID": "زبان پشتیبانی‌شده انتخاب کنید.",
"VALIDATION_INVALID_REQUEST": "درخواست حاوی داده نامعتبر است.",
"NOT_FOUND": "مورد درخواستی یافت نشد.",
"CONFLICT": "این عمل با داده‌های موجود در تضاد است.",
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
}
}

View File

@@ -829,5 +829,66 @@
"monthOctober": "Oktober",
"monthNovember": "November",
"monthDecember": "December"
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",
"TIMEOUT": "Het verzoek duurde te lang. Probeer het opnieuw.",
"AUTH_INVALID_CREDENTIALS": "Onjuist e-mailadres of wachtwoord.",
"AUTH_UNAUTHORIZED": "Uw sessie is verlopen. Meld u opnieuw aan.",
"AUTH_REFRESH_TOKEN_NOT_FOUND": "Uw sessie is verlopen. Meld u opnieuw aan.",
"AUTH_REFRESH_TOKEN_INVALID": "Uw sessie is verlopen. Meld u opnieuw aan.",
"AUTH_SESSION_EXPIRED": "Uw sessie is verlopen. Meld u opnieuw aan.",
"AUTH_USER_NOT_FOUND": "We konden uw account niet vinden.",
"AUTH_ORG_NOT_SELECTED": "Selecteer een organisatie om door te gaan.",
"AUTH_ORG_ACCESS_DENIED": "U hebt geen toegang tot deze organisatie.",
"AUTH_INVITATION_PENDING": "Uw uitnodiging is nog in behandeling. Vraag een beheerder om uw account te activeren.",
"AUTH_REGISTRATION_EMAIL_EXISTS": "Er bestaat al een account met dit e-mailadres. Meld u aan om een nieuwe organisatie toe te voegen.",
"AUTH_REGISTRATION_MOBILE_EXISTS": "Dit mobiele nummer is al geregistreerd.",
"AUTH_REGISTRATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.",
"AUTH_REGISTRATION_FAILED": "Registratie kon niet worden voltooid. Probeer het opnieuw.",
"AUTH_PASSWORD_INCORRECT": "Huidig wachtwoord is onjuist.",
"AUTH_PASSWORD_RESET_EXPIRED": "Wachtwoordherstel is verlopen. Verifieer uw mobiele nummer opnieuw.",
"AUTH_VERIFICATION_CODE_INVALID": "Ongeldige of verlopen verificatiecode.",
"AUTH_VERIFICATION_RATE_LIMIT": "Wacht even voordat u opnieuw een code aanvraagt.",
"AUTH_SELECT_ORG_FIRST": "Selecteer een organisatie voordat u een nieuwe aanmaakt.",
"AUTH_CREATE_ORG_OWNER_ONLY": "Alleen eigenaren van de huidige organisatie kunnen nieuwe organisaties aanmaken.",
"AUTH_PASSWORD_CURRENT_REQUIRED": "Huidig wachtwoord is verplicht.",
"PERMISSION_DENIED": "U hebt geen toestemming voor deze actie.",
"PERMISSION_ORG_MANAGE": "U hebt geen toestemming om organisaties te beheren.",
"PERMISSION_CLINIC_ONLY": "Deze actie is alleen beschikbaar voor klinieken.",
"PERMISSION_LAB_ONLY": "Deze actie is alleen beschikbaar voor laboratoria.",
"PERMISSION_NOT_MEMBER": "U bent geen lid van deze organisatie.",
"PERMISSION_OWNER_ONLY": "Alleen organisatie-eigenaren kunnen dit doen.",
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "Een actief abonnement is vereist om deel te nemen aan behandelingen of taken.",
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "Schakel deelname aan behandelingen in voordat u werktijden instelt.",
"PERMISSION_CLINIC_WORKING_HOURS": "Werktijden zijn alleen beschikbaar voor klinieken.",
"PERMISSION_ACCESS_APPOINTMENTS": "U hebt geen toegang tot afspraken.",
"PERMISSION_EDIT_APPOINTMENTS": "U kunt geen afspraken maken of wijzigen.",
"PERMISSION_ACCESS_TREATMENTS": "U hebt geen toegang tot behandelingen.",
"PERMISSION_EDIT_TREATMENTS": "U kunt behandelingen niet bewerken.",
"PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.",
"PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.",
"PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.",
"PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.",
"PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.",
"PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.",
"VALIDATION_FAILED": "Controleer het formulier en probeer het opnieuw.",
"VALIDATION_EMAIL_INVALID": "Voer een geldig e-mailadres in.",
"VALIDATION_PASSWORD_TOO_SHORT": "Wachtwoord is te kort.",
"VALIDATION_PASSWORD_REQUIRED": "Wachtwoord is verplicht.",
"VALIDATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.",
"VALIDATION_NAME_TOO_SHORT": "Naam is te kort.",
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organisatienaam is verplicht.",
"VALIDATION_ORGANIZATION_TYPE_INVALID": "Selecteer een geldig organisatietype.",
"VALIDATION_TOKEN_REQUIRED": "Deze link is ongeldig of onvolledig.",
"VALIDATION_FIELD_REQUIRED": "Vul alle verplichte velden in.",
"VALIDATION_LANGUAGE_INVALID": "Selecteer een ondersteunde taal.",
"VALIDATION_INVALID_REQUEST": "Het verzoek bevat ongeldige gegevens.",
"NOT_FOUND": "Het gevraagde item is niet gevonden.",
"CONFLICT": "Deze actie conflicteert met bestaande gegevens.",
"CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
}
}

View File

@@ -20,7 +20,7 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { ToastStack } from '@/components/ui/shared/Toast';
import { useToast } from '@/lib/hooks/useToast';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
@@ -32,6 +32,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function AppointmentsPage() {
const t = useTranslations('appointments');
const tErrors = useTranslations('errors');
const tPatients = useTranslations('patients');
const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
@@ -105,7 +106,7 @@ export default function AppointmentsPage() {
if (gen !== scheduleLoadGen.current) {
return;
}
toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule')));
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
@@ -178,11 +179,7 @@ export default function AppointmentsPage() {
);
}
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: tPatients('errorSavePatient');
toast.showError(message);
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
} finally {
setSavingPatient(false);
}
@@ -248,13 +245,13 @@ export default function AppointmentsPage() {
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: activeEditingAppointment
? t('errorUpdate')
: t('errorSave');
toast.showError(message);
toast.showError(
getUserFacingError(
err,
tErrors,
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
),
);
} finally {
setSavingAppointment(false);
}
@@ -276,11 +273,7 @@ export default function AppointmentsPage() {
toast.showSuccess(t('successRemoved'));
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: t('errorDelete');
toast.showError(message);
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
} finally {
setDeletingAppointment(false);
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
@@ -35,6 +35,7 @@ const PAGE_SIZE = 20;
export default function CasesPage() {
const t = useTranslations('cases');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const toast = useToast();
@@ -112,7 +113,7 @@ export default function CasesPage() {
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
} finally {
setLoadingList(false);
}
@@ -127,7 +128,7 @@ export default function CasesPage() {
const response = await casesApi.getOne(caseId);
setSelectedCase(response.data);
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail')));
if (!options?.silent) {
setSelectedCase(null);
}
@@ -218,7 +219,7 @@ export default function CasesPage() {
setSelectedCase(response.data);
} catch (error: unknown) {
setSelectedCase(previousCase);
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingImportant(false);
}

View File

@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/shared/Input';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { ApiError } from '@/types/api';
import { getUserFacingError } from '@/components/shared/formatApiError';
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
@@ -42,6 +42,7 @@ type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const t = useTranslations('organizations');
const tErrors = useTranslations('errors');
const tNav = useTranslations('nav');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
@@ -49,14 +50,8 @@ export default function OrganizationsPage() {
const toast = useToast();
const formatApiMessage = useCallback(
(err: unknown): string => {
if (!err || typeof err !== 'object') return tCommon('errorGeneric');
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return tCommon('errorGeneric');
},
[tCommon],
(err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')),
[tCommon, tErrors],
);
const formatConnectionStatusLabel = useCallback(

View File

@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { ToastStack } from '@/components/ui/shared/Toast';
import { patientsApi } from '@/lib/api/patients';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { hasPermission } from '@/components/shared/permissions';
@@ -23,6 +23,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
export default function PatientsPage() {
const t = useTranslations('patients');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization } = useAuth();
const toast = useToast();
@@ -67,7 +68,7 @@ export default function PatientsPage() {
setSelectedPatient(freshSelected);
}
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
toast.showError(getUserFacingError(error, tErrors, t('errorLoadPatients')));
} finally {
setLoadingPatients(false);
}
@@ -98,7 +99,7 @@ export default function PatientsPage() {
);
}
} catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient')));
} finally {
setSavingPatient(false);
}

View File

@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/shared/Input';
import { Toast } from '@/components/ui/shared/Toast';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
type PasswordForm = {
@@ -26,6 +27,7 @@ type PasswordForm = {
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tErrors = useTranslations('errors');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
@@ -152,8 +154,7 @@ export default function AccountSettingsPage() {
await syncSessionAfterParticipationChange();
setSuccessMessage(t('participateEnabledTasks'));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
setParticipatesInTasks(false);
} finally {
setParticipationLoading(false);
@@ -180,8 +181,7 @@ export default function AccountSettingsPage() {
setRevokeConfirmOpen(false);
setPendingRevokeType(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
} finally {
setParticipationLoading(false);
}
@@ -214,8 +214,7 @@ export default function AccountSettingsPage() {
try {
await enableClinicParticipation(options);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
setError(message || t('participateUpdateFailed'));
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
throw err;
} finally {
setParticipationLoading(false);
@@ -249,8 +248,7 @@ export default function AccountSettingsPage() {
setSuccessMessage(t('passwordChanged'));
router.replace('/login');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('passwordChangeFailed');
setError(message || t('passwordChangeFailed'));
setError(getUserFacingError(err, tErrors, t('passwordChangeFailed')));
} finally {
setIsSubmitting(false);
}

View File

@@ -35,7 +35,7 @@ import { Input } from '@/components/ui/shared/Input';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Table } from '@/components/ui/shared/Table';
import { ToastStack } from '@/components/ui/shared/Toast';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList';
import { useToast } from '@/lib/hooks/useToast';
@@ -147,6 +147,7 @@ function PermissionGrid({
export default function StaffPage() {
const router = useRouter();
const t = useTranslations('staff');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const tFeatures = useTranslations('staff.features');
const tWorkingHours = useTranslations('staff.workingHours');
@@ -229,7 +230,7 @@ export default function StaffPage() {
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorLoadStaff')));
toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff')));
} finally {
setLoading(false);
}
@@ -304,7 +305,7 @@ export default function StaffPage() {
await load();
}
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}
@@ -387,7 +388,7 @@ export default function StaffPage() {
resetInviteForm();
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorSendInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite')));
} finally {
setInviteLoading(false);
}
@@ -410,7 +411,7 @@ export default function StaffPage() {
setEditWorkingHoursDays(state.days);
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours')));
toast.showError(getUserFacingError(e, tErrors, t('errorLoadWorkingHours')));
} finally {
setEditLoadingWorkingHours(false);
}
@@ -449,7 +450,7 @@ export default function StaffPage() {
setEditStep(1);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorUpdateMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember')));
} finally {
setEditLoading(false);
}
@@ -470,7 +471,7 @@ export default function StaffPage() {
setDisableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorDisableMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember')));
} finally {
setDisablingMembershipId(null);
}
@@ -487,7 +488,7 @@ export default function StaffPage() {
setEnableTarget(null);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorEnableMember')));
toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember')));
} finally {
setEnablingMembershipId(null);
}
@@ -600,7 +601,7 @@ export default function StaffPage() {
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
} finally {
setCopyingInviteMembershipId(null);
}

View File

@@ -17,7 +17,7 @@ import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
@@ -38,6 +38,7 @@ function formatPatientName(patient: { firstName: string; lastName: string }) {
export default function TasksPage() {
const t = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { currentOrganization, user, isAuthReady } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
@@ -107,7 +108,7 @@ export default function TasksPage() {
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
} finally {
setLoading(false);
}
@@ -127,7 +128,7 @@ export default function TasksPage() {
await tasksApi.updateStatus(taskId, status);
await loadTasks();
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { TodayDashboard } from '@/components/today/TodayDashboard';
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
@@ -13,6 +13,7 @@ import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
export default function TodayPage() {
const t = useTranslations('today');
const tErrors = useTranslations('errors');
const { currentOrganization } = useAuth();
const orgId = currentOrganization?.id;
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
@@ -54,7 +55,7 @@ export default function TodayPage() {
{error ? (
<TodayLoadErrorBanner
message={formatApiErrorMessage(error, t('loadError'))}
message={getUserFacingError(error, tErrors, t('loadError'))}
retryLabel={t('retryLoad')}
onRetry={() => void reload()}
isRetrying={loading && Boolean(data)}

View File

@@ -7,10 +7,12 @@ import { Link, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() {
const t = useTranslations('auth');
const tErrors = useTranslations('errors');
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
@@ -49,8 +51,7 @@ function AcceptInviteContent() {
setSuccess(t('invitationAlreadyAccepted'));
}
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorLoadInvitation'));
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
} finally {
setLoading(false);
}
@@ -86,8 +87,7 @@ function AcceptInviteContent() {
router.replace('/login');
}, 1000);
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorAcceptInvitation'));
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
} finally {
setSubmitting(false);
}

View File

@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { organizationApi } from '@/lib/api/organization';
type AcceptOrganizationInviteForm = {
@@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = {
function AcceptOrganizationInviteContent() {
const t = useTranslations('auth');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const params = useSearchParams();
@@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() {
setSuccess(t('invitationAlreadyAccepted'));
}
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorLoadInvitation'));
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
} finally {
setLoading(false);
}
@@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() {
setSuccess(t('organizationAcceptedRedirect'));
setTimeout(() => router.replace('/login'), 1000);
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
setError(message || t('errorAcceptInvitation'));
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
} finally {
setSubmitting(false);
}

View File

@@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { Phone, ShieldCheck } from 'lucide-react';
import { authApi } from '@/lib/api/auth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
import { Button } from '@/components/ui/shared/Button';
@@ -29,6 +30,7 @@ export default function ForgotPasswordPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const router = useRouter();
const { refreshSession } = useAuth();
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
@@ -76,8 +78,7 @@ export default function ForgotPasswordPage() {
setSentMobile(mobile);
setStep('code');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('codeSendFailed');
setError(message || t('codeSendFailed'));
setError(getUserFacingError(err, tErrors, t('codeSendFailed')));
} finally {
setIsSending(false);
}
@@ -116,8 +117,7 @@ export default function ForgotPasswordPage() {
await refreshSession();
router.push('/settings/account?reset=1');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('verifyFailed');
setError(message || t('verifyFailed'));
setError(getUserFacingError(err, tErrors, t('verifyFailed')));
} finally {
setIsVerifying(false);
}

View File

@@ -8,6 +8,7 @@ import * as z from 'zod';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Mail, Lock } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { getRememberedEmail } from '@/lib/auth/rememberMe';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
@@ -25,6 +26,7 @@ export default function LoginPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
@@ -67,8 +69,7 @@ export default function LoginPage() {
setError(null);
await login(data.email, data.password, data.rememberMe);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('invalidCredentials');
setError(message || t('invalidCredentials'));
setError(getUserFacingError(err, tErrors, t('invalidCredentials')));
}
};

View File

@@ -7,6 +7,7 @@ import * as z from 'zod';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Mail, Lock, User, Phone } from 'lucide-react';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
@@ -36,6 +37,7 @@ export default function RegisterPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
const tErrors = useTranslations('errors');
const { registerTrial, isLoading } = useAuth();
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
@@ -110,8 +112,7 @@ export default function RegisterPage() {
data.organizationType,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('registrationFailed');
setError(message || t('registrationFailed'));
setError(getUserFacingError(err, tErrors, t('registrationFailed')));
}
};

View File

@@ -1,12 +1,123 @@
export function formatApiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'message' in err) {
const m = (err as { message: unknown }).message;
if (Array.isArray(m)) {
return m.filter(Boolean).join(', ');
import { asApiError, isApiError, type ApiError, type ApiErrorDetail } from '@/types/api';
type TranslateFn = (key: string) => string;
const STATUS_FALLBACK_KEYS: Record<number, string> = {
401: 'AUTH_UNAUTHORIZED',
403: 'PERMISSION_DENIED',
404: 'NOT_FOUND',
409: 'CONFLICT',
500: 'INTERNAL_ERROR',
};
function translateKey(t: TranslateFn, key: string): string | null {
const translated = t(key);
return translated !== key ? translated : null;
}
function formatValidationDetails(details: ApiErrorDetail[], t: TranslateFn): string | null {
const messages = details
.map((detail) => translateKey(t, detail.code))
.filter((message): message is string => Boolean(message));
if (messages.length === 0) {
return null;
}
return messages.join(' ');
}
function resolveFromStatus(statusCode: number, t: TranslateFn): string | null {
const key = STATUS_FALLBACK_KEYS[statusCode] ?? (statusCode >= 500 ? 'INTERNAL_ERROR' : null);
return key ? translateKey(t, key) : null;
}
function normalizeLegacyError(value: unknown): ApiError | null {
if (isApiError(value)) {
return value;
}
if (typeof value === 'object' && value !== null && 'statusCode' in value) {
const statusCode =
typeof (value as { statusCode?: unknown }).statusCode === 'number'
? (value as { statusCode: number }).statusCode
: 500;
return {
statusCode,
code:
statusCode === 401
? 'AUTH_UNAUTHORIZED'
: statusCode === 403
? 'PERMISSION_DENIED'
: statusCode >= 500
? 'INTERNAL_ERROR'
: 'BAD_REQUEST',
};
}
if (value instanceof Error) {
if (value.message === 'Network Error') {
return { statusCode: 0, code: 'NETWORK_ERROR' };
}
if (typeof m === 'string' && m.trim()) {
return m;
if (value.message.includes('timeout')) {
return { statusCode: 0, code: 'TIMEOUT' };
}
}
return null;
}
/** Maps API error codes (and status fallbacks) to translated user-facing text. */
export function getUserFacingError(
err: unknown,
t: TranslateFn,
featureFallback?: string,
): string {
const apiError = asApiError(err) ?? normalizeLegacyError(err);
if (apiError?.code === 'VALIDATION_FAILED' && apiError.details?.length) {
const validationMessage = formatValidationDetails(apiError.details, t);
if (validationMessage) {
return validationMessage;
}
}
if (apiError?.code) {
const codeMessage = translateKey(t, apiError.code);
if (codeMessage) {
return codeMessage;
}
}
if (apiError?.statusCode) {
const statusMessage = resolveFromStatus(apiError.statusCode, t);
if (statusMessage) {
return statusMessage;
}
}
if (featureFallback?.trim()) {
return featureFallback;
}
return translateKey(t, 'GENERIC') ?? 'Something went wrong';
}
/** @deprecated Prefer getUserFacingError(err, tErrors, fallback). */
export function formatApiErrorMessage(
err: unknown,
fallback: string,
t?: TranslateFn,
): string {
if (t) {
return getUserFacingError(err, t, fallback);
}
const apiError = asApiError(err);
if (apiError?.code) {
return apiError.code;
}
return fallback;
}

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Eye, EyeOff, Send } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import type { LabCaseComment } from '@/types/cases';
interface LabCaseCommentsPanelProps {
@@ -36,6 +36,7 @@ export function LabCaseCommentsPanel({
onComposerValueChange,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const tErrors = useTranslations('errors');
const [comments, setComments] = useState<LabCaseComment[]>([]);
const [loading, setLoading] = useState(false);
const [posting, setPosting] = useState(false);
@@ -48,7 +49,7 @@ export function LabCaseCommentsPanel({
const items = await loadComments();
setComments(items);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorLoad')));
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
}
@@ -68,7 +69,7 @@ export function LabCaseCommentsPanel({
setBody('');
setVisibleToClinic(false);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorPost')));
onError?.(getUserFacingError(error, tErrors, t('errorPost')));
} finally {
setPosting(false);
}
@@ -87,7 +88,7 @@ export function LabCaseCommentsPanel({
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorToggle')));
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
}
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditCases } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
@@ -37,6 +37,7 @@ export function ConnectionCaseHistoryContent({
onBack,
}: ConnectionCaseHistoryContentProps) {
const t = useTranslations('organizations');
const tErrors = useTranslations('errors');
const tCases = useTranslations('cases');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
@@ -102,7 +103,7 @@ export function ConnectionCaseHistoryContent({
setPagination(response.data.pagination);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList')));
showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadList')));
} finally {
if (!cancelled) setLoadingList(false);
}
@@ -142,7 +143,7 @@ export function ConnectionCaseHistoryContent({
setSelectedCase(response.data);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail')));
showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadDetail')));
setSelectedCase(null);
} finally {
if (!cancelled) setLoadingDetail(false);
@@ -182,7 +183,7 @@ export function ConnectionCaseHistoryContent({
setSelectedCase(response.data);
} catch (error: unknown) {
setSelectedCase(previousCase);
showError(formatApiErrorMessage(error, tCases('errorUpdateTask')));
showError(getUserFacingError(error, tErrors, tCases('errorUpdateTask')));
} finally {
setUpdatingImportant(false);
}

View File

@@ -2,6 +2,7 @@
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
import { Building2, Beaker, Mail } from 'lucide-react';
@@ -12,13 +13,14 @@ export function OrganizationSelectorContent() {
const t = useTranslations('organizations');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const tErrors = useTranslations('errors');
const {
organizations,
currentOrganization,
selectOrganization,
createOrganization,
isLoading,
error,
apiError,
clearError,
} = useAuth();
const canCreateOrganization = useMemo(
@@ -82,6 +84,12 @@ export function OrganizationSelectorContent() {
)}
</div>
{apiError && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{getUserFacingError(apiError, tErrors)}</p>
</div>
)}
{canCreateOrganization && isCreateOpen && (
<div className="surface-card p-4 sm:p-6 space-y-4">
<Input
@@ -128,11 +136,6 @@ export function OrganizationSelectorContent() {
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex flex-col sm:flex-row sm:justify-end">
<Button
type="button"

View File

@@ -22,7 +22,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
@@ -264,6 +264,7 @@ export function TreatmentWorkspace({
initialAppointmentId = null,
}: TreatmentWorkspaceProps) {
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const router = useRouter();
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization);
@@ -493,7 +494,7 @@ export function TreatmentWorkspace({
}
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadAppointments')));
showError(getUserFacingError(error, tErrors, t('errorLoadAppointments')));
}
} finally {
if (!cancelled) setApptsLoading(false);
@@ -534,7 +535,7 @@ export function TreatmentWorkspace({
);
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
}
}
})();
@@ -563,7 +564,7 @@ export function TreatmentWorkspace({
if (!cancelled) setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
} finally {
if (!cancelled) setHistoryLoading(false);
@@ -621,7 +622,7 @@ export function TreatmentWorkspace({
setSaveStatus('idle');
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
showError(getUserFacingError(error, tErrors, t('errorLoadDraft')));
}
} finally {
if (!cancelled) {
@@ -693,7 +694,7 @@ export function TreatmentWorkspace({
setSaveStatus('saved');
} catch (error: unknown) {
setSaveStatus('error');
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
throw error;
} finally {
saveInFlightRef.current = false;
@@ -711,7 +712,7 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
@@ -864,7 +865,7 @@ export function TreatmentWorkspace({
);
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpload')));
showError(getUserFacingError(error, tErrors, t('errorUpload')));
} finally {
setUploadBusyDetailId(null);
}
@@ -954,7 +955,7 @@ export function TreatmentWorkspace({
const saved = await persistDraft({ force: true });
await persistLabCases(saved, cleaned);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
})();
}
@@ -999,7 +1000,7 @@ export function TreatmentWorkspace({
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
return;
}
@@ -1016,7 +1017,7 @@ export function TreatmentWorkspace({
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
}, [
activeDetailId,
@@ -1104,7 +1105,7 @@ export function TreatmentWorkspace({
});
showSuccess(t('successCaseSent'));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSendCase')));
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
} finally {
setSendBusyId(null);
}

View File

@@ -88,13 +88,34 @@ apiClient.interceptors.response.use(
}
}
const responseData = error.response?.data as
| { success?: false; error?: { code?: string; statusCode?: number; details?: ApiError['details'] } }
| { message?: string | string[] }
| undefined;
if (responseData && 'error' in responseData && responseData.error?.code) {
const apiError: ApiError = {
statusCode: responseData.error.statusCode ?? error.response?.status ?? 500,
code: responseData.error.code,
details: responseData.error.details,
};
return Promise.reject(apiError);
}
const apiError: ApiError = {
statusCode: error.response?.status || 500,
message:
(error.response?.data as any)?.message ||
error.message ||
'An unexpected error occurred',
error: (error.response?.data as any)?.error,
code:
error.response?.status === 401
? 'AUTH_UNAUTHORIZED'
: error.response?.status === 403
? 'PERMISSION_DENIED'
: error.response?.status === 404
? 'NOT_FOUND'
: error.response?.status === 409
? 'CONFLICT'
: (error.response?.status ?? 500) >= 500
? 'INTERNAL_ERROR'
: 'BAD_REQUEST',
};
return Promise.reject(apiError);

View File

@@ -16,6 +16,21 @@ import {
rememberAccessTokenExpiresAt,
startProactiveSessionRefresh,
} from '@/lib/auth/proactiveRefresh';
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
function toApiError(err: unknown): ApiError {
return (
asApiError(err) ?? {
statusCode: legacyStatusCode(err) ?? 500,
code:
legacyStatusCode(err) === 401
? 'AUTH_UNAUTHORIZED'
: legacyStatusCode(err) === 403
? 'PERMISSION_DENIED'
: 'INTERNAL_ERROR',
}
);
}
interface AuthContextType {
user: User | null;
@@ -23,7 +38,7 @@ interface AuthContextType {
currentOrganization: Organization | null;
isLoading: boolean;
isAuthReady: boolean;
error: string | null;
apiError: ApiError | null;
registerTrial: (
email: string,
password: string,
@@ -56,7 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [currentOrganization, setCurrentOrganization] = useState<Organization | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isAuthReady, setIsAuthReady] = useState(false); // ✅ KEY FIX
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState<ApiError | null>(null);
const router = useRouter();
@@ -135,9 +150,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}
} catch (err: unknown) {
const status =
(err as { statusCode?: number })?.statusCode ??
(err as { response?: { status?: number } })?.response?.status;
const status = legacyStatusCode(err);
// Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m).
if (status === 401) {
@@ -251,7 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) => {
try {
setIsLoading(true);
setError(null);
setApiError(null);
const response = await authApi.registerTrial({
email,
@@ -282,7 +295,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
} catch (err: any) {
setError(err.message || t('errorRegistrationFailed'));
setApiError(toApiError(err));
throw err;
} finally {
setIsLoading(false);
@@ -297,7 +310,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) => {
try {
setIsLoading(true);
setError(null);
setApiError(null);
const response = await authApi.login({ email, password, rememberMe });
@@ -325,7 +338,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
} catch (err: any) {
setError(err.message || t('errorLoginFailed'));
setApiError(toApiError(err));
throw err;
} finally {
setIsLoading(false);
@@ -348,7 +361,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null);
setOrganizations([]);
setCurrentOrganization(null);
setError(null);
setApiError(null);
clearAccessTokenExpiresAt();
setIsAuthReady(true);
router.replace('/');
@@ -387,7 +400,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
} catch (err: any) {
setError(err.message);
setApiError(toApiError(err));
throw err;
} finally {
setIsLoading(false);
@@ -402,7 +415,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) => {
try {
setIsLoading(true);
setError(null);
setApiError(null);
const createResponse = await authApi.createOrganization({
organizationName,
@@ -420,7 +433,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return createResponse.data.organization.id as string;
} catch (err: any) {
setError(err.message || t('errorCreateOrganization'));
setApiError(toApiError(err));
throw err;
} finally {
setIsLoading(false);
@@ -431,7 +444,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await checkAuth();
}, [checkAuth]);
const clearError = useCallback(() => setError(null), []);
const clearError = useCallback(() => setApiError(null), []);
const setUserLanguage = useCallback((language: string) => {
const normalized = isAppLocale(language) ? language : 'en';
@@ -445,7 +458,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
currentOrganization,
isLoading,
isAuthReady,
error,
apiError,
registerTrial,
login,
logout,
@@ -461,7 +474,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
currentOrganization,
isLoading,
isAuthReady,
error,
apiError,
registerTrial,
login,
logout,

View File

@@ -1,5 +1,66 @@
export interface ApiErrorDetail {
field: string;
code: string;
}
export interface ApiError {
statusCode: number;
message: string | string[];
error?: string;
code: string;
details?: ApiErrorDetail[];
}
export interface ApiErrorResponse {
success: false;
error: {
code: string;
statusCode: number;
details?: ApiErrorDetail[];
};
}
export function isApiError(value: unknown): value is ApiError {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
typeof (value as ApiError).code === 'string' &&
'statusCode' in value &&
typeof (value as ApiError).statusCode === 'number'
);
}
export function asApiError(value: unknown): ApiError | null {
if (isApiError(value)) {
return value;
}
if (
typeof value === 'object' &&
value !== null &&
'response' in value &&
typeof (value as { response?: { data?: ApiErrorResponse } }).response?.data === 'object'
) {
const payload = (value as { response: { data?: ApiErrorResponse } }).response.data;
if (payload?.success === false && payload.error?.code) {
return {
statusCode: payload.error.statusCode,
code: payload.error.code,
details: payload.error.details,
};
}
}
return null;
}
/** @deprecated Use ApiError.code — kept for transitional parsing only. */
export function legacyStatusCode(value: unknown): number | undefined {
if (isApiError(value)) {
return value.statusCode;
}
if (typeof value === 'object' && value !== null && 'statusCode' in value) {
const statusCode = (value as { statusCode?: unknown }).statusCode;
return typeof statusCode === 'number' ? statusCode : undefined;
}
return undefined;
}