diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx
index 0619402a..362a42b4 100644
--- a/src/authz-module/audit-user/index.tsx
+++ b/src/authz-module/audit-user/index.tsx
@@ -14,6 +14,7 @@ import {
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
import { useNavigate, useParams } from 'react-router-dom';
import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
+import { getHttpErrorStatus } from '@src/data/utils';
import baseMessages from '@src/authz-module/messages';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import {
@@ -22,6 +23,7 @@ import {
} from '@src/authz-module/components/TableCells';
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
+import type { RevokeUserRolesRequest } from '@src/authz-module/data/api';
import { RoleToDelete } from '@src/types';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
import UserPermissions from '@src/authz-module/components/UserPermissions';
@@ -79,8 +81,7 @@ const AuditUserPage = () => {
useEffect(() => {
if (!user && !isLoadingUser) {
- // @ts-ignore
- if (!isErrorUser || errorUser?.customAttributes?.httpErrorStatus === 404) {
+ if (!isErrorUser || getHttpErrorStatus(errorUser) === 404) {
navigate(AUTHZ_HOME_PATH);
}
}
@@ -166,7 +167,7 @@ const AuditUserPage = () => {
scope: roleToDelete.scope,
};
- const runRevokeRole = (variables) => {
+ const runRevokeRole = (variables: { data: RevokeUserRolesRequest }) => {
const variablesData = {
data: {
...variables.data,
diff --git a/src/authz-module/components/ErrorPage/index.tsx b/src/authz-module/components/ErrorPage/index.tsx
index 263865a7..fdf582ed 100644
--- a/src/authz-module/components/ErrorPage/index.tsx
+++ b/src/authz-module/components/ErrorPage/index.tsx
@@ -8,6 +8,7 @@ import {
import {
CustomErrors, ERROR_STATUS, STATUS_400, STATUS_404,
} from '@src/constants';
+import { getHttpErrorStatus } from '@src/data/utils';
import messages from './messages';
@@ -52,7 +53,7 @@ const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
const intl = useIntl();
const [reloading, setReloading] = useState(false);
- const errorStatus: number = error?.customAttributes?.httpErrorStatus;
+ const errorStatus = getHttpErrorStatus(error);
const errorMessage: string = error?.message;
const {
title, description, statusCode, showBackButton, showReloadButton,
diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
index d15adab4..b37fda34 100644
--- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
+++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
@@ -7,6 +7,7 @@ import {
} from '@openedx/paragon';
import { SpinnerSimple } from '@openedx/paragon/icons';
import { RoleMetadata } from '@src/types';
+import { getHttpErrorStatus } from '@src/data/utils';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep';
import DefineApplicationScopeStep from './components/DefineApplicationScopeStep';
@@ -141,7 +142,7 @@ const AssignRoleWizard = ({
}
} catch (error) {
// TODO: remove once the backend supports the permissions endpoint without a required scope.
- if ((error as any)?.customAttributes?.httpErrorStatus === 403) {
+ if (getHttpErrorStatus(error) === 403) {
showToast({
message: intl.formatMessage(messages['wizard.save.error.forbidden']),
type: 'error',
diff --git a/src/components/ToastManager/ToastManagerContext.tsx b/src/components/ToastManager/ToastManagerContext.tsx
index 8a40dd9b..df7f619f 100644
--- a/src/components/ToastManager/ToastManagerContext.tsx
+++ b/src/components/ToastManager/ToastManagerContext.tsx
@@ -1,11 +1,12 @@
import {
- createContext, useContext, useState, useMemo,
+ createContext, useContext, useState, useMemo, useCallback, useEffect, useRef,
} from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Toast } from '@openedx/paragon';
import messages from '@src/authz-module/messages';
import { DEFAULT_TOAST_DELAY, RETRY_TOAST_DELAY } from '@src/authz-module/constants';
+import { getHttpErrorStatus } from '@src/data/utils';
type ToastType = 'success' | 'error' | 'error-retry';
@@ -33,7 +34,7 @@ const Br = () =>
;
type ToastManagerContextType = {
showToast: (toast: Omit) => void;
- showErrorToast: (error, retryFn?: () => void) => void;
+ showErrorToast: (error: unknown, retryFn?: () => void) => void;
Bold: (chunks: React.ReactNode[]) => JSX.Element;
Br: () => JSX.Element;
};
@@ -47,26 +48,33 @@ interface ToastManagerProviderProps {
export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => {
const intl = useIntl();
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
+ const removalTimers = useRef[]>([]);
- const showToast = (toast: Omit) => {
+ const showToast = useCallback((toast: Omit) => {
const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
const newToast = { ...toast, id, visible: true };
setToasts(prev => [...prev, newToast]);
- };
+ }, []);
- const discardToast = (id: string) => {
+ const discardToast = useCallback((id: string) => {
setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t)));
- setTimeout(() => {
+ const timer = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
- }, 5000);
- };
+ }, DEFAULT_TOAST_DELAY);
+ removalTimers.current.push(timer);
+ }, []);
+
+ // Clear any pending removal timers when the provider unmounts.
+ useEffect(() => () => {
+ removalTimers.current.forEach(clearTimeout);
+ }, []);
const value = useMemo(() => {
- const showErrorToast = (error, retryFn?: () => void) => {
- logError(error);
- const errorStatus = error?.customAttributes?.httpErrorStatus;
- const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT;
+ const showErrorToast = (error: unknown, retryFn?: () => void) => {
+ logError(error as Error);
+ const errorStatus = getHttpErrorStatus(error);
+ const toastConfig = (errorStatus !== undefined && ERROR_TOAST_MAP[errorStatus]) || ERROR_TOAST_MAP.DEFAULT;
const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br });
/**
* For retryable errors, we set a longer delay to give users more time to read the message
@@ -90,7 +98,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
Bold,
Br,
});
- }, [intl]);
+ }, [intl, showToast]);
return (
diff --git a/src/data/utils.ts b/src/data/utils.ts
index 8676ba1a..e1536723 100644
--- a/src/data/utils.ts
+++ b/src/data/utils.ts
@@ -2,3 +2,11 @@ import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
+
+/**
+ * Safely reads the HTTP status that @edx/frontend-platform's HTTP client attaches
+ * to thrown errors. Returns `undefined` when no status is present.
+ */
+export const getHttpErrorStatus = (error: unknown): number | undefined => (
+ (error as PlatformError | null | undefined)?.customAttributes?.httpErrorStatus
+);
diff --git a/src/global.d.ts b/src/global.d.ts
index 9fb2f578..66c9f7c3 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -1,8 +1,20 @@
-// Module augmentations for external libraries.
+// Module augmentations and ambient platform types for external libraries.
// Application domain types belong in src/types.ts, not here.
export {};
+declare global {
+ /**
+ * Error shape produced by @edx/frontend-platform's HTTP client, which attaches
+ * the HTTP status under `customAttributes`. Read it with `getHttpErrorStatus`.
+ */
+ interface PlatformError extends Error {
+ customAttributes?: {
+ httpErrorStatus?: number;
+ };
+ }
+}
+
declare module '@openedx/paragon' {
export interface DataTableRow {
original: T;