diff --git a/src/client/components/common/Alert/Alert.tsx b/src/client/components/common/Alert/Alert.tsx index d4e9e5e6..76dac113 100644 --- a/src/client/components/common/Alert/Alert.tsx +++ b/src/client/components/common/Alert/Alert.tsx @@ -3,10 +3,15 @@ import { Severity } from '../../../../server/checkResult'; import SuccessIcon from './sucess.svg'; import ErrorIcon from './error.svg'; import WarningIcon from './warning.svg'; +import InfoIcon from '../../../../client/img/InfoIcon.svg'; import styles from './alert.module.scss'; import Image from 'next/image'; import classNames from 'classnames'; import { TEST_IDS } from '../../../testIDs'; +import { CustomContentProps, VariantType, useSnackbar, SnackbarKey } from 'notistack'; +import React from 'react'; +import Button from '../Button/Button'; +import { CrossIconSvg } from '../../../img/crossIconSvg'; type AlertProps = { severity: Severity; @@ -14,31 +19,87 @@ type AlertProps = { dataTestId?: string; } & PropsWithChildren; -const STYLES_MAP: Record = { +const STYLES_MAP: Record = { error: styles.error, warning: styles.warning, success: styles.success, + default: styles.info, + info: styles.info, }; -const ICON_MAP: Record = { - error: ErrorIcon, - warning: WarningIcon, - success: SuccessIcon, +export const ALERT_ICON_MAP: Record = { + error: , + warning: , + success: , + default: , + info: , }; -const Alert: FunctionComponent = ({ severity, children, className, dataTestId }) => { +/** + * Static on-page alert/notification + */ +export const Alert: FunctionComponent = ({ severity, children, className, dataTestId }) => { return (
-
- -
+
{ALERT_ICON_MAP[severity]}
{children}
); }; -export default Alert; +/** + * Custom `notistack` snackbar/popup (visually similar to the static alert above, but a separate component) + */ +export const CustomSnackbar = React.forwardRef((props, ref) => { + const { + // You have access to notistack props and options 👇🏼 + id, + action, + message, + variant, + className, + ...other + } = props; + + return ( +
+
+
{ALERT_ICON_MAP[variant]}
+
{message}
+
+
{typeof action === 'function' ? action(id) : action}
+
+ ); +}); + +CustomSnackbar.displayName = 'CustomSnackbar'; + +export function CloseSnackbarButton({ snackbarId }: { snackbarId: SnackbarKey }) { + const { closeSnackbar } = useSnackbar(); + + return ( + <> +
+ closeSnackbar(snackbarId)} /> +
+ + + ); +} diff --git a/src/client/components/common/Alert/alert.module.scss b/src/client/components/common/Alert/alert.module.scss index c2b9436b..b671875b 100644 --- a/src/client/components/common/Alert/alert.module.scss +++ b/src/client/components/common/Alert/alert.module.scss @@ -33,14 +33,100 @@ .success { background: v('success-background'); color: v('success-text'); + + &.withBorder { + border: 1px solid v('success-text'); + } } .error { background: v('error-background'); color: v('error-text'); + + &.withBorder { + border: 1px solid v('error-text'); + } } .warning { background: v('warning-background'); color: v('warning-text'); + + &.withBorder { + border: 1px solid v('warning-text'); + } +} + +.info { + background: v('info-background'); + color: v('info-text'); + + &.withBorder { + border: 1px solid v('info-text'); + } +} + +.snackbar { + display: flex; + flex-direction: row; + padding: 12px; + align-items: center; + gap: 12px; + flex-wrap: nowrap; + border-radius: 6px; + font-size: 14px; + line-height: 160%; + letter-spacing: 0.14px; + font-weight: 500; + + @include media('<=phone') { + flex-direction: column; + align-items: flex-start; + } +} + +.snackbarContent { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + + .iconWrapper { + display: flex; + } + + .message { + align-self: center; + } +} + +.snackbarActions { + display: flex; + flex-direction: row; + margin-left: auto; + align-self: flex-start; + gap: 12px; +} + +.closeButton { + color: inherit; + margin-left: auto; + text-transform: capitalize !important; + @include media('>=phone') { + display: none; + } +} + +.closeIcon { + cursor: pointer; + padding: 0px 3px; + margin-left: auto; + // height matching action button (even if it's not there) to keep the snackbar height consistent and everything aligned + min-height: 37px; + display: flex; + align-items: center; + + @include media('<=phone') { + display: none; + } } diff --git a/src/client/hooks/personalization/use-personalization-notification.tsx b/src/client/hooks/personalization/use-personalization-notification.tsx index 002aa1ed..dbcd6f59 100644 --- a/src/client/hooks/personalization/use-personalization-notification.tsx +++ b/src/client/hooks/personalization/use-personalization-notification.tsx @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { SnackbarKey, useSnackbar } from 'notistack'; -import Button from '@mui/material/Button'; import { useCopyToClipboard } from 'react-use'; +import Button from '../../components/common/Button/Button'; +import { CloseSnackbarButton } from '../../components/common/Alert/Alert'; export function usePersonalizationNotification() { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); @@ -26,18 +27,16 @@ export function usePersonalizationNotification() { action: (snackbarId) => ( <> - + ), }); diff --git a/src/client/hooks/useReset/useReset.tsx b/src/client/hooks/useReset/useReset.tsx index 955ba368..8b35ceea 100644 --- a/src/client/hooks/useReset/useReset.tsx +++ b/src/client/hooks/useReset/useReset.tsx @@ -20,8 +20,8 @@ export const useReset = ({ onError, onSuccess }: UseResetParams) => { const resetMutation = useMutation( 'resetMutation', async () => { - const { visitorId, requestId } = await getData(); - const body: ResetRequest = { visitorId, requestId }; + const { requestId } = await getData(); + const body: ResetRequest = { requestId }; return fetch('/api/admin/reset', { method: 'POST', @@ -46,7 +46,18 @@ export const useReset = ({ onError, onSuccess }: UseResetParams) => { ((data) => { enqueueSnackbar(
-

Scenarios reset successfully!

{data.message}

+

+ Scenarios reset successfully! +

{' '} +
    +
  • Deleted {data.result?.deletedCouponsClaims} coupon claims.
  • +
  • Deleted {data.result?.deletedLoginAttempts} login attempts.
  • +
  • Deleted {data.result?.deletedLoanRequests} loan requests.
  • +
  • Deleted {data.result?.deletedPaymentAttempts} payment attempts.
  • +
  • Deleted {data.result?.deletedArticleViews} article views.
  • +
  • Deleted {data.result?.deletedPersonalizationRecords} personalization records.
  • +
  • Deleted {data.result?.deletedBlockedIps} blocked IPs.
  • +
, { variant: 'success', diff --git a/src/client/hooks/useReset/userReset.module.scss b/src/client/hooks/useReset/userReset.module.scss index 2174e8a3..81176f6d 100644 --- a/src/client/hooks/useReset/userReset.module.scss +++ b/src/client/hooks/useReset/userReset.module.scss @@ -1,3 +1,7 @@ .snackbar { max-width: rem(600px); + + p:first-child { + margin-top: 2px; + } } diff --git a/src/client/img/crossIconSvg.tsx b/src/client/img/crossIconSvg.tsx new file mode 100644 index 00000000..b5012b7b --- /dev/null +++ b/src/client/img/crossIconSvg.tsx @@ -0,0 +1,14 @@ +import { FunctionComponent, ComponentProps } from 'react'; + +export const CrossIconSvg: FunctionComponent> = (props) => ( + + + +); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c941b203..912609f5 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,7 +2,6 @@ import '../styles/global-styles.scss'; import { QueryClient, QueryClientProvider } from 'react-query'; import Head from 'next/head'; import { SnackbarProvider } from 'notistack'; -import { SnackbarAction } from '../client/components/snackbar-action'; import { FpjsProvider, FingerprintJSPro } from '@fingerprintjs/fingerprintjs-pro-react'; import { AppProps } from 'next/app'; import Header from '../client/components/common/Header/Header'; @@ -12,6 +11,8 @@ import Footer from '../client/components/common/Footer/Footer'; import styles from '../styles/layout.module.scss'; import { PUBLIC_API_KEY, SCRIPT_URL_PATTERN, ENDPOINT, FRONTEND_REGION, CUSTOM_TLS_ENDPOINT } from '../server/const'; +import { CloseSnackbarButton, CustomSnackbar } from '../client/components/common/Alert/Alert'; + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -44,13 +45,20 @@ function CustomApp({ Component, pageProps }: AppProps) { return ( } - maxSnack={3} + action={(snackbarId) => } + maxSnack={4} autoHideDuration={5000} anchorOrigin={{ horizontal: 'left', vertical: 'bottom', }} + Components={{ + default: CustomSnackbar, + success: CustomSnackbar, + error: CustomSnackbar, + warning: CustomSnackbar, + info: CustomSnackbar, + }} > diff --git a/src/pages/api/admin/reset.ts b/src/pages/api/admin/reset.ts index 80cacad4..4adea9fc 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/pages/api/admin/reset.ts @@ -1,9 +1,4 @@ -import { - Severity, - ensureValidRequestIdAndVisitorId, - getIdentificationEvent, - messageSeverity, -} from '../../../server/server'; +import { Severity, isValidPostRequest } from '../../../server/server'; import { LoginAttemptDbModel } from '../credential-stuffing/authenticate'; import { PaymentAttemptDbModel } from '../payment-fraud/place-order'; import { @@ -14,111 +9,81 @@ import { import { LoanRequestDbModel } from '../../../server/loan-risk/database'; import { ArticleViewDbModel } from '../../../server/paywall/database'; import { CouponClaimDbModel } from '../../../server/coupon-fraud/database'; -import { CheckResult, checkResultType } from '../../../server/checkResult'; -import { - RuleCheck, - checkConfidenceScore, - checkFreshIdentificationRequest, - checkIpAddressIntegrity, - checkOriginsIntegrity, -} from '../../../server/checks'; -import { sendForbiddenResponse, sendOkResponse } from '../../../server/response'; +import { getAndValidateFingerprintResult } from '../../../server/checks'; import { NextApiRequest, NextApiResponse } from 'next'; -import { isVisitorsError } from '@fingerprintjs/fingerprintjs-pro-server-api'; import { deleteBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabase'; import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper'; export type ResetResponse = { message: string; severity?: Severity; - type?: string; + result?: ResetResult; }; export type ResetRequest = { - visitorId: string; requestId: string; }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { // This API route accepts only POST requests. - if (req.method !== 'POST') { - res.status(405).send({ message: 'Only POST requests allowed' }); + const reqValidation = isValidPostRequest(req); + if (!reqValidation.okay) { + res.status(405).send({ severity: 'error', message: reqValidation.error }); return; } - res.setHeader('Content-Type', 'application/json'); - - return await tryToReset(req, res, [ - checkFreshIdentificationRequest, - checkConfidenceScore, - checkIpAddressIntegrity, - checkOriginsIntegrity, - deleteVisitorData, - ]); -} -async function tryToReset(req: NextApiRequest, res: NextApiResponse, ruleChecks: RuleCheck[]) { - // Get requestId and visitorId from the client. - const { visitorId, requestId } = req.body as ResetRequest; + const { requestId } = req.body as ResetRequest; - if (!ensureValidRequestIdAndVisitorId(req, res, visitorId, requestId)) { + // Get the full Identification result from Fingerprint Server API and validate its authenticity + const fingerprintResult = await getAndValidateFingerprintResult(requestId, req); + if (!fingerprintResult.okay) { + res.status(403).send({ severity: 'error', message: fingerprintResult.error }); return; } - const eventResponse = await getIdentificationEvent(requestId); + const { visitorId, ip } = fingerprintResult.data.products?.identification?.data ?? {}; + if (!visitorId) { + res.status(403).send({ severity: 'error', message: 'Visitor ID not found.' }); + return; + } - for (const ruleCheck of ruleChecks) { - const result = await ruleCheck(eventResponse, req); + const deleteResult = await deleteVisitorData(visitorId, ip ?? ''); - if (result) { - switch (result.type) { - case checkResultType.Passed: - return sendOkResponse(res, result); - default: - return sendForbiddenResponse(res, result); - } - } - } + res.status(200).json({ + message: 'Visitor data deleted successfully.', + severity: 'success', + result: deleteResult, + }); } -const deleteVisitorData: RuleCheck = async (eventResponse) => { - if (isVisitorsError(eventResponse)) { - return; - } - +const deleteVisitorData = async (visitorId: string, ip: string) => { const options = { - where: { visitorId: eventResponse.products?.identification?.data?.visitorId }, + where: { visitorId }, }; - const loginAttemptsRowsRemoved = await tryToDestroy(() => LoginAttemptDbModel.destroy(options)); - - const paymentAttemptsRowsRemoved = await tryToDestroy(() => PaymentAttemptDbModel.destroy(options)); - - const couponsRemoved = await tryToDestroy(() => CouponClaimDbModel.destroy(options)); - - const deletedCartItemsCount = await tryToDestroy(() => UserCartItemDbModel.destroy(options)); - const deletedUserPreferencesCount = await tryToDestroy(() => UserPreferencesDbModel.destroy(options)); - const deletedUserSearchHistoryCount = await tryToDestroy(() => UserSearchHistoryDbModel.destroy(options)); - - const deletedPersonalizationCount = - deletedCartItemsCount + deletedUserPreferencesCount + deletedUserSearchHistoryCount; - - const deletedLoanRequests = await tryToDestroy(() => LoanRequestDbModel.destroy(options)); - const deletedPaywallData = await tryToDestroy(() => ArticleViewDbModel.destroy(options)); - - const deletedBlockedIps = await tryToDestroy(async () => { - const deletedIpCount = await deleteBlockedIp(eventResponse.products?.identification?.data?.ip ?? ''); - await syncFirewallRuleset(); - return deletedIpCount; - }); - - return new CheckResult( - `Deleted ${loginAttemptsRowsRemoved} rows for Credential Stuffing problem. Deleted ${paymentAttemptsRowsRemoved} rows for Payment Fraud problem. Deleted ${deletedPersonalizationCount} entries related to personalization. Deleted ${deletedLoanRequests} loan request entries. Deleted ${deletedPaywallData} rows for the Paywall problem. Deleted ${couponsRemoved} rows for the Coupon fraud problem. Deleted ${deletedBlockedIps} blocked IPs for the Bot Firewall demo.`, - messageSeverity.Success, - checkResultType.Passed, - ); + return { + deletedLoginAttempts: await tryToDestroy(() => LoginAttemptDbModel.destroy(options)), + deletedPaymentAttempts: await tryToDestroy(() => PaymentAttemptDbModel.destroy(options)), + deletedCouponsClaims: await tryToDestroy(() => CouponClaimDbModel.destroy(options)), + deletedPersonalizationRecords: await tryToDestroy(async () => { + const deletedCartItemsCount = await UserCartItemDbModel.destroy(options); + const deletedUserPreferencesCount = await UserPreferencesDbModel.destroy(options); + const deletedUserSearchHistoryCount = await UserSearchHistoryDbModel.destroy(options); + return deletedCartItemsCount + deletedUserPreferencesCount + deletedUserSearchHistoryCount; + }), + deletedLoanRequests: await tryToDestroy(() => LoanRequestDbModel.destroy(options)), + deletedArticleViews: await tryToDestroy(() => ArticleViewDbModel.destroy(options)), + deletedBlockedIps: await tryToDestroy(async () => { + const deletedIpCount = await deleteBlockedIp(ip); + await syncFirewallRuleset(); + return deletedIpCount; + }), + }; }; -const tryToDestroy = async (callback: () => Promise) => { +type ResetResult = Awaited>; + +const tryToDestroy = async (callback: () => Promise) => { try { return await callback(); } catch (err) { diff --git a/src/pages/bot-firewall/index.tsx b/src/pages/bot-firewall/index.tsx index 4348a223..ed224a27 100644 --- a/src/pages/bot-firewall/index.tsx +++ b/src/pages/bot-firewall/index.tsx @@ -16,7 +16,7 @@ import { useState } from 'react'; import { BotTypeInfo, BotVisitAction, InstructionPrompt } from '../../client/bot-firewall/botFirewallComponents'; import { wait } from '../../shared/timeUtils'; import { Spinner } from '../../client/components/common/Spinner/Spinner'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; const DEFAULT_DISPLAYED_VISITS = 10; const DISPLAYED_VISITS_INCREMENT = 10; diff --git a/src/pages/coupon-fraud/index.tsx b/src/pages/coupon-fraud/index.tsx index 443d0343..bbb3da5e 100644 --- a/src/pages/coupon-fraud/index.tsx +++ b/src/pages/coupon-fraud/index.tsx @@ -8,7 +8,7 @@ import formStyles from '../../styles/forms.module.scss'; import classNames from 'classnames'; import AirMax from './shoeAirMax.svg'; import AllStar from './shoeAllStar.svg'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; import Button from '../../client/components/common/Button/Button'; import { Cart } from '../../client/components/common/Cart/Cart'; import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; diff --git a/src/pages/credential-stuffing/index.tsx b/src/pages/credential-stuffing/index.tsx index cd61ad32..b5f4f3f7 100644 --- a/src/pages/credential-stuffing/index.tsx +++ b/src/pages/credential-stuffing/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper'; import React from 'react'; import { USE_CASES } from '../../client/components/common/content'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; import Button from '../../client/components/common/Button/Button'; import styles from './credentialStuffing.module.scss'; import formStyles from '../../styles/forms.module.scss'; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5c3b25c2..fef0aaf7 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; - import styles from './index.module.scss'; import Container from '../client/components/common/Container'; import { HOMEPAGE_CARDS } from '../client/components/common/content'; diff --git a/src/pages/loan-risk/index.tsx b/src/pages/loan-risk/index.tsx index 86cd31a0..50974a3a 100644 --- a/src/pages/loan-risk/index.tsx +++ b/src/pages/loan-risk/index.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { USE_CASES } from '../../client/components/common/content'; import { CustomPageProps } from '../_app'; import Button from '../../client/components/common/Button/Button'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; import formStyles from '../../styles/forms.module.scss'; import { Slider } from '../../client/components/common/Slider/Slider'; import { NumberInputWithUnits } from '../../client/components/common/InputNumberWithUnits/InputNumberWithUnits'; diff --git a/src/pages/payment-fraud/index.tsx b/src/pages/payment-fraud/index.tsx index 5d7aa24d..8e3c07a6 100644 --- a/src/pages/payment-fraud/index.tsx +++ b/src/pages/payment-fraud/index.tsx @@ -6,7 +6,7 @@ import Button from '../../client/components/common/Button/Button'; import styles from './paymentFraud.module.scss'; import formStyles from '../../styles/forms.module.scss'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; import { CustomPageProps } from '../_app'; import classNames from 'classnames'; import { Severity } from '../../server/checkResult'; diff --git a/src/pages/paywall/article/[id]/index.tsx b/src/pages/paywall/article/[id]/index.tsx index 0f658c44..63982fb2 100644 --- a/src/pages/paywall/article/[id]/index.tsx +++ b/src/pages/paywall/article/[id]/index.tsx @@ -7,7 +7,7 @@ import BackArrow from '../../../../client/img/arrowLeft.svg'; import Link from 'next/link'; import Image from 'next/image'; import styles from '../../paywall.module.scss'; -import Alert from '../../../../client/components/common/Alert/Alert'; +import { Alert } from '../../../../client/components/common/Alert/Alert'; import { ARTICLES } from '../../../../server/paywall/articles'; import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react'; import { useQuery } from 'react-query'; diff --git a/src/pages/personalization/index.tsx b/src/pages/personalization/index.tsx index 5a0aab04..9e1e5cb6 100644 --- a/src/pages/personalization/index.tsx +++ b/src/pages/personalization/index.tsx @@ -61,7 +61,7 @@ export default function Index({ embed }: CustomPageProps) { !userWelcomed && (searchHistoryQuery.data?.data?.length || hasDarkMode || cartQuery.data?.data?.length) ) { - enqueueSnackbar('Welcome back! We applied your dark mode preference and synced your cart and search terms.', { + enqueueSnackbar('Welcome back! We synced your cart and search terms.', { variant: 'info', className: 'WelcomeBack', }); diff --git a/src/pages/playground/index.tsx b/src/pages/playground/index.tsx index d403a4ed..88d4ab9c 100644 --- a/src/pages/playground/index.tsx +++ b/src/pages/playground/index.tsx @@ -23,7 +23,7 @@ import externalLinkArrow from '../../client/img/externalLinkArrow.svg'; import Image from 'next/image'; import styles from './playground.module.scss'; import { Spinner } from '../../client/components/common/Spinner/Spinner'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; const PLAYGROUND_COPY = { androidOnly: 'Applicable only to Android devices', diff --git a/src/pages/web-scraping/index.tsx b/src/pages/web-scraping/index.tsx index 7aa79471..781bd582 100644 --- a/src/pages/web-scraping/index.tsx +++ b/src/pages/web-scraping/index.tsx @@ -13,7 +13,7 @@ import ArrowIcon from '../../client/img/arrowRight.svg'; import Image from 'next/image'; import styles from './webScraping.module.scss'; import Button from '../../client/components/common/Button/Button'; -import Alert from '../../client/components/common/Alert/Alert'; +import { Alert } from '../../client/components/common/Alert/Alert'; import { Spinner } from '../../client/components/common/Spinner/Spinner'; // Make URL query object available as props to the page on first render diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index c913d410..07c93f52 100755 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -28,9 +28,11 @@ $colors: ( 'success-background': #edf7ed, 'error-background': #ffefef, 'warning-background': #fcf4e5, + 'info-background': #f0f8ff, 'success-text': #1e4620, 'error-text': #901414, 'warning-text': #5d3e0f, + 'info-text': #1a4b70, ); $brand-colors: ( diff --git a/tsconfig.json b/tsconfig.json index f9946d62..77363119 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "checkJs": false, "skipLibCheck": true, @@ -14,8 +18,22 @@ "resolveJsonModule": true, "isolatedModules": true, "noUnusedLocals": true, - "jsx": "preserve" + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }