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;