diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js
index 1fd0ebf3..201a7d86 100644
--- a/src/components/mui/SnackbarNotification/index.js
+++ b/src/components/mui/SnackbarNotification/index.js
@@ -19,11 +19,17 @@ import {
Alert,
Box,
Collapse,
+ IconButton,
Link,
Snackbar,
+ Tooltip,
Typography
} from "@mui/material";
+import { visuallyHidden } from "@mui/utils";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import CheckIcon from "@mui/icons-material/Check";
+import CloseIcon from "@mui/icons-material/Close";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ErrorIcon from "@mui/icons-material/Error";
import SnackbarNotificationContext from "./Context";
export { useSnackbarMessage } from "./Context";
@@ -44,6 +50,13 @@ import { clearConsumedSnackbarMessage } from "../../../utils/actions";
const formatSentAt = (msg) =>
formatEpoch(msg.receivedAt / MILLISECONDS_IN_SECOND, "h:mm:ss a");
+const toCopyText = (msg) => {
+ const html = String(msg.html).replace(/
/gi, "\n");
+ const text = new DOMParser().parseFromString(html, "text/html").body
+ .textContent;
+ return [text.trim(), msg.details].filter(Boolean).join("\n\n");
+};
+
// a queue shows its first message, a group shows all of them at once
const useMessages = ({ grouped = false } = {}) => {
const [messages, setMessages] = useState([]);
@@ -64,17 +77,24 @@ const useMessages = ({ grouped = false } = {}) => {
// a blank message never opens, since it would block the queue
if (empty(msg.html)) return prev;
const now = Date.now();
- // skip a repeat unless the first is fading out; errors only within the dedupe window
- const isRepeat = prev.some(
+ // a repeat adds its details to the first, unless the first is fading out; errors only within the dedupe window
+ const repeat = prev.find(
(m) =>
!closingRef.current.includes(m) &&
m.html === msg.html &&
m.type === msg.type &&
(!grouped || now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW)
);
- if (isRepeat) return prev;
- const next = { ...msg, receivedAt: now };
- return [...prev, next];
+ if (!repeat) return [...prev, { ...msg, receivedAt: now }];
+ if (!msg.details) return prev;
+ return prev.map((m) =>
+ m === repeat
+ ? {
+ ...m,
+ details: [m.details, msg.details].filter(Boolean).join("\n\n")
+ }
+ : m
+ );
});
// closes what is on screen; anything newer shows once it has faded out
@@ -120,6 +140,45 @@ const NotificationSnackbar = ({ queue, vertical }) => {
queue.onExited();
};
+ // clipboard results for an earlier message are ignored
+ const [copyResult, setCopyResult] = useState(null);
+ const copyStatus = copyResult?.message === msgData ? copyResult.status : null;
+ // clipboard callbacks only see their own render, so they read a ref
+ const msgRef = useRef(msgData);
+ useEffect(() => {
+ msgRef.current = msgData;
+ });
+ const copyStatusKey = {
+ copied: "general.copied",
+ failed: "general.copy_failed"
+ }[copyStatus];
+ const copyLabel =
+ count > 1 ? "general.copy_all_errors" : "general.copy_error";
+ const handleCopy = () => {
+ // a late result for a message no longer on screen is dropped
+ const finish = (status) =>
+ msgRef.current === msgData && setCopyResult({ message: msgData, status });
+ // a group copies oldest first, each error under its position and sent time
+ const copyText =
+ count > 1
+ ? queue.shown
+ .map(
+ (m, i) =>
+ `[${i + 1}/${count}] ${formatSentAt(m)}\n${toCopyText(m)}`
+ )
+ .join("\n\n")
+ : toCopyText(msgData);
+ // the clipboard can be missing (non-https page) or blocked by the browser
+ try {
+ navigator.clipboard.writeText(copyText).then(
+ () => finish("copied"),
+ () => finish("failed")
+ );
+ } catch (e) {
+ finish("failed");
+ }
+ };
+
return (
{
>
+
+
+ {copyStatus === "copied" ? (
+
+ ) : (
+
+ )}
+
+
+ {/* a live region, since screen readers skip the tooltip change */}
+
+ {copyStatusKey && T.translate(copyStatusKey)}
+
+
+
+
+ >
+ )
+ }
severity={msgData.type}
variant="filled"
sx={{ width: "100%", backgroundColor: `${msgData.type}.light` }}
diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js
index 8b5d050b..e1e88173 100644
--- a/src/components/mui/__tests__/snackbar-notification.test.js
+++ b/src/components/mui/__tests__/snackbar-notification.test.js
@@ -32,6 +32,28 @@ import {
} from "../../../utils/constants";
describe("SnackbarNotification", () => {
+ let clearConsumedSnackbarMessage;
+
+ const ui = (snackbarMessage) => (
+
+ App
+
+ );
+
+ const mockClipboard = (writeText) =>
+ Object.assign(navigator, { clipboard: { writeText } });
+
+ beforeEach(() => {
+ clearConsumedSnackbarMessage = jest.fn();
+ });
+
+ afterEach(() => {
+ delete navigator.clipboard;
+ });
+
test("renders children", () => {
render(
@@ -78,19 +100,117 @@ describe("SnackbarNotification", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
+ test("copies an error as plain text with its details", async () => {
+ const writeText = jest.fn().mockResolvedValue();
+ mockClipboard(writeText);
+ render(
+ ui({
+ html: "Start date is required.
End date is invalid.
",
+ type: "warning",
+ details: "Request: PUT /api/v1/summits/1 (412)"
+ })
+ );
+
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+
+ expect(writeText).toHaveBeenCalledWith(
+ "Start date is required.\nEnd date is invalid.\n\nRequest: PUT /api/v1/summits/1 (412)"
+ );
+ // let the copied state settle inside the test
+ await screen.findByTestId("CheckIcon");
+ });
+
+ test("shows the check mark and announces Copied only after the clipboard accepts the text", async () => {
+ let accept;
+ mockClipboard(
+ jest.fn(
+ () =>
+ new Promise((resolve) => {
+ accept = resolve;
+ })
+ )
+ );
+ render(ui({ html: "Not found", type: "warning" }));
+
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+ expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+
+ accept();
+ expect(await screen.findByTestId("CheckIcon")).toBeInTheDocument();
+ expect(screen.getByRole("status")).toHaveTextContent("general.copied");
+ });
+
+ test("says the copy failed when the clipboard rejects it", async () => {
+ mockClipboard(jest.fn().mockRejectedValue(new Error("blocked")));
+ render(ui({ html: "Not found", type: "warning" }));
+
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+
+ expect(await screen.findByText("general.copy_failed")).toHaveAttribute(
+ "role",
+ "status"
+ );
+ expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
+ });
+
+ test("says the copy failed when there is no clipboard", () => {
+ render(ui({ html: "Not found", type: "warning" }));
+
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+
+ expect(screen.getByRole("status")).toHaveTextContent("general.copy_failed");
+ expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
+ });
+
+ test("keeps a newer copy's check mark when an earlier copy finishes late", async () => {
+ let acceptFirst;
+ mockClipboard(
+ jest
+ .fn()
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ acceptFirst = resolve;
+ })
+ )
+ .mockResolvedValueOnce()
+ );
+ const { rerender } = render(ui({ html: "First error", type: "warning" }));
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+ rerender(ui({ html: "Second error", type: "error" }));
+ fireEvent.click(screen.getByLabelText("general.copy_all_errors"));
+ await screen.findByTestId("CheckIcon");
+
+ await act(async () => {
+ acceptFirst();
+ });
+ expect(screen.getByTestId("CheckIcon")).toBeInTheDocument();
+ });
+
+ test("clears the copied state when a new message with the same text shows", async () => {
+ mockClipboard(jest.fn().mockResolvedValue());
+ const { rerender } = render(ui({ html: "Not found", type: "warning" }));
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+ await screen.findByTestId("CheckIcon");
+
+ // a repeat only shows once the copied one is closed
+ fireEvent.click(screen.getByRole("button", { name: /close/i }));
+ rerender(ui({ html: "Not found", type: "warning" }));
+
+ expect(await screen.findByTestId("ContentCopyIcon")).toBeInTheDocument();
+ expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
+ });
+
+ test("keeps only the close button on a success message", () => {
+ render(ui({ html: "Saved", type: "success" }));
+
+ expect(screen.queryByLabelText("general.copy_error")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Close")).toBeInTheDocument();
+ });
+
describe("pinned and grouped errors", () => {
const errorA = { html: "First error", type: "warning" };
const errorB = { html: "Second error", type: "error" };
- let clearConsumedSnackbarMessage;
-
- const ui = (snackbarMessage) => (
-
- App
-
- );
const advance = (ms) =>
act(() => {
@@ -109,7 +229,6 @@ describe("SnackbarNotification", () => {
beforeEach(() => {
jest.useFakeTimers();
- clearConsumedSnackbarMessage = jest.fn();
});
afterEach(() => jest.useRealTimers());
@@ -250,6 +369,42 @@ describe("SnackbarNotification", () => {
expect(items[1]).toHaveTextContent("First error");
});
+ test("copies every error in the group, oldest first", async () => {
+ const writeText = jest.fn().mockResolvedValue();
+ mockClipboard(writeText);
+ jest.setSystemTime(new Date(2026, 10, 10, 17, 18, 52));
+ const { rerender } = render(
+ ui({ ...errorA, details: "Request: PUT /api/v1/summits/1 (412)" })
+ );
+ jest.setSystemTime(new Date(2026, 10, 10, 17, 18, 55));
+ rerender(ui(errorB));
+
+ fireEvent.click(screen.getByLabelText("general.copy_all_errors"));
+
+ expect(writeText).toHaveBeenCalledWith(
+ "[1/2] 5:18:52 pm\nFirst error\n\nRequest: PUT /api/v1/summits/1 (412)\n\n[2/2] 5:18:55 pm\nSecond error"
+ );
+ await screen.findByTestId("CheckIcon");
+ });
+
+ test("adds a repeat's details to the error it repeats", async () => {
+ const writeText = jest.fn().mockResolvedValue();
+ mockClipboard(writeText);
+ const { rerender } = render(
+ ui({ ...errorA, details: "Request: GET /api/v1/summits (500)" })
+ );
+ rerender(
+ ui({ ...errorA, details: "Request: GET /api/v1/attendees (500)" })
+ );
+
+ fireEvent.click(screen.getByLabelText("general.copy_error"));
+
+ expect(writeText).toHaveBeenCalledWith(
+ "First error\n\nRequest: GET /api/v1/summits (500)\n\nRequest: GET /api/v1/attendees (500)"
+ );
+ await screen.findByTestId("CheckIcon");
+ });
+
test("ignores a repeat within the dedupe window", () => {
const { rerender } = render(ui(errorA));
rerender(ui({ ...errorA }));
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 88ebce24..c8024ef1 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -10,6 +10,11 @@
"active": "Active",
"archived": "Archived",
"select_summit": "Select a Summit...",
+ "copy_error": "Copy error details",
+ "copy_all_errors": "Copy all errors",
+ "copied": "Copied",
+ "copy_failed": "Couldn't copy",
+ "close": "Close",
"add": "Add",
"add_new": "Add New",
"clear": "Clear",
diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js
index 2482a485..28ea51e5 100644
--- a/src/utils/__tests__/actions.test.js
+++ b/src/utils/__tests__/actions.test.js
@@ -2,6 +2,8 @@ import {authErrorHandler, snackbarErrorHandler, clearConsumedSnackbarMessage, CL
import {setAuthHandlers} from "../../components/security/methods";
import { CLEAR_SESSION_STATE } from "../../components/security/actions";
import configureMockStore from 'redux-mock-store';
+import request from 'superagent/lib/client';
+import URI from "urijs";
import Swal from 'sweetalert2';
import thunk from 'redux-thunk'
@@ -208,6 +210,106 @@ describe('Utils Actions', () => {
// CLEAR_SESSION_STATE is only dispatched by initLogin
expect(types).toContain(CLEAR_SESSION_STATE);
});
+
+ test('adds request details without the query string', () => {
+ const err = {
+ status: 404,
+ response: {
+ body: { message: "Entity Not Found" },
+ req: { method: "PUT", url: "https://api.example.com/api/v1/summits/1?access_token=secret&expand=x" }
+ }
+ };
+
+ store.dispatch(snackbarErrorHandler(err));
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain("Request: PUT https://api.example.com/api/v1/summits/1 (404)");
+ expect(payload.details).not.toContain("secret");
+ });
+
+ test('copies the request and 4xx response bodies with sensitive fields redacted', () => {
+ const req = request.put("https://api.example.com/api/v1/summits/1/attendees/2").send({
+ ticket_type_id: 7,
+ send_speaker_email: true,
+ owner: { email: "jane@example.com", first_name: "Jane", phone_number: "555-0100", billing_address_1: "1 Main St" },
+ payment: { payment_info_details: { brand: "visa", last4: "4242" }, live_secret_key: "sk_live_123" },
+ extra_questions: [{ question_id: 3, answer: "1990-01-01" }],
+ client: { contact_name: "Ann Lee" },
+ qr_code: "BDG|123|jane@example.com|Jane Doe",
+ access_token: "abc"
+ });
+ const body = { message: "Validation Failed", errors: { start_date: ["The start date field is required."] }, user_password: "password2" };
+
+ store.dispatch(snackbarErrorHandler({ status: 412, response: { body, req } }));
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain('"ticket_type_id": 7');
+ expect(payload.details).toContain('"send_speaker_email": true');
+ expect(payload.details).toContain("The start date field is required.");
+ ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", "Ann Lee", "BDG|123", '"abc"', "password2"]
+ .forEach((value) => expect(payload.details).not.toContain(value));
+ });
+
+ test('skips 5xx response bodies and truncates long payloads', () => {
+ const req = request.post("https://api.example.com/api/v1/summits").send({ notes: "x".repeat(5000) });
+
+ store.dispatch(snackbarErrorHandler({ status: 500, response: { body: { trace: "debug output" }, req } }));
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain("… (truncated)");
+ expect(payload.details).not.toContain("x".repeat(2001));
+ expect(payload.details).not.toContain("debug output");
+ });
+
+ test('still notifies and re-logs in when the details cannot be built', () => {
+ windowSpy.mockImplementation(() => ({
+ location: {
+ href: 'https://example.com',
+ pathname:'/',
+ replace: () => {}
+ },
+ localStorage: localStorageMock
+ }));
+ const err = { status: 401, get response() { throw new Error("unreadable"); } };
+
+ store.dispatch(snackbarErrorHandler(err));
+
+ const actions = store.getActions();
+ const { payload } = actions.find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toBe("");
+ expect(actions.map((a) => a.type)).toContain(CLEAR_SESSION_STATE);
+ });
+
+ test('flags a missing response when the browser is offline', () => {
+ const onLineSpy = jest.spyOn(navigator, "onLine", "get").mockReturnValue(false);
+
+ store.dispatch(snackbarErrorHandler({ method: "GET", url: "https://api.example.com/api/v1/summits" }));
+ onLineSpy.mockRestore();
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain("Request: GET https://api.example.com/api/v1/summits (no response, offline)");
+ });
+
+ test('builds details for a post request made with a URI object', () => {
+ // postRequest, deleteRequest, postFile and putFile hand superagent a URI, not a string
+ const req = request.post(URI("https://api.example.com/api/v1/summits").query({ access_token: "secret" })).send({ name: "Summit" });
+
+ store.dispatch(snackbarErrorHandler({ status: 412, response: { body: { errors: ["Name taken."] }, req } }));
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain("Request: POST https://api.example.com/api/v1/summits (412)");
+ expect(payload.details).toContain('"name": "Summit"');
+ expect(payload.details).not.toContain("secret");
+ });
+
+ test('says the request timed out', () => {
+ const err = Object.assign(new Error("Timeout of 60000ms exceeded"), { timeout: 60000, code: "ECONNABORTED" });
+
+ store.dispatch(snackbarErrorHandler(err));
+
+ const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE);
+ expect(payload.details).toContain("Request: (timed out)");
+ });
});
describe('clearConsumedSnackbarMessage', () => {
diff --git a/src/utils/actions.js b/src/utils/actions.js
index afe89cf9..f2f5a1f2 100644
--- a/src/utils/actions.js
+++ b/src/utils/actions.js
@@ -18,10 +18,10 @@ URI.escapeQuerySpace = false;
let http = request;
import Swal from 'sweetalert2';
import T from "i18n-react/dist/i18n-react";
-import { isClearingSessionState, setSessionClearingState, getCurrentPathName } from './methods';
+import { isClearingSessionState, setSessionClearingState, getCurrentPathName, formatEpoch } from './methods';
import { CLEAR_SESSION_STATE } from '../components/security/actions';
import { doLogin, initLogOut, getAuthHandlers } from '../components/security/methods';
-import {CODE_200} from "./constants";
+import {CODE_200, ERROR_DETAILS_MAX_LENGTH, MILLISECONDS_IN_SECOND} from "./constants";
export const GENERIC_ERROR = "Yikes. Something seems to be broken. Our web team has been notified, and we apologize for the inconvenience.";
export const RESET_LOADING = 'RESET_LOADING';
@@ -62,8 +62,46 @@ const notifySnackbarError = (message, callback) => (dispatch) => {
if (typeof callback === "function") callback();
};
+// best-effort redaction by key name of secrets, payment details and personal information
+const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|contact_name|qr_code|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i;
+
+const formatPayload = (label, data) => {
+ // strings can't be redacted by key, so only objects are copied
+ if (data == null || typeof data !== "object" || !Object.keys(data).length) return "";
+ const json = JSON.stringify(data, (key, value) => (typeof value !== "boolean" && SENSITIVE_KEYS.test(key) ? "[REDACTED]" : value), 2);
+ const text = json.length > ERROR_DETAILS_MAX_LENGTH ? `${json.slice(0, ERROR_DETAILS_MAX_LENGTH)}… (truncated)` : json;
+ return `${label}: ${text}`;
+};
+
+// copyable context for bug reports; query strings are dropped since they carry the access_token
+const buildErrorDetails = (err) => {
+ const req = err.response?.req || err;
+ // post, delete and file requests keep the URI object superagent was given
+ const url = req.url ? String(req.url).split("?")[0] : "";
+ // timeouts carry no method or url, only the timeout
+ const status = err.status || (err.timeout && "timed out") || (navigator.onLine ? "no response" : "no response, offline");
+ // 5xx bodies can hold debug output, so only 4xx responses are copied
+ const isClientError = err.status >= 400 && err.status < 500;
+ const now = new Date();
+ return [
+ `Request: ${[req.method, url, `(${status})`].filter(Boolean).join(" ")}`,
+ formatPayload("Request body", req.toJSON?.().data),
+ isClientError ? formatPayload("Response body", err.response?.body) : "",
+ `Page: ${window.location.pathname}`,
+ `Time: ${now.toISOString()} (${formatEpoch(now.getTime() / MILLISECONDS_IN_SECOND, "M/D/YYYY h:mm:ss a [UTC]Z")})`
+ ].filter(Boolean).join("\n");
+};
+
export const snackbarErrorHandler = (err, res) => (dispatch, state) => {
- authErrorHandler(err, res, notifySnackbarError)(dispatch, state);
+ let details;
+ // details are optional; a failure here must not stop the snackbar or the 401 re-login
+ try {
+ details = buildErrorDetails(err);
+ } catch (e) {
+ details = "";
+ }
+ const notifyWithDetails = (message, callback) => notifySnackbarError({ ...message, details }, callback);
+ authErrorHandler(err, res, notifyWithDetails)(dispatch, state);
};
export const snackbarSuccessHandler = (message) => (dispatch, state) =>
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 050d30df..af8e33b9 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -12,6 +12,7 @@ export const DEBOUNCE_WAIT = 500;
export const NOTIFICATION_TIMEOUT = 6000;
export const NOTIFICATION_DEDUPE_WINDOW = 1000;
+export const ERROR_DETAILS_MAX_LENGTH = 2000;
export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000;
export const DEFAULT_PER_PAGE = 10;
export const TWENTY_PER_PAGE = 20;