Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/components/manageUsers/RoleChangeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import React, { RefObject } from "react";
import {
Alert,
Button,
ButtonGroup,
ModalFooter,
Expand All @@ -19,6 +20,7 @@ export interface RoleChangeModalProps {
nextRoleName: string;
onConfirm: () => void;
onCancel: () => void;
errorMessage?: string | null;
}

export function RoleChangeModal({
Expand All @@ -27,6 +29,7 @@ export function RoleChangeModal({
nextRoleName,
onConfirm,
onCancel,
errorMessage,
}: RoleChangeModalProps) {
const t = useTranslations("ManageUsers.confirmationModal");
const modalTitle = t("header");
Expand All @@ -41,6 +44,22 @@ export function RoleChangeModal({
onClose={onCancel}
>
<p className="font-sans-2xs margin-y-4">{modalDescription}</p>

{errorMessage && (
<div className="margin-bottom-2">
<Alert
slim
type="error"
noIcon
headingLevel="h6"
role="alert"
data-testid="role-change-error"
>
{errorMessage}
</Alert>
</div>
)}

<ModalFooter>
<ButtonGroup>
{isSubmitting ? (
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/manageUsers/RoleManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ export function RoleManager({
const [selectedRoleId, setSelectedRoleId] = useState(currentRoleId);
const [pendingRoleId, setPendingRoleId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const { clientFetch } = useClientFetch("Unable to update user role");
const { clientFetch } = useClientFetch(t("errorState"));

const modalRef = useRef<ModalRef | null>(null);

Expand All @@ -46,6 +47,7 @@ export function RoleManager({
: "";

const openModal = () => {
setErrorMessage(null);
modalRef.current?.toggleModal(undefined, true);
};

Expand All @@ -64,6 +66,7 @@ export function RoleManager({

const handleCancel = () => {
setPendingRoleId(null);
setErrorMessage(null);
closeModal();
};

Expand All @@ -74,6 +77,7 @@ export function RoleManager({
}

setIsSubmitting(true);
setErrorMessage(null);

try {
await clientFetch(
Expand All @@ -84,13 +88,12 @@ export function RoleManager({
},
);

// If we got here without throwing an error,
// we trust the role change
setSelectedRoleId(pendingRoleId);
setPendingRoleId(null);
closeModal();
} catch (error) {
console.error("Failed to update user role", error);
setErrorMessage(t("errorState"));
} finally {
setIsSubmitting(false);
}
Expand Down Expand Up @@ -130,6 +133,7 @@ export function RoleManager({
});
}}
onCancel={handleCancel}
errorMessage={errorMessage}
/>
</>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1555,7 +1555,8 @@ export const messages = {
"We have encountered an error retrieving the Invited Users list, please try again later.",
invitedUsersTableZeroState: "There are no invited users.",
roleManager: {
errorState: "Unable to fetch roles",
errorState:
"We were unable to make the change requested at this time. Please try again.",
cancel: "Cancel",
changeUserRole: "Change user role",
},
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/services/fetch/fetchers/organizationsFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
UserDetail,
UserRole,
} from "src/types/userTypes";
import { getBackendMessage } from "src/utils/apiUtils";

import { fetchOrganizationWithMethod, fetchUserWithMethod } from "./fetchers";

Expand Down Expand Up @@ -175,6 +176,34 @@ export const updateOrganizationUserRoles = async (
additionalHeaders: { "X-SGG-TOKEN": session.token },
body: { role_ids: roleIds },
});

if (!resp.ok) {
let backendMessage: string | undefined;

try {
const body: unknown = await resp.json();
backendMessage = getBackendMessage(body);
} catch {
console.warn("Failed to parse error body when updating org user roles");
}

if (resp.status === 401) {
throw new UnauthorizedError(
backendMessage ?? "No active session for updating user roles.",
);
}

const error = new Error(
`updateOrganizationUserRoles failed with status ${resp.status}${
backendMessage ? `: ${backendMessage}` : ""
}`,
);

(error as { status?: number }).status = resp.status;

throw error;
}

const json = (await resp.json()) as { data: UserDetail };
return json.data;
};
11 changes: 11 additions & 0 deletions frontend/src/utils/apiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,14 @@ export const respondWithTraceAndLogs =
logResponse(response);
return response;
};

// Safely returns the backend's message field from a
// parsed error response, or undefined if unavailable.
export function getBackendMessage(body: unknown): string | undefined {
if (!body || typeof body !== "object") {
return undefined;
}

const maybe = body as { message?: unknown };
return typeof maybe.message === "string" ? maybe.message : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ interface ButtonGroupProps {
children: ReactNode;
}

interface AlertProps {
children: ReactNode;
[key: string]: unknown;
}

jest.mock("@trussworks/react-uswds", () => {
const ModalFooter = ({ children }: ModalFooterProps) => (
<div className="usa-modal__footer" data-testid="modalFooter">
Expand Down Expand Up @@ -60,11 +65,22 @@ jest.mock("@trussworks/react-uswds", () => {
<ul className="usa-button-group">{children}</ul>
);

const Alert = ({ children, ...rest }: AlertProps) => {
const testId =
typeof rest["data-testid"] === "string" ? rest["data-testid"] : "alert";
return (
<div data-testid={testId} {...rest}>
{children}
</div>
);
};

return {
ModalFooter,
Button,
ModalToggleButton,
ButtonGroup,
Alert,
};
});

Expand Down Expand Up @@ -161,10 +177,26 @@ describe("RoleChangeModal", () => {
/>,
);

const confirmButton = screen.getByRole("button", { name: /saving/i });
expect(confirmButton).toBeDisabled();

const cancelButton = screen.getByRole("button", { name: "cancel" });
expect(cancelButton).toBeDisabled();
});

it("renders an error message when errorMessage is provided", () => {
const modalRef = { current: null };

render(
<RoleChangeModal
isSubmitting={false}
modalRef={modalRef}
nextRoleName="Viewer"
onConfirm={() => undefined}
onCancel={() => undefined}
errorMessage="Role cannot be changed"
/>,
);

const alert = screen.getByTestId("role-change-error");
expect(alert).toBeInTheDocument();
expect(alert).toHaveTextContent("Role cannot be changed");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,37 @@ describe("RoleManager", () => {

expect((select as HTMLSelectElement).value).toBe("role-2");
});

it("sets errorMessage on the modal when clientFetch rejects", async () => {
clientFetchMock.mockRejectedValueOnce(new Error("Server said no"));

render(
<RoleManager
organizationId={organizationId}
userId={userId}
currentRoleId="role-1"
roleOptions={roleOptions}
/>,
);

const select = screen.getByTestId("Select");

fireEvent.change(select, { target: { value: "role-2" } });

expect(lastModalProps).not.toBeNull();
if (!lastModalProps) {
throw new Error("Modal props not set");
}

lastModalProps.onConfirm();

await waitFor(() => {
expect(clientFetchMock).toHaveBeenCalledTimes(1);
});

// frontend - message
expect(lastModalProps.errorMessage).toBe("errorState");
// backend message - should not be surfaced
expect(lastModalProps.errorMessage).not.toBe("Server said no");
});
});
Loading