diff --git a/pkg/admin/graphql/fraud_protection_overview.go b/pkg/admin/graphql/fraud_protection_overview.go index aa6b83438f..803f92ac2c 100644 --- a/pkg/admin/graphql/fraud_protection_overview.go +++ b/pkg/admin/graphql/fraud_protection_overview.go @@ -6,6 +6,40 @@ import ( "github.com/authgear/authgear-server/pkg/lib/audit" ) +var fraudProtectionOverviewTimeBucketType = graphql.NewObject(graphql.ObjectConfig{ + Name: "FraudProtectionOverviewTimeBucket", + Fields: graphql.Fields{ + "hour": &graphql.Field{ + Type: graphql.NewNonNull(graphql.DateTime), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewTimeBucket) + return source.Hour, nil + }, + }, + "total": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewTimeBucket) + return source.Total, nil + }, + }, + "blocked": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewTimeBucket) + return source.Blocked, nil + }, + }, + "flagged": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewTimeBucket) + return source.Flagged, nil + }, + }, + }, +}) + var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectConfig{ Name: "FraudProtectionOverviewTopSourceIP", Fields: graphql.Fields{ @@ -16,6 +50,13 @@ var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectCon return source.IPAddress, nil }, }, + "geoCountryCode": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewIP) + return source.GeoCountryCode, nil + }, + }, "total": &graphql.Field{ Type: graphql.NewNonNull(graphql.Int), Resolve: func(p graphql.ResolveParams) (any, error) { @@ -40,6 +81,74 @@ var fraudProtectionOverviewTopSourceIPType = graphql.NewObject(graphql.ObjectCon }, }) +var fraudProtectionOverviewIPLocationType = graphql.NewObject(graphql.ObjectConfig{ + Name: "FraudProtectionOverviewIPLocation", + Fields: graphql.Fields{ + "geoCountryCode": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewIPLocation) + return source.GeoCountryCode, nil + }, + }, + "total": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewIPLocation) + return source.Total, nil + }, + }, + "blocked": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewIPLocation) + return source.Blocked, nil + }, + }, + "flagged": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewIPLocation) + return source.Flagged, nil + }, + }, + }, +}) + +var fraudProtectionOverviewSMSOriginType = graphql.NewObject(graphql.ObjectConfig{ + Name: "FraudProtectionOverviewSMSOrigin", + Fields: graphql.Fields{ + "phoneCountryCode": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSMSOrigin) + return source.PhoneCountryCode, nil + }, + }, + "total": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSMSOrigin) + return source.Total, nil + }, + }, + "blocked": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSMSOrigin) + return source.Blocked, nil + }, + }, + "flagged": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSMSOrigin) + return source.Flagged, nil + }, + }, + }, +}) + var fraudProtectionOverviewSendSMSType = graphql.NewObject(graphql.ObjectConfig{ Name: "FraudProtectionOverviewSendSMS", Fields: graphql.Fields{ @@ -71,6 +180,27 @@ var fraudProtectionOverviewSendSMSType = graphql.NewObject(graphql.ObjectConfig{ return source.TopSourceIPs, nil }, }, + "topIPLocations": &graphql.Field{ + Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewIPLocationType))), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSendSMS) + return source.TopIPLocations, nil + }, + }, + "topSMSOrigins": &graphql.Field{ + Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewSMSOriginType))), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSendSMS) + return source.TopSMSOrigins, nil + }, + }, + "timeBuckets": &graphql.Field{ + Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(fraudProtectionOverviewTimeBucketType))), + Resolve: func(p graphql.ResolveParams) (any, error) { + source := p.Source.(audit.FraudProtectionOverviewSendSMS) + return source.TimeBuckets, nil + }, + }, }, }) diff --git a/pkg/lib/audit/fraud_protection_overview.go b/pkg/lib/audit/fraud_protection_overview.go index f22980372a..338d254b00 100644 --- a/pkg/lib/audit/fraud_protection_overview.go +++ b/pkg/lib/audit/fraud_protection_overview.go @@ -17,17 +17,42 @@ type FraudProtectionOverview struct { } type FraudProtectionOverviewSendSMS struct { - TotalActions int `json:"totalActions"` - BlockedActions int `json:"blockedActions"` - WarnedActions int `json:"warnedActions"` - TopSourceIPs []FraudProtectionOverviewIP `json:"topSourceIPs"` + TotalActions int `json:"totalActions"` + BlockedActions int `json:"blockedActions"` + WarnedActions int `json:"warnedActions"` + TopSourceIPs []FraudProtectionOverviewIP `json:"topSourceIPs"` + TopIPLocations []FraudProtectionOverviewIPLocation `json:"topIPLocations"` + TopSMSOrigins []FraudProtectionOverviewSMSOrigin `json:"topSMSOrigins"` + TimeBuckets []FraudProtectionOverviewTimeBucket `json:"timeBuckets"` +} + +type FraudProtectionOverviewTimeBucket struct { + Hour time.Time `json:"hour"` + Total int `json:"total"` + Blocked int `json:"blocked"` + Flagged int `json:"flagged"` } type FraudProtectionOverviewIP struct { - IPAddress string `json:"ipAddress"` - Total int `json:"total"` - Blocked int `json:"blocked"` - Flagged int `json:"flagged"` + IPAddress string `json:"ipAddress"` + GeoCountryCode string `json:"geoCountryCode"` + Total int `json:"total"` + Blocked int `json:"blocked"` + Flagged int `json:"flagged"` +} + +type FraudProtectionOverviewSMSOrigin struct { + PhoneCountryCode string `json:"phoneCountryCode"` + Total int `json:"total"` + Blocked int `json:"blocked"` + Flagged int `json:"flagged"` +} + +type FraudProtectionOverviewIPLocation struct { + GeoCountryCode string `json:"geoCountryCode"` + Total int `json:"total"` + Blocked int `json:"blocked"` + Flagged int `json:"flagged"` } type FraudProtectionOverviewQueryOptions struct { @@ -98,6 +123,7 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr topIPsQuery := s.SQLBuilder. Select( "ip_address", + `COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(UPPER(data#>>'{payload,record,geo_location_code}'), '')), '') AS geo_country_code`, "COUNT(*) AS total_actions", "COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions", "COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions", @@ -121,7 +147,7 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr var total int64 var blocked int64 var warnings int64 - if err := rows.Scan(&ip, &total, &blocked, &warnings); err != nil { + if err := rows.Scan(&ip, &item.GeoCountryCode, &total, &blocked, &warnings); err != nil { return nil, err } if ip.Valid { @@ -136,12 +162,124 @@ func (s *ReadStore) GetFraudProtectionOverview(ctx context.Context, opts FraudPr return nil, err } + topSMSOriginsQuery := s.SQLBuilder. + Select( + "COALESCE(data#>>'{payload,record,action_detail,phone_number_country_code}', '') AS phone_country_code", + "COUNT(*) AS total_actions", + "COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions", + "COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions", + ). + FromSelect(baseQuery, "records"). + Where("COALESCE(data#>>'{payload,record,action_detail,phone_number_country_code}', '') <> ''"). + GroupBy("phone_country_code"). + OrderBy("total_actions DESC", "phone_country_code ASC"). + Limit(10) + + smsRows, err := s.SQLExecutor.QueryWith(ctx, topSMSOriginsQuery) + if err != nil { + return nil, err + } + defer smsRows.Close() + + topSMSOrigins := make([]FraudProtectionOverviewSMSOrigin, 0) + for smsRows.Next() { + var item FraudProtectionOverviewSMSOrigin + var total int64 + var blocked int64 + var warnings int64 + if err := smsRows.Scan(&item.PhoneCountryCode, &total, &blocked, &warnings); err != nil { + return nil, err + } + item.Total = int(total) + item.Blocked = int(blocked) + item.Flagged = int(warnings) + topSMSOrigins = append(topSMSOrigins, item) + } + if err := smsRows.Err(); err != nil { + return nil, err + } + + topIPLocationsQuery := s.SQLBuilder. + Select( + "COALESCE(UPPER(data#>>'{payload,record,geo_location_code}'), '') AS geo_country_code", + "COUNT(*) AS total_actions", + "COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions", + "COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions", + ). + FromSelect(baseQuery, "records"). + Where("COALESCE(data#>>'{payload,record,geo_location_code}', '') <> ''"). + GroupBy("geo_country_code"). + OrderBy("total_actions DESC", "geo_country_code ASC"). + Limit(10) + + ipLocationRows, err := s.SQLExecutor.QueryWith(ctx, topIPLocationsQuery) + if err != nil { + return nil, err + } + defer ipLocationRows.Close() + + topIPLocations := make([]FraudProtectionOverviewIPLocation, 0) + for ipLocationRows.Next() { + var item FraudProtectionOverviewIPLocation + var total int64 + var blocked int64 + var warnings int64 + if err := ipLocationRows.Scan(&item.GeoCountryCode, &total, &blocked, &warnings); err != nil { + return nil, err + } + item.Total = int(total) + item.Blocked = int(blocked) + item.Flagged = int(warnings) + topIPLocations = append(topIPLocations, item) + } + if err := ipLocationRows.Err(); err != nil { + return nil, err + } + + timeBucketsQuery := s.SQLBuilder. + Select( + "DATE_TRUNC('hour', created_at) AS hour", + "COUNT(*) AS total_actions", + "COUNT(*) FILTER (WHERE decision = 'blocked') AS blocked_actions", + "COUNT(*) FILTER (WHERE decision = 'allowed' AND warning_count > 0) AS warning_actions", + ). + FromSelect(baseQuery, "records"). + GroupBy("hour"). + OrderBy("hour ASC") + + timeBucketRows, err := s.SQLExecutor.QueryWith(ctx, timeBucketsQuery) + if err != nil { + return nil, err + } + defer timeBucketRows.Close() + + timeBuckets := make([]FraudProtectionOverviewTimeBucket, 0) + for timeBucketRows.Next() { + var item FraudProtectionOverviewTimeBucket + var total int64 + var blocked int64 + var warnings int64 + if err := timeBucketRows.Scan(&item.Hour, &total, &blocked, &warnings); err != nil { + return nil, err + } + item.Total = int(total) + item.Blocked = int(blocked) + item.Flagged = int(warnings) + timeBuckets = append(timeBuckets, item) + } + if err := timeBucketRows.Err(); err != nil { + return nil, err + } + return &FraudProtectionOverview{ SendSMS: FraudProtectionOverviewSendSMS{ TotalActions: int(totalActions), BlockedActions: int(blockedActions), WarnedActions: int(warnedActions), TopSourceIPs: topSourceIPs, + TopIPLocations: topIPLocations, + TopSMSOrigins: topSMSOrigins, + TimeBuckets: timeBuckets, }, }, nil } diff --git a/pkg/lib/audit/read_store.go b/pkg/lib/audit/read_store.go index dcbeb554ad..ae63358251 100644 --- a/pkg/lib/audit/read_store.go +++ b/pkg/lib/audit/read_store.go @@ -170,7 +170,13 @@ func (s *ReadStore) queryFraudProtectionDecisionRecordsBase( opts FraudProtectionDecisionRecordQueryOptions, ) db.SelectBuilder { query := s.SQLBuilder. - Select("id", "created_at", "data"). + Select( + "id", + "created_at", + "data", + "COALESCE(host(ip_address)::text, '') AS ip_address", + "COALESCE(user_agent, '') AS user_agent", + ). From(s.SQLBuilder.TableName("_audit_log")) return opts.Apply(query) } @@ -299,7 +305,9 @@ func (s *ReadStore) scanFraudProtectionDecisionRecord( ) (*FraudProtectionDecisionRecord, error) { record := &FraudProtectionDecisionRecord{} var raw []byte - if err := scn.Scan(&record.ID, &record.CreatedAt, &raw); err != nil { + var columnIPAddress string + var columnUserAgent string + if err := scn.Scan(&record.ID, &record.CreatedAt, &raw, &columnIPAddress, &columnUserAgent); err != nil { return nil, err } @@ -312,5 +320,11 @@ func (s *ReadStore) scanFraudProtectionDecisionRecord( return nil, err } record.Record = payload.Payload.Record + if record.Record.IPAddress == "" && columnIPAddress != "" { + record.Record.IPAddress = columnIPAddress + } + if record.Record.UserAgent == "" && columnUserAgent != "" { + record.Record.UserAgent = columnUserAgent + } return record, nil } diff --git a/portal/src/DateTimePicker.tsx b/portal/src/DateTimePicker.tsx index 88511a2212..2e521f0753 100644 --- a/portal/src/DateTimePicker.tsx +++ b/portal/src/DateTimePicker.tsx @@ -6,6 +6,7 @@ import { IComboBox, ITimeRange, defaultDatePickerStrings, + DirectionalHint, } from "@fluentui/react"; import { Context, FormattedMessage } from "./intl"; import { DateTime } from "luxon"; @@ -304,6 +305,11 @@ export default function DateTimePicker( onChange={onChange} onValidateUserInput={onValidateUserInput} onFormatDate={onFormatDate} + calloutProps={{ + directionalHint: DirectionalHint.bottomLeftEdge, + calloutMaxHeight: 240, + doNotLayer: false, + }} /> {showClearButton ? ( | React.KeyboardEvent ) => void; @@ -27,6 +29,7 @@ const DesktopDateRangeFilterDropdown: React.VFC = function DesktopDateRangeFilterDropdown({ className, value, + customRangeLabel, onClickAllDateRange, onClickCustomDateRange, }: DateRangeFilterDropdownProps) { @@ -38,11 +41,11 @@ const DesktopDateRangeFilterDropdown: React.VFC = const placeholder = useMemo(() => { if (value === "customDateRange") { - return renderToString("AuditLogScreen.date-range.custom"); + return customRangeLabel ?? customDateRangeLabel; } - return renderToString("AuditLogScreen.date-range.all"); - }, [renderToString, value]); + return allDateRangeLabel; + }, [allDateRangeLabel, customDateRangeLabel, customRangeLabel, value]); const menuProps = useMemo(() => { return { @@ -81,6 +84,7 @@ const MobileDateRangeFilterDropdown: React.VFC = function MobileDateRangeFilterDropdown({ className, value, + customRangeLabel, onClickAllDateRange, onClickCustomDateRange, }: DateRangeFilterDropdownProps) { @@ -102,6 +106,16 @@ const MobileDateRangeFilterDropdown: React.VFC = ]; }, [allDateRangeLabel, customDateRangeLabel]); + const onRenderTitle: IRenderFunction = useCallback( + (selectedOptions?: IDropdownOption[]) => { + if (value === "customDateRange" && customRangeLabel != null) { + return {customRangeLabel}; + } + return {selectedOptions?.[0]?.text ?? ""}; + }, + [customRangeLabel, value] + ); + const onChangeOption = useCallback( (_e: unknown, option?: IDropdownOption) => { if (option == null) { @@ -128,6 +142,7 @@ const MobileDateRangeFilterDropdown: React.VFC = selectedKey={value} options={options} onChange={onChangeOption} + onRenderTitle={onRenderTitle} /> ); }; diff --git a/portal/src/components/fraud-protection/FraudProtectionLogsTab.module.css b/portal/src/components/fraud-protection/FraudProtectionLogsTab.module.css index ea194a699a..67349c5c56 100644 --- a/portal/src/components/fraud-protection/FraudProtectionLogsTab.module.css +++ b/portal/src/components/fraud-protection/FraudProtectionLogsTab.module.css @@ -5,17 +5,50 @@ /* Filter bar */ .filterRow { - @apply flex flex-wrap items-center justify-between gap-3; - @apply pb-4; + @apply flex flex-wrap items-center gap-x-3 gap-y-2 pb-4; border-bottom: 1px solid #edebe9; + + @apply tablet:flex-col tablet:items-stretch tablet:gap-2; } .filterGroup { - @apply flex flex-wrap items-stretch gap-2 h-8; + @apply flex flex-wrap items-center gap-2 min-h-8 min-w-0; + + @apply tablet:grid tablet:grid-cols-2 tablet:gap-2 tablet:w-full; +} + +.bottomRow { + @apply flex items-center gap-2; + flex: 1 1 auto; + min-width: 320px; + + @apply tablet:w-full tablet:flex-wrap; } .filterActions { - @apply flex items-stretch gap-2 h-8; + @apply flex flex-shrink-0 items-center gap-2 ml-auto; +} + +.dateRangeFilter { + @apply h-8 tablet:h-11 tablet:col-span-2; +} + +.dateRangeFilterCustom { + @apply h-auto min-h-8 self-center; + max-width: min(100%, 520px); + + @apply tablet:col-span-2 tablet:max-w-none; +} + +.dateRangeFilterCustom :global(.ms-Button-label) { + white-space: normal; + text-align: left; + line-height: 1.25; +} + +.actionDropdown, +.resultDropdown { + @apply tablet:w-full tablet:min-w-0 tablet:h-11; } .actionDropdown { @@ -27,17 +60,42 @@ } .reasonCodeComboBox { - min-width: 200px; + width: 180px; + flex: 0 0 180px; + + @apply tablet:w-full tablet:basis-full tablet:max-w-none tablet:h-11; +} + +.searchBoxFilter { + width: 180px; + flex: 0 0 180px; + height: 32px; + + @apply tablet:flex-1 tablet:w-auto tablet:min-w-0 tablet:max-w-none; +} + +.searchBoxFilter :global(.ms-SearchBox) { + height: 100%; + min-height: unset; } -.searchBox { - width: 240px; +.searchBoxFilter :global(.ms-SearchBox-field) { + height: 100%; + line-height: normal; +} + +.refreshButton { + @apply h-8 flex-shrink-0; } /* Table */ +.tableWrapper { + overflow-x: auto; +} + .list { - @apply border-0; + @apply w-full border-0; } .logRow { @@ -152,5 +210,73 @@ /* Pagination */ .pagination { - @apply mt-2; + @apply mt-2 flex justify-end; +} + +/* Columns dropdown */ + +.columnsDropdownWrapper { + position: relative; + flex-shrink: 0; +} + +.columnsButton { + @apply inline-flex items-center gap-1.5 h-8 px-3 flex-shrink-0; + font-family: "Segoe UI", system-ui, sans-serif; + font-size: 14px; + color: #323130; + background: transparent; + border: 1px solid #8a8886; + cursor: pointer; + white-space: nowrap; +} + +.columnsButton:hover { + background: #f3f2f1; +} + +.columnsButtonIcon { + @apply flex items-center; + color: #605e5c; +} + +/* Columns callout */ + +.columnsCallout { + @apply flex flex-col; + min-width: 200px; + padding: 8px 0; +} + +.columnsSectionLabel { + padding: 4px 12px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: #8a8886; +} + +.columnsSectionLabelOptional { + margin-top: 4px; + border-top: 1px solid #edebe9; + padding-top: 8px; +} + +.columnsCheckboxAlways { + padding: 4px 12px; +} + +.columnsCheckbox { + padding: 4px 12px; +} + +.columnsCalloutFooter { + @apply flex justify-end; + padding: 8px 12px 4px; + margin-top: 4px; + border-top: 1px solid #edebe9; +} + +.columnsCheckbox :global(.ms-Checkbox-text) { + font-size: 14px; } diff --git a/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx b/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx index 70a4de20a5..1df715c855 100644 --- a/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx +++ b/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx @@ -6,11 +6,15 @@ import React, { useRef, useState, } from "react"; +import cn from "classnames"; import { + Callout, + Checkbox, ColumnActionsMode, ComboBox, DetailsListLayoutMode, DetailsRow, + DirectionalHint, Dropdown, IColumn, IComboBox, @@ -18,22 +22,22 @@ import { IDetailsList, IDetailsRowProps, IDropdownOption, - IconButton, MessageBar, SearchBox, SelectionMode, ShimmeredDetailsList, } from "@fluentui/react"; import { useNavigate, useParams } from "react-router-dom"; -import { DateTime } from "luxon"; import { Context, FormattedMessage } from "../../intl"; import ShowError from "../../ShowError"; import WidgetTitle from "../../WidgetTitle"; import PaginationWidget from "../../PaginationWidget"; import CommandBarButton from "../../CommandBarButton"; +import PrimaryButton from "../../PrimaryButton"; import DateRangeDialog from "../../graphql/portal/DateRangeDialog"; import { DateRangeFilterDropdown } from "../audit-log/DateRangeFilterDropdown"; import useTransactionalState from "../../hook/useTransactionalState"; +import { FraudProtectionWarningType } from "../../types"; import { FraudProtectionLogsQueryQuery, useFraudProtectionLogsQueryQuery, @@ -43,9 +47,11 @@ import { SortDirection, } from "../../graphql/adminapi/globalTypes.generated"; import { encodeOffsetToCursor } from "../../util/pagination"; -import { formatDatetime } from "../../util/formatDatetime"; import { useDebounced } from "../../hook/useDebounced"; -import { FraudProtectionWarningType } from "../../types"; +import { + formatCustomDateRangeLabel, + formatDatetime, +} from "../../util/formatDatetime"; import styles from "./FraudProtectionLogsTab.module.css"; const PAGE_SIZE = 20; @@ -106,32 +112,6 @@ function ensureFraudDecisionNodeID(id: string): string { return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } -function parseEntry( - node: NonNullable< - NonNullable< - NonNullable< - NonNullable< - FraudProtectionLogsQueryQuery["fraudProtectionLogs"] - >["edges"] - >[number] - >["node"] - >, - locale: string -): FraudProtectionLogEntry { - const phoneNumber = node.actionDetail.recipient; - return { - id: node.id, - createdAt: formatDatetime(locale, node.createdAt) ?? "", - decision: node.decision, - reasonCodes: node.triggeredWarnings, - ipAddress: node.ipAddress ?? "", - geoLocationCode: node.geoLocationCode ?? "", - userAgent: node.userAgent ?? "", - phoneNumber, - phoneCountryCode: node.actionDetail.phoneNumberCountryCode ?? "", - }; -} - function getResultQueryVariables(resultFilter: ResultFilterKey): { maximumWarningCount?: number; minimumWarningCount?: number; @@ -175,6 +155,37 @@ function getResultClassName(entry: FraudProtectionLogEntry): string { return styles.resultAllowed; } +type FraudProtectionLogNode = NonNullable< + NonNullable< + NonNullable< + FraudProtectionLogsQueryQuery["fraudProtectionLogs"] + >["edges"] + >[number] +>["node"]; + +function mapLogNodeToEntry(node: NonNullable): FraudProtectionLogEntry { + const phoneNumber = + node.actionDetail.__typename === "FraudProtectionDecisionSendSMSActionDetail" + ? node.actionDetail.recipient + : ""; + const phoneCountryCode = + node.actionDetail.__typename === "FraudProtectionDecisionSendSMSActionDetail" + ? node.actionDetail.phoneNumberCountryCode ?? "" + : ""; + + return { + id: node.id, + createdAt: node.createdAt, + decision: node.decision, + reasonCodes: node.triggeredWarnings, + ipAddress: node.ipAddress ?? "", + geoLocationCode: node.geoLocationCode ?? "", + userAgent: node.userAgent ?? "", + phoneNumber, + phoneCountryCode, + }; +} + // ---- ResultCell ---- interface ResultCellProps { @@ -190,144 +201,263 @@ const ResultCell: React.VFC = function ResultCell({ entry }) { ); }; -// ---- LogRowDetails ---- - -interface LogRowDetailsProps { - entry: FraudProtectionLogEntryViewModel; +// ---- Column definitions ---- + +type ColumnKey = + | "timestamp" + | "action" + | "result" + | "ip" + | "reasonCodes" + | "ipCountry" + | "phone" + | "phoneCountry"; + +interface ColumnDef { + key: ColumnKey; + alwaysShown: boolean; + defaultVisible: boolean; + minWidth: number; + maxWidth?: number; + fieldName?: string; } -const LogRowDetails: React.VFC = function LogRowDetails({ - entry, -}) { - const hasPhoneCountryCode = entry.phoneCountryCode !== ""; - - return ( -
-
-
-
- -
-
- - - - - {entry.ipAddress || "—"} - -
-
- - - - - {entry.geoLocationCode || "—"} - -
-
- -
-
- -
-
- - - - - {entry.phoneNumber || "—"} - -
- {hasPhoneCountryCode ? ( -
- - - - - {entry.phoneCountryCode} - -
- ) : null} -
- -
-
- -
-
- - - - {entry.reasonCodes.length > 0 ? ( -
- {entry.reasonCodes.map((code) => ( - - {code} - - ))} -
- ) : ( - - - - )} -
-
-
-
- ); -}; - -// ---- FraudProtectionLogsTab ---- - -export interface FraudProtectionLogsTabProps {} - -const columns: IColumn[] = [ - { - key: "expand", - name: "", - minWidth: 32, - maxWidth: 32, - columnActionsMode: ColumnActionsMode.disabled, - }, +const COLUMN_DEFS: ColumnDef[] = [ { key: "timestamp", - name: "Timestamp", - fieldName: "createdAt", + alwaysShown: true, + defaultVisible: true, minWidth: 200, maxWidth: 240, - columnActionsMode: ColumnActionsMode.disabled, + fieldName: "createdAt", }, { key: "action", - name: "Action", + alwaysShown: true, + defaultVisible: true, minWidth: 80, maxWidth: 100, - columnActionsMode: ColumnActionsMode.disabled, }, { key: "result", - name: "Result", + alwaysShown: true, + defaultVisible: true, minWidth: 80, maxWidth: 100, - columnActionsMode: ColumnActionsMode.disabled, }, { key: "reasonCodes", - name: "Reason codes", - minWidth: 200, - columnActionsMode: ColumnActionsMode.disabled, + alwaysShown: false, + defaultVisible: true, + minWidth: 480, }, { key: "ip", - name: "IP", + alwaysShown: true, + defaultVisible: true, minWidth: 120, maxWidth: 150, - columnActionsMode: ColumnActionsMode.disabled, + }, + { + key: "ipCountry", + alwaysShown: false, + defaultVisible: true, + minWidth: 100, + maxWidth: 130, + }, + { + key: "phone", + alwaysShown: false, + defaultVisible: false, + minWidth: 140, + maxWidth: 180, + }, + { + key: "phoneCountry", + alwaysShown: false, + defaultVisible: false, + minWidth: 100, + maxWidth: 130, }, ]; -const FraudProtectionLogsTab: React.VFC = +const FRAUD_PROTECTION_LOGS_COLUMNS_STORAGE_KEY_PREFIX = + "fraud-protection-logs-visible-columns-v2:"; + +const OPTIONAL_COLUMN_KEYS = new Set( + COLUMN_DEFS.filter((c) => !c.alwaysShown).map((c) => c.key) +); + +function getDefaultVisibleOptionalColumns(): Set { + return new Set( + COLUMN_DEFS.filter((c) => !c.alwaysShown && c.defaultVisible).map( + (c) => c.key + ) + ); +} + +function loadVisibleOptionalColumns(appID: string): Set { + try { + const raw = window.localStorage.getItem( + `${FRAUD_PROTECTION_LOGS_COLUMNS_STORAGE_KEY_PREFIX}${appID}` + ); + if (raw == null) { + return getDefaultVisibleOptionalColumns(); + } + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return getDefaultVisibleOptionalColumns(); + } + const keys = parsed.filter( + (key): key is ColumnKey => + typeof key === "string" && OPTIONAL_COLUMN_KEYS.has(key as ColumnKey) + ); + if (keys.length === 0) { + return getDefaultVisibleOptionalColumns(); + } + return new Set(keys); + } catch { + return getDefaultVisibleOptionalColumns(); + } +} + +function saveVisibleOptionalColumns( + appID: string, + columns: Set +): void { + window.localStorage.setItem( + `${FRAUD_PROTECTION_LOGS_COLUMNS_STORAGE_KEY_PREFIX}${appID}`, + JSON.stringify([...columns]) + ); +} + + +// ---- ColumnsDropdown ---- + +interface ColumnsDropdownProps { + columnDefs: ColumnDef[]; + visibleOptionalColumns: Set; + onSaveColumns: (columns: Set) => void; +} + +const ColumnsDropdown: React.VFC = + function ColumnsDropdown({ + columnDefs, + visibleOptionalColumns, + onSaveColumns, + }) { + const { renderToString } = useContext(Context); + const [isOpen, setIsOpen] = useState(false); + const [draftOptionalColumns, setDraftOptionalColumns] = useState< + Set + >(() => new Set(visibleOptionalColumns)); + const buttonRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setDraftOptionalColumns(new Set(visibleOptionalColumns)); + } + }, [isOpen, visibleOptionalColumns]); + + const alwaysShown = useMemo( + () => columnDefs.filter((c) => c.alwaysShown), + [columnDefs] + ); + const optional = useMemo( + () => columnDefs.filter((c) => !c.alwaysShown), + [columnDefs] + ); + + const onToggleDraftColumn = useCallback((key: ColumnKey) => { + setDraftOptionalColumns((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const onClickSave = useCallback(() => { + onSaveColumns(draftOptionalColumns); + setIsOpen(false); + }, [draftOptionalColumns, onSaveColumns]); + + return ( +
+ + {isOpen ? ( + setIsOpen(false)} + directionalHint={DirectionalHint.bottomLeftEdge} + gapSpace={4} + isBeakVisible={false} + doNotLayer={true} + > +
+
+ {renderToString("FraudProtectionConfigurationScreen.logs.columns.alwaysShown")} +
+ {alwaysShown.map((col) => ( + + ))} +
+ {renderToString("FraudProtectionConfigurationScreen.logs.columns.optional")} +
+ {optional.map((col) => ( + onToggleDraftColumn(col.key)} + label={renderToString( + `FraudProtectionConfigurationScreen.logs.column.${col.key}` + )} + /> + ))} +
+ } + onClick={onClickSave} + /> +
+
+
+ ) : null} +
+ ); + }; + +// ---- FraudProtectionLogsTab ---- + +const FraudProtectionLogsTab: React.VFC = function FraudProtectionLogsTab() { const { renderToString, locale } = useContext(Context); const { appID } = useParams() as { appID: string }; @@ -341,8 +471,18 @@ const FraudProtectionLogsTab: React.VFC = [] ); const [searchText, setSearchText] = useState(""); - const [expandedRowId, setExpandedRowId] = useState(null); const detailsListRef = useRef(null); + const [visibleOptionalColumns, setVisibleOptionalColumns] = useState< + Set + >(() => loadVisibleOptionalColumns(appID)); + + const onSaveColumns = useCallback( + (columns: Set) => { + setVisibleOptionalColumns(columns); + saveVisibleOptionalColumns(appID, columns); + }, + [appID] + ); const [dateRangeDialogHidden, setDateRangeDialogHidden] = useState(true); const [lastUpdatedAt, setLastUpdatedAt] = useState(() => new Date()); @@ -371,16 +511,21 @@ const FraudProtectionLogsTab: React.VFC = const queryRangeTo = useMemo(() => { if (rangeTo != null) { - return DateTime.fromJSDate(rangeTo) - .plus({ days: 1 }) - .toJSDate() - .toISOString(); + return rangeTo.toISOString(); } return lastUpdatedAt.toISOString(); }, [rangeTo, lastUpdatedAt]); const isCustomDateRange = rangeFrom != null || rangeTo != null; + const customDateRangeLabel = useMemo( + () => + isCustomDateRange + ? formatCustomDateRangeLabel(locale, rangeFrom, rangeTo) + : undefined, + [isCustomDateRange, locale, rangeFrom, rangeTo] + ); + const cursor = useMemo(() => encodeOffsetToCursor(offset), [offset]); const [debouncedSearch] = useDebounced(searchText, 300); @@ -389,66 +534,57 @@ const FraudProtectionLogsTab: React.VFC = [resultFilter] ); - const { data, previousData, loading, error, refetch } = - useFraudProtectionLogsQueryQuery({ - variables: { - pageSize: PAGE_SIZE, - cursor, - rangeFrom: queryRangeFrom, - rangeTo: queryRangeTo, - sortDirection, - verdicts: resultQueryVariables.verdicts, - reasonCodes: - selectedReasonCodes.length > 0 ? selectedReasonCodes : undefined, - maximumWarningCount: resultQueryVariables.maximumWarningCount, - minimumWarningCount: resultQueryVariables.minimumWarningCount, - search: - debouncedSearch.trim() !== "" ? debouncedSearch.trim() : undefined, - }, - fetchPolicy: "network-only", - }); - - const currentData = data ?? previousData; + const { data, loading, error, refetch } = useFraudProtectionLogsQueryQuery({ + variables: { + pageSize: PAGE_SIZE, + cursor, + rangeFrom: queryRangeFrom, + rangeTo: queryRangeTo, + sortDirection, + verdicts: resultQueryVariables.verdicts, + reasonCodes: + selectedReasonCodes.length > 0 ? selectedReasonCodes : undefined, + maximumWarningCount: resultQueryVariables.maximumWarningCount, + minimumWarningCount: resultQueryVariables.minimumWarningCount, + search: + debouncedSearch.trim() !== "" ? debouncedSearch.trim() : undefined, + }, + fetchPolicy: "network-only", + }); const allEntries = useMemo(() => { - const edges = currentData?.fraudProtectionLogs?.edges ?? []; + const edges = data?.fraudProtectionLogs?.edges ?? []; return edges - .map((edge) => { - const node = edge?.node; - if (node == null) return null; - return parseEntry(node, locale); - }) - .filter((e): e is FraudProtectionLogEntry => e != null); - }, [currentData, locale]); + .map((edge) => edge?.node) + .filter((node): node is NonNullable => node != null) + .map(mapLogNodeToEntry); + }, [data]); const entries = useMemo( () => allEntries.map((entry) => ({ ...entry, - isExpanded: expandedRowId === entry.id, + isExpanded: false, })), - [allEntries, expandedRowId] + [allEntries] ); - const rowItems = useMemo(() => { - const items: FraudProtectionLogRowItem[] = []; - for (const entry of entries) { - items.push({ kind: "entry", entry }); - if (entry.isExpanded) { - items.push({ - kind: "details", - id: `${entry.id}::details`, - entry, - }); - } - } - return items; - }, [entries]); + const rowItems = useMemo( + () => entries.map((entry) => ({ kind: "entry" as const, entry })), + [entries] + ); + // Reset offset when filters change useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setOffset(0); - }, [resultFilter, selectedReasonCodes, debouncedSearch]); + }, [ + resultFilter, + selectedReasonCodes, + debouncedSearch, + queryRangeFrom, + queryRangeTo, + ]); const onClickRefresh = useCallback(() => { setLastUpdatedAt(new Date()); @@ -590,10 +726,6 @@ const FraudProtectionLogsTab: React.VFC = setSearchText(""); }, []); - const onToggleRow = useCallback((id: string) => { - setExpandedRowId((prev) => (prev === id ? null : id)); - }, []); - const onClickRow = useCallback( (id: string) => { const nodeID = ensureFraudDecisionNodeID(id); @@ -607,25 +739,13 @@ const FraudProtectionLogsTab: React.VFC = const onRenderItemColumn = useCallback( (item?: FraudProtectionLogRowItem, _index?: number, column?: IColumn) => { if (item == null) return null; - if (item.kind === "details") { - if (column?.key !== "details") return null; - return ; - } + if (item.kind === "details") return null; const entry = item.entry; switch (column?.key) { - case "expand": - return ( - { - e.stopPropagation(); - onToggleRow(entry.id); - }} - /> - ); + case "columnSettings": + return null; + case "timestamp": + return {formatDatetime(locale, entry.createdAt) ?? "—"}; case "action": return ( @@ -644,6 +764,12 @@ const FraudProtectionLogsTab: React.VFC = ); case "ip": return {entry.ipAddress || "—"}; + case "ipCountry": + return {entry.geoLocationCode || "—"}; + case "phoneCountry": + return {entry.phoneCountryCode || "—"}; + case "phone": + return {entry.phoneNumber || "—"}; default: { const fieldName = column?.fieldName as | keyof FraudProtectionLogEntryViewModel @@ -653,7 +779,7 @@ const FraudProtectionLogsTab: React.VFC = } } }, - [onToggleRow] + [locale] ); const onRenderRow = useCallback( @@ -663,9 +789,6 @@ const FraudProtectionLogsTab: React.VFC = if (item == null) { return ; } - if (item.kind === "details") { - return ; - } return (
= } }} > - +
); }, @@ -693,22 +813,22 @@ const FraudProtectionLogsTab: React.VFC = setOffset(newOffset); }, []); - const localizedColumns = useMemo( - () => - columns.map((col) => ({ - ...col, - name: - col.key === "expand" - ? "" - : renderToString( - `FraudProtectionConfigurationScreen.logs.column.${col.key}` - ), - })), - [renderToString] - ); - - const totalCount = - currentData?.fraudProtectionLogs?.totalCount ?? undefined; + const localizedColumns = useMemo(() => { + return COLUMN_DEFS.filter( + (def) => def.alwaysShown || visibleOptionalColumns.has(def.key) + ).map((def) => ({ + key: def.key, + name: renderToString( + `FraudProtectionConfigurationScreen.logs.column.${def.key}` + ), + fieldName: def.fieldName, + minWidth: def.minWidth, + maxWidth: def.maxWidth, + columnActionsMode: ColumnActionsMode.disabled, + })); + }, [renderToString, visibleOptionalColumns]); + + const totalCount = data?.fraudProtectionLogs?.totalCount ?? 0; const isEmpty = !loading && rowItems.length === 0; if (error != null) { @@ -725,7 +845,12 @@ const FraudProtectionLogsTab: React.VFC =
@@ -741,6 +866,8 @@ const FraudProtectionLogsTab: React.VFC = options={resultOptions} onChange={onChangeResult} /> +
+
= allowFreeInput={false} /> = "FraudProtectionConfigurationScreen.logs.search.placeholder" )} /> -
-
- +
+ + +
{!isEmpty ? ( <> - { - if (item == null) { - return String(index ?? ""); - } - return item.kind === "details" - ? item.id - : `${item.entry.id}-detail`; - }} - selectionMode={SelectionMode.none} - layoutMode={DetailsListLayoutMode.justified} - onRenderRow={onRenderRow} - onRenderItemColumn={onRenderItemColumn} - className={styles.list} - /> +
+ { + if (item == null) { + return String(index ?? ""); + } + return `${item.entry.id}-row`; + }} + selectionMode={SelectionMode.none} + layoutMode={DetailsListLayoutMode.justified} + onRenderRow={onRenderRow} + onRenderItemColumn={onRenderItemColumn} + className={styles.list} + /> +
= onSelectRangeTo={onSelectRangeTo} onCommitDateRange={commitDateRange} onDismiss={onDismissDateRangeDialog} + showTimePicker={true} /> ); diff --git a/portal/src/components/fraud-protection/FraudProtectionOverviewTab.module.css b/portal/src/components/fraud-protection/FraudProtectionOverviewTab.module.css index a6c021a0d9..8037d7257d 100644 --- a/portal/src/components/fraud-protection/FraudProtectionOverviewTab.module.css +++ b/portal/src/components/fraud-protection/FraudProtectionOverviewTab.module.css @@ -18,6 +18,7 @@ .overviewTimeRangeButton { @apply appearance-none border-0 bg-transparent px-[14px] py-[5px] text-sm leading-5; color: #605e5c; + font-family: inherit; } .overviewTimeRangeButtonSelected { @@ -26,33 +27,31 @@ } .overviewLayout { - @apply flex flex-wrap gap-4; + @apply flex flex-col gap-4; } -.overviewMain { - @apply grid grid-cols-1 gap-4; - min-width: min(100%, 480px); - flex: 2 1 0px; +.overviewCardsRow { + @apply grid grid-cols-2 gap-4; } -@media (min-width: 640px) { - .overviewMain { - @apply grid-cols-2; +@media (min-width: 900px) { + .overviewCardsRow { + @apply grid-cols-4; } } -.overviewMainRow1 { - @apply col-span-1 grid grid-cols-1 gap-4; +.overviewBottomRow { + @apply grid grid-cols-1 gap-4; } -@media (min-width: 640px) { - .overviewMainRow1 { - @apply col-span-2 grid-cols-2; +@media (min-width: 768px) { + .overviewBottomRow { + @apply grid-cols-2; } } -.overviewSide { - @apply flex flex-none flex-col self-start; - flex: 1 1 0px; - min-width: min(100%, 300px); +@media (min-width: 1200px) { + .overviewBottomRow { + @apply grid-cols-3; + } } diff --git a/portal/src/components/fraud-protection/FraudProtectionOverviewTab.tsx b/portal/src/components/fraud-protection/FraudProtectionOverviewTab.tsx index d783c1d710..a5131df797 100644 --- a/portal/src/components/fraud-protection/FraudProtectionOverviewTab.tsx +++ b/portal/src/components/fraud-protection/FraudProtectionOverviewTab.tsx @@ -7,7 +7,12 @@ import { FraudProtectionDecisionAction } from "../../types"; import { useFraudProtectionOverviewQueryQuery } from "../../graphql/adminapi/query/fraudProtectionOverviewQuery.generated"; import OverviewMetricCard from "./OverviewMetricCard"; import OverviewEnforcementCard from "./OverviewEnforcementCard"; -import OverviewTopSourceIPs, { SourceIPRow } from "./OverviewTopSourceIPs"; +import OverviewTopSourceIPs, { + OverviewTopIPLocations, + OverviewTopSMSOrigins, + SourceIPRow, +} from "./OverviewTopSourceIPs"; +import OverviewRequestsChart from "./OverviewRequestsChart"; import styles from "./FraudProtectionOverviewTab.module.css"; type OverviewTimeRange = "24h" | "7d"; @@ -26,15 +31,22 @@ const FraudProtectionOverviewTab: React.VFC = const [overviewTimeRange, setOverviewTimeRange] = useState("24h"); + const [showAllTopLists, setShowAllTopLists] = useState(false); + + const toggleShowAllTopLists = useCallback(() => { + setShowAllTopLists((prev) => !prev); + }, []); const timeRangeVars = useMemo(() => { const now = new Date(); if (overviewTimeRange === "24h") { + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const endOfToday = new Date(now); + endOfToday.setHours(23, 59, 59, 999); return { - rangeFrom: new Date( - now.getTime() - 24 * 60 * 60 * 1000 - ).toISOString(), - rangeTo: now.toISOString(), + rangeFrom: startOfToday.toISOString(), + rangeTo: endOfToday.toISOString(), }; } return { @@ -57,12 +69,44 @@ const FraudProtectionOverviewTab: React.VFC = rangeTo: timeRangeVars.rangeTo, }, }); - const overview = overviewData?.fraudProtectionOverview ?? null; const overviewHasError = overviewError != null; const onRetryOverview = useCallback(() => { void refetchOverview(); }, [refetchOverview]); + const sendSMS = overviewData?.fraudProtectionOverview?.sendSMS; + + const sourceIPs = useMemo(() => { + return (sendSMS?.topSourceIPs ?? []).map((ip) => ({ + ip: ip.ipAddress, + geoCountryCode: ip.geoCountryCode, + label: ip.ipAddress, + total: ip.total, + blocked: ip.blocked, + flagged: ip.flagged, + })); + }, [sendSMS?.topSourceIPs]); + + const smsOrigins = useMemo(() => { + return sendSMS?.topSMSOrigins ?? []; + }, [sendSMS?.topSMSOrigins]); + + const ipLocations = useMemo(() => { + return sendSMS?.topIPLocations ?? []; + }, [sendSMS?.topIPLocations]); + + const timeBuckets = useMemo(() => { + return sendSMS?.timeBuckets ?? []; + }, [sendSMS?.timeBuckets]); + + const displayMetrics = useMemo(() => { + return { + total: sendSMS?.total ?? 0, + blocked: sendSMS?.blocked ?? 0, + flagged: sendSMS?.flagged ?? 0, + }; + }, [sendSMS?.blocked, sendSMS?.flagged, sendSMS?.total]); + const formatCount = useCallback((value: number | undefined): string => { return value == null ? "—" : String(value); }, []); @@ -78,24 +122,6 @@ const FraudProtectionOverviewTab: React.VFC = : "FraudProtectionConfigurationScreen.overview.enforcement.protect.description" ); - const sourceIPs = useMemo(() => { - return ( - overview?.sendSMS.topSourceIPs.map((sourceIP) => ({ - ip: sourceIP.ipAddress, - total: sourceIP.total, - blocked: sourceIP.blocked, - flagged: sourceIP.flagged, - })) ?? [] - ); - }, [overview]); - - const maxTotal = useMemo(() => { - if (sourceIPs.length === 0) { - return 0; - } - return Math.max(...sourceIPs.map((row) => row.total)); - }, [sourceIPs]); - const overviewTimeRangeOptions = useMemo( () => [ { @@ -149,29 +175,27 @@ const FraudProtectionOverviewTab: React.VFC = ) : (
-
-
- - -
+
+ + = title={renderToString( "FraudProtectionConfigurationScreen.overview.blocked.title" )} - value={formatCount(overview?.sendSMS.blocked)} + value={formatCount(displayMetrics.blocked)} />
-
- + +
+ + +
)} diff --git a/portal/src/components/fraud-protection/OverviewMetricCard.module.css b/portal/src/components/fraud-protection/OverviewMetricCard.module.css index a56ecee49a..68e0b5757a 100644 --- a/portal/src/components/fraud-protection/OverviewMetricCard.module.css +++ b/portal/src/components/fraud-protection/OverviewMetricCard.module.css @@ -68,6 +68,7 @@ .metricLink { @apply mt-1 w-fit appearance-none border-0 bg-transparent p-0 text-left text-[14px] leading-5; color: #176df3; + font-family: inherit; font-weight: 400; } diff --git a/portal/src/components/fraud-protection/OverviewRequestsChart.module.css b/portal/src/components/fraud-protection/OverviewRequestsChart.module.css new file mode 100644 index 0000000000..8d1219b81b --- /dev/null +++ b/portal/src/components/fraud-protection/OverviewRequestsChart.module.css @@ -0,0 +1,20 @@ +.container { + @apply flex flex-col gap-3; + background: #fff; + border: 1px solid #edebe9; + border-radius: 4px; + padding: 20px 24px 16px; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +.title { + font-size: 14px; + font-weight: 600; + color: #323130; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +.chartWrap { + height: 200px; + width: 100%; +} diff --git a/portal/src/components/fraud-protection/OverviewRequestsChart.tsx b/portal/src/components/fraud-protection/OverviewRequestsChart.tsx new file mode 100644 index 0000000000..4b36787e5b --- /dev/null +++ b/portal/src/components/fraud-protection/OverviewRequestsChart.tsx @@ -0,0 +1,237 @@ +import React, { useMemo } from "react"; +import { Bar } from "react-chartjs-2"; +import { ChartOptions } from "chart.js"; +import { DateTime } from "luxon"; +import { FraudProtectionOverviewQueryQuery } from "../../graphql/adminapi/query/fraudProtectionOverviewQuery.generated"; +import styles from "./OverviewRequestsChart.module.css"; + +type TimeBucket = + FraudProtectionOverviewQueryQuery["fraudProtectionOverview"]["sendSMS"]["timeBuckets"][number]; + +interface SlottedBucket { + label: string; + total: number; + blocked: number; + flagged: number; +} + +interface OverviewRequestsChartProps { + timeBuckets: TimeBucket[]; + timeRange: "24h" | "7d"; + rangeFrom: string; + rangeTo: string; +} + +// Build hourly slots for 24h view. +function buildHourlySlots( + timeBuckets: TimeBucket[], + rangeFrom: string, + rangeTo: string +): SlottedBucket[] { + const byHour = new Map(); + for (const b of timeBuckets) { + const key = DateTime.fromISO(b.hour).toUTC().startOf("hour").toISO(); + if (key != null) byHour.set(key, b); + } + + const from = DateTime.fromISO(rangeFrom).toUTC().startOf("hour"); + const to = DateTime.fromISO(rangeTo).toUTC().startOf("hour"); + + const slots: SlottedBucket[] = []; + let cursor = from; + while (cursor <= to) { + const key = cursor.toISO(); + const bucket = key != null ? byHour.get(key) : undefined; + slots.push({ + label: cursor.toLocal().toFormat("h a"), + total: bucket?.total ?? 0, + blocked: bucket?.blocked ?? 0, + flagged: bucket?.flagged ?? 0, + }); + cursor = cursor.plus({ hours: 1 }); + } + return slots; +} + +// Build daily slots for 7d view by aggregating hourly buckets into days. +function buildDailySlots( + timeBuckets: TimeBucket[], + rangeFrom: string, + rangeTo: string +): SlottedBucket[] { + // Aggregate by local day key "yyyy-MM-dd" + const byDay = new Map(); + for (const b of timeBuckets) { + const dayKey = DateTime.fromISO(b.hour).toLocal().toFormat("yyyy-MM-dd"); + const existing = byDay.get(dayKey); + if (existing != null) { + existing.total += b.total; + existing.blocked += b.blocked; + existing.flagged += b.flagged; + } else { + const label = DateTime.fromISO(b.hour).toLocal().toFormat("LLL d"); + byDay.set(dayKey, { label, total: b.total, blocked: b.blocked, flagged: b.flagged }); + } + } + + // Generate every day in range to fill gaps + const from = DateTime.fromISO(rangeFrom).toLocal().startOf("day"); + const to = DateTime.fromISO(rangeTo).toLocal().startOf("day"); + + const slots: SlottedBucket[] = []; + let cursor = from; + while (cursor <= to) { + const dayKey = cursor.toFormat("yyyy-MM-dd"); + const existing = byDay.get(dayKey); + slots.push( + existing ?? { label: cursor.toFormat("LLL d"), total: 0, blocked: 0, flagged: 0 } + ); + cursor = cursor.plus({ days: 1 }); + } + return slots; +} + +function buildSlots( + timeBuckets: TimeBucket[], + rangeFrom: string, + rangeTo: string, + timeRange: "24h" | "7d" +): SlottedBucket[] { + if (timeRange === "7d") { + return buildDailySlots(timeBuckets, rangeFrom, rangeTo); + } + return buildHourlySlots(timeBuckets, rangeFrom, rangeTo); +} + +const OverviewRequestsChart: React.VFC = + function OverviewRequestsChart({ timeBuckets, timeRange, rangeFrom, rangeTo }) { + const slots = useMemo( + () => buildSlots(timeBuckets, rangeFrom, rangeTo, timeRange), + [timeBuckets, rangeFrom, rangeTo, timeRange] + ); + + const data = useMemo( + () => ({ + labels: slots.map((s) => s.label), + datasets: [ + { + label: "Blocked", + data: slots.map((s) => s.blocked), + backgroundColor: "#fca5a5", + borderWidth: 0, + stack: "stack", + }, + { + label: "Flagged", + data: slots.map((s) => s.flagged), + backgroundColor: "#fde68a", + borderWidth: 0, + stack: "stack", + }, + { + label: "Total requests", + data: slots.map((s) => Math.max(0, s.total - s.blocked - s.flagged)), + backgroundColor: "#e5e5e5", + borderWidth: 0, + stack: "stack", + }, + ], + }), + [slots] + ); + + const options = useMemo>( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "bottom" as const, + labels: { + usePointStyle: true, + pointStyle: "rect" as const, + boxWidth: 12, + boxHeight: 12, + padding: 24, + color: "#605e5c", + font: { size: 12, family: "'Segoe UI', system-ui, -apple-system, sans-serif" }, + generateLabels: (chart) => { + const datasets = chart.data.datasets; + return datasets.map((ds, i) => ({ + text: ds.label ?? "", + fillStyle: ds.backgroundColor as string, + strokeStyle: "transparent", + lineWidth: 0, + datasetIndex: i, + hidden: false, + fontColor: "#605e5c", + })); + }, + }, + }, + tooltip: { + mode: "index" as const, + backgroundColor: "#ffffff", + borderColor: "#edebe9", + borderWidth: 1, + titleColor: "#323130", + bodyColor: "#605e5c", + titleFont: { size: 12, weight: "600" as const, family: "'Segoe UI', system-ui, -apple-system, sans-serif" }, + bodyFont: { size: 12, family: "'Segoe UI', system-ui, -apple-system, sans-serif" }, + padding: 10, + cornerRadius: 2, + boxWidth: 10, + boxHeight: 10, + usePointStyle: true, + callbacks: { + label: (ctx) => { + const ds = ctx.dataset.label ?? ""; + if (ds === "Total requests") { + const blocked = ctx.chart.data.datasets[0].data[ctx.dataIndex] as number; + const flagged = ctx.chart.data.datasets[1].data[ctx.dataIndex] as number; + const allowed = ctx.raw as number; + return ` Total requests: ${blocked + flagged + allowed}`; + } + return ` ${ds}: ${ctx.raw}`; + }, + }, + }, + }, + scales: { + x: { + stacked: true, + grid: { display: false }, + ticks: { + maxRotation: 0, + autoSkip: false, + font: { size: 11, family: "'Segoe UI', system-ui, -apple-system, sans-serif" }, + color: "#8a8886", + }, + }, + y: { + stacked: true, + min: 0, + grid: { color: "#f3f2f1" }, + ticks: { + precision: 0, + font: { size: 11, family: "'Segoe UI', system-ui, -apple-system, sans-serif" }, + color: "#8a8886", + }, + border: { display: false }, + }, + }, + }), + [timeRange] + ); + + return ( +
+
Requests by action
+
+ +
+
+ ); + }; + +export default OverviewRequestsChart; diff --git a/portal/src/components/fraud-protection/OverviewTopSourceIPs.module.css b/portal/src/components/fraud-protection/OverviewTopSourceIPs.module.css index 1a50ab217a..68e884b48d 100644 --- a/portal/src/components/fraud-protection/OverviewTopSourceIPs.module.css +++ b/portal/src/components/fraud-protection/OverviewTopSourceIPs.module.css @@ -1,13 +1,20 @@ .topSourceSection { - @apply flex flex-col gap-4 rounded-lg border border-[#edebe9] bg-white px-[0.875rem] py-4; + @apply flex flex-col gap-3 rounded-lg border border-[#edebe9] bg-white px-[0.875rem] py-4; + font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, + BlinkMacSystemFont, "Noto Sans", "Helvetica", "Arial", sans-serif; } .topSourceIPsHeader { - @apply flex items-center justify-between w-full; + @apply flex items-center w-full; +} + +.headerToggle { + @apply flex flex-none items-center justify-end; + width: 156px; } .topSourceIPsHeaderLeft { - @apply flex items-center gap-[10px]; + @apply flex items-start gap-[10px] flex-1 min-w-0; } .topSourceIPsIcon { @@ -15,26 +22,68 @@ @apply bg-[#fff4ce] text-[#d97706]; } +.topSourceIPsTitleGroup { + @apply flex flex-col gap-0.5; +} + .topSourceIPsTitle { @apply font-semibold; color: #201f1e; font-size: 14px; } +.topSourceIPsSubtitle { + color: #8a8886; + font-size: 12px; + line-height: 1.4; + min-height: calc(12px * 1.4 * 2); +} + +/* Column header row */ +.columnHeaders { + @apply flex items-center w-full; +} + +.columnHeadersLabel { + @apply flex-1 min-w-0; +} + +.columnHeadersRight { + @apply flex items-center flex-none; +} + +.columnHeader { + @apply font-medium text-right; + font-size: 12px; + color: #8a8886; +} + +.columnHeader.colTotal { + width: 44px; + font-weight: 600; +} + +.columnHeader.colBlocked { + width: 56px; + color: #dc2626; +} + +.columnHeader.colFlagged { + width: 56px; + color: #d97706; +} + +/* List */ .topSourceIPsList { @apply flex flex-col gap-2.5; } .topSourceIPRow { - @apply flex flex-col gap-1 w-full; -} - -.topSourceIPInfo { - @apply flex items-center justify-between text-xs leading-4; + @apply flex items-center w-full text-xs leading-4; } .topSourceIPInfoLeft { - @apply flex items-center gap-1.5; + @apply flex items-center gap-1.5 flex-1 min-w-0; } .topSourceIPRank { @@ -43,33 +92,35 @@ } .topSourceIPAddress { - @apply text-xs; + @apply text-xs truncate; color: #201f1e; } .topSourceIPMetrics { - @apply flex items-center gap-1.5 flex-none; + @apply flex items-center flex-none; } -.totalValue { - @apply text-[11px]; +.metricCol { + @apply text-right; + font-size: 12px; +} + +.metricCol.colTotal { + width: 44px; color: #605e5c; + font-weight: 600; } -.blockedStatus { - @apply text-[11px]; +.metricCol.colBlocked { + width: 56px; color: #dc2626; } -.flaggedStatus { - @apply text-[11px]; +.metricCol.colFlagged { + width: 56px; color: #d97706; } -.topSourceIPProgress { - @apply h-1 w-full rounded-full bg-[#f3f2f1]; -} - -.progressBar { - @apply h-full rounded-full bg-[#176df3]; +.metricEmpty { + color: #c8c6c4; } diff --git a/portal/src/components/fraud-protection/OverviewTopSourceIPs.tsx b/portal/src/components/fraud-protection/OverviewTopSourceIPs.tsx index 534cea6292..602cd6ac50 100644 --- a/portal/src/components/fraud-protection/OverviewTopSourceIPs.tsx +++ b/portal/src/components/fraud-protection/OverviewTopSourceIPs.tsx @@ -1,117 +1,288 @@ -import React, { useCallback, useContext, useMemo, useState } from "react"; +import React, { useContext, useMemo } from "react"; import { Icon, Text } from "@fluentui/react"; import { Context } from "../../intl"; import ActionButton from "../../ActionButton"; import { useSystemConfig } from "../../context/SystemConfigContext"; import styles from "./OverviewTopSourceIPs.module.css"; -export interface SourceIPRow { - ip: string; +export interface SourceRow { + label: string; total: number; blocked: number; flagged: number; } -export interface OverviewTopSourceIPsProps { - sourceIPs: SourceIPRow[]; - maxTotal: number; +/** @deprecated Use SourceRow */ +export interface SourceIPRow extends SourceRow { + ip: string; + geoCountryCode?: string; } -const OverviewTopSourceIPs: React.VFC = - function OverviewTopSourceIPs(props) { - const { sourceIPs, maxTotal } = props; - const { themes } = useSystemConfig(); - const { renderToString } = useContext(Context); +export interface OverviewTopListProps { + rows: SourceRow[]; + iconName: string; + titleKey: string; + subtitleKey?: string; + toggleKey: string; + showLessKey: string; + showAll: boolean; + onToggleShowAll: () => void; +} + +const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" }); - const [showAll, setShowAll] = useState(false); +function formatCountryLabel(code: string): string { + if (!code) return code; + try { + const fullName = countryDisplayNames.of(code); + return fullName != null && fullName !== code + ? `${fullName} (${code})` + : code; + } catch { + return code; + } +} - const toggleShowAll = useCallback(() => { - setShowAll((prev) => !prev); - }, []); +function formatIPLabel(ip: string, countryCode: string): string { + if (countryCode !== "") { + return `${ip} (${countryCode})`; + } + return ip; +} + +const OverviewTopList: React.VFC = + function OverviewTopList(props) { + const { + rows, + iconName, + titleKey, + subtitleKey, + toggleKey, + showLessKey, + showAll, + onToggleShowAll, + } = props; + const { themes } = useSystemConfig(); + const { renderToString } = useContext(Context); const maxSlots = showAll ? 10 : 5; - const displaySourceIPs = useMemo(() => { - const list = sourceIPs.slice(0, maxSlots); + const displayRows = useMemo(() => { + const list = rows.slice(0, maxSlots); while (list.length < maxSlots) { - list.push({ ip: "—", total: 0, blocked: 0, flagged: 0 }); + list.push({ label: "—", total: 0, blocked: 0, flagged: 0 }); } return list; - }, [sourceIPs, maxSlots]); + }, [rows, maxSlots]); return (
- +
- +
+ + {renderToString(titleKey)} + + {subtitleKey != null ? ( + + {renderToString(subtitleKey)} + + ) : null} +
+
+
+ +
+
+ + {/* Column headers */} +
+
+
+
+ {renderToString( + "FraudProtectionConfigurationScreen.overview.list.column.blocked" + )} +
+
+ {renderToString( + "FraudProtectionConfigurationScreen.overview.list.column.flagged" + )} +
+
{renderToString( - "FraudProtectionConfigurationScreen.overview.topSourceIPs.title" + "FraudProtectionConfigurationScreen.overview.list.column.total" )} - +
-
+
- {displaySourceIPs.map((row, index) => ( -
-
+ {displayRows.map((row, index) => { + const isEmpty = row.label === "—"; + return ( +
#{index + 1}
-
{row.ip}
+
{row.label}
-
- {row.total || (row.ip === "—" ? "" : 0)} +
+ {isEmpty ? "—" : row.blocked} +
+
+ {isEmpty ? "—" : row.flagged} +
+
+ {isEmpty ? "—" : row.total}
- {row.blocked > 0 ? ( -
- {renderToString( - "FraudProtectionConfigurationScreen.overview.topSourceIPs.blockedStatus", - { count: row.blocked } - )} -
- ) : null} - {row.blocked === 0 && row.flagged > 0 ? ( -
- {renderToString( - "FraudProtectionConfigurationScreen.overview.topSourceIPs.flaggedStatus", - { count: row.flagged } - )} -
- ) : null}
-
-
0 ? (row.total / maxTotal) * 100 : 0 - }%`, - }} - /> -
-
- ))} + ); + })}
); }; +export interface OverviewTopSourceIPsProps { + sourceIPs: SourceIPRow[]; + showAll: boolean; + onToggleShowAll: () => void; +} + +const OverviewTopSourceIPs: React.VFC = + function OverviewTopSourceIPs(props) { + const { sourceIPs, showAll, onToggleShowAll } = props; + const rows: SourceRow[] = useMemo( + () => + sourceIPs.map((r) => ({ + ...r, + label: formatIPLabel(r.ip, r.geoCountryCode ?? ""), + })), + [sourceIPs] + ); + return ( + + ); + }; + +export interface OverviewTopSMSOriginsProps { + smsOrigins: Array<{ + phoneCountryCode: string; + total: number; + blocked: number; + flagged: number; + }>; + showAll: boolean; + onToggleShowAll: () => void; +} + +export const OverviewTopSMSOrigins: React.VFC = + function OverviewTopSMSOrigins(props) { + const { smsOrigins, showAll, onToggleShowAll } = props; + const rows: SourceRow[] = useMemo( + () => + smsOrigins.map((r) => ({ + label: formatCountryLabel(r.phoneCountryCode), + total: r.total, + blocked: r.blocked, + flagged: r.flagged, + })), + [smsOrigins] + ); + return ( + + ); + }; + +export interface OverviewTopIPLocationsProps { + ipLocations: Array<{ + geoCountryCode: string; + total: number; + blocked: number; + flagged: number; + }>; + showAll: boolean; + onToggleShowAll: () => void; +} + +export const OverviewTopIPLocations: React.VFC = + function OverviewTopIPLocations(props) { + const { ipLocations, showAll, onToggleShowAll } = props; + const rows: SourceRow[] = useMemo( + () => + ipLocations.map((r) => ({ + label: formatCountryLabel(r.geoCountryCode), + total: r.total, + blocked: r.blocked, + flagged: r.flagged, + })), + [ipLocations] + ); + return ( + + ); + }; + export default OverviewTopSourceIPs; diff --git a/portal/src/graphql/adminapi/globalTypes.generated.ts b/portal/src/graphql/adminapi/globalTypes.generated.ts index 0fface397a..cab5265b24 100644 --- a/portal/src/graphql/adminapi/globalTypes.generated.ts +++ b/portal/src/graphql/adminapi/globalTypes.generated.ts @@ -706,18 +706,46 @@ export type FraudProtectionOverview = { sendSMS: FraudProtectionOverviewSendSms; }; +export type FraudProtectionOverviewIpLocation = { + __typename?: 'FraudProtectionOverviewIPLocation'; + blocked: Scalars['Int']['output']; + flagged: Scalars['Int']['output']; + geoCountryCode: Scalars['String']['output']; + total: Scalars['Int']['output']; +}; + +export type FraudProtectionOverviewSmsOrigin = { + __typename?: 'FraudProtectionOverviewSMSOrigin'; + blocked: Scalars['Int']['output']; + flagged: Scalars['Int']['output']; + phoneCountryCode: Scalars['String']['output']; + total: Scalars['Int']['output']; +}; + export type FraudProtectionOverviewSendSms = { __typename?: 'FraudProtectionOverviewSendSMS'; blocked: Scalars['Int']['output']; flagged: Scalars['Int']['output']; + timeBuckets: Array; + topIPLocations: Array; + topSMSOrigins: Array; topSourceIPs: Array; total: Scalars['Int']['output']; }; +export type FraudProtectionOverviewTimeBucket = { + __typename?: 'FraudProtectionOverviewTimeBucket'; + blocked: Scalars['Int']['output']; + flagged: Scalars['Int']['output']; + hour: Scalars['DateTime']['output']; + total: Scalars['Int']['output']; +}; + export type FraudProtectionOverviewTopSourceIp = { __typename?: 'FraudProtectionOverviewTopSourceIP'; blocked: Scalars['Int']['output']; flagged: Scalars['Int']['output']; + geoCountryCode: Scalars['String']['output']; ipAddress: Scalars['String']['output']; total: Scalars['Int']['output']; }; diff --git a/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.generated.ts b/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.generated.ts index da0aecfa99..9e166ed5ca 100644 --- a/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.generated.ts +++ b/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.generated.ts @@ -9,7 +9,7 @@ export type FraudProtectionOverviewQueryQueryVariables = Types.Exact<{ }>; -export type FraudProtectionOverviewQueryQuery = { __typename?: 'Query', fraudProtectionOverview: { __typename?: 'FraudProtectionOverview', sendSMS: { __typename?: 'FraudProtectionOverviewSendSMS', total: number, blocked: number, flagged: number, topSourceIPs: Array<{ __typename?: 'FraudProtectionOverviewTopSourceIP', ipAddress: string, total: number, blocked: number, flagged: number }> } } }; +export type FraudProtectionOverviewQueryQuery = { __typename?: 'Query', fraudProtectionOverview: { __typename?: 'FraudProtectionOverview', sendSMS: { __typename?: 'FraudProtectionOverviewSendSMS', total: number, blocked: number, flagged: number, topSourceIPs: Array<{ __typename?: 'FraudProtectionOverviewTopSourceIP', ipAddress: string, geoCountryCode: string, total: number, blocked: number, flagged: number }>, topIPLocations: Array<{ __typename?: 'FraudProtectionOverviewIPLocation', geoCountryCode: string, total: number, blocked: number, flagged: number }>, topSMSOrigins: Array<{ __typename?: 'FraudProtectionOverviewSMSOrigin', phoneCountryCode: string, total: number, blocked: number, flagged: number }>, timeBuckets: Array<{ __typename?: 'FraudProtectionOverviewTimeBucket', hour: any, total: number, blocked: number, flagged: number }> } } }; export const FraudProtectionOverviewQueryDocument = gql` @@ -21,6 +21,25 @@ export const FraudProtectionOverviewQueryDocument = gql` flagged topSourceIPs { ipAddress + geoCountryCode + total + blocked + flagged + } + topIPLocations { + geoCountryCode + total + blocked + flagged + } + topSMSOrigins { + phoneCountryCode + total + blocked + flagged + } + timeBuckets { + hour total blocked flagged diff --git a/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.graphql b/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.graphql index 54b8ba62db..5f11ef7365 100644 --- a/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.graphql +++ b/portal/src/graphql/adminapi/query/fraudProtectionOverviewQuery.graphql @@ -6,6 +6,25 @@ query fraudProtectionOverviewQuery($rangeFrom: DateTime, $rangeTo: DateTime) { flagged topSourceIPs { ipAddress + geoCountryCode + total + blocked + flagged + } + topIPLocations { + geoCountryCode + total + blocked + flagged + } + topSMSOrigins { + phoneCountryCode + total + blocked + flagged + } + timeBuckets { + hour total blocked flagged diff --git a/portal/src/graphql/adminapi/schema.graphql b/portal/src/graphql/adminapi/schema.graphql index 34e2bb7742..676e04042f 100644 --- a/portal/src/graphql/adminapi/schema.graphql +++ b/portal/src/graphql/adminapi/schema.graphql @@ -1146,6 +1146,36 @@ type FraudProtectionOverview { sendSMS: FraudProtectionOverviewSendSMS! } +"""""" +type FraudProtectionOverviewIPLocation { + """""" + blocked: Int! + + """""" + flagged: Int! + + """""" + geoCountryCode: String! + + """""" + total: Int! +} + +"""""" +type FraudProtectionOverviewSMSOrigin { + """""" + blocked: Int! + + """""" + flagged: Int! + + """""" + phoneCountryCode: String! + + """""" + total: Int! +} + """""" type FraudProtectionOverviewSendSMS { """""" @@ -1154,6 +1184,15 @@ type FraudProtectionOverviewSendSMS { """""" flagged: Int! + """""" + timeBuckets: [FraudProtectionOverviewTimeBucket!]! + + """""" + topIPLocations: [FraudProtectionOverviewIPLocation!]! + + """""" + topSMSOrigins: [FraudProtectionOverviewSMSOrigin!]! + """""" topSourceIPs: [FraudProtectionOverviewTopSourceIP!]! @@ -1161,6 +1200,21 @@ type FraudProtectionOverviewSendSMS { total: Int! } +"""""" +type FraudProtectionOverviewTimeBucket { + """""" + blocked: Int! + + """""" + flagged: Int! + + """""" + hour: DateTime! + + """""" + total: Int! +} + """""" type FraudProtectionOverviewTopSourceIP { """""" @@ -1169,6 +1223,9 @@ type FraudProtectionOverviewTopSourceIP { """""" flagged: Int! + """""" + geoCountryCode: String! + """""" ipAddress: String! diff --git a/portal/src/graphql/portal/DateRangeDialog.module.css b/portal/src/graphql/portal/DateRangeDialog.module.css index 239036e00d..2715f8d1e4 100644 --- a/portal/src/graphql/portal/DateRangeDialog.module.css +++ b/portal/src/graphql/portal/DateRangeDialog.module.css @@ -3,3 +3,15 @@ height: 0; visibility: hidden; } + +.dateTimePicker { + margin-bottom: 12px; +} + +.dateTimePickerLabel { + display: block; + font-size: 14px; + font-weight: 600; + color: #323130; + margin-bottom: 4px; +} diff --git a/portal/src/graphql/portal/DateRangeDialog.tsx b/portal/src/graphql/portal/DateRangeDialog.tsx index 211dccea59..88d35d5956 100644 --- a/portal/src/graphql/portal/DateRangeDialog.tsx +++ b/portal/src/graphql/portal/DateRangeDialog.tsx @@ -5,6 +5,7 @@ import styles from "./DateRangeDialog.module.css"; import TextField from "../../TextField"; import PrimaryButton from "../../PrimaryButton"; import DefaultButton from "../../DefaultButton"; +import DateTimePicker from "../../DateTimePicker"; interface DateRangeDialogProps { hidden: boolean; @@ -21,6 +22,7 @@ interface DateRangeDialogProps { onSelectRangeTo?: (date: Date | null | undefined) => void; onCommitDateRange?: (e?: React.MouseEvent) => void; onDismiss?: (e?: React.MouseEvent) => void; + showTimePicker?: boolean; } const DateRangeDialog: React.VFC = @@ -40,6 +42,7 @@ const DateRangeDialog: React.VFC = onSelectRangeTo, onCommitDateRange, onDismiss, + showTimePicker = false, } = props; const dateRangeDialogContentProps = useMemo(() => { @@ -53,28 +56,57 @@ const DateRangeDialog: React.VFC = hidden={hidden} onDismiss={onDismiss} dialogContentProps={dateRangeDialogContentProps} - /* https://developer.microsoft.com/en-us/fluentui#/controls/web/dialog - * Best practice says the max width is 340 */ - minWidth={340} + minWidth={showTimePicker ? 480 : 340} > {/* Dialog is based on Modal, which will focus the first child on open. * However, we do not want the date picker to be opened at the same time. * So we make the first focusable element a hidden TextField */} - - + {showTimePicker ? ( + <> + + {fromDatePickerLabel} + + } + pickedDateTime={rangeFrom ?? null} + minDateTime={null} + onPickDateTime={onSelectRangeFrom ?? (() => {})} + showClearButton={false} + /> + + {toDatePickerLabel} + + } + pickedDateTime={rangeTo ?? null} + minDateTime={null} + onPickDateTime={onSelectRangeTo ?? (() => {})} + showClearButton={false} + /> + + ) : ( + <> + + + + )} onChangeKey("settings")} /> ) : null} - {selectedKey === "logs" ? : null} + {selectedKey === "logs" ? ( + + ) : null} {selectedKey === "settings" ? ( (["overview", "logs", "settings"]); + usePivotNavigation([ + "overview", + "logs", + "settings", + ]); const handleToggleEnabledAndSave = useCallback( (enabled: boolean) => { diff --git a/portal/src/screens/fraud-protection/FraudProtectionLogEntryScreen.tsx b/portal/src/screens/fraud-protection/FraudProtectionLogEntryScreen.tsx index 2b9419a5fd..c6c25b3e6f 100644 --- a/portal/src/screens/fraud-protection/FraudProtectionLogEntryScreen.tsx +++ b/portal/src/screens/fraud-protection/FraudProtectionLogEntryScreen.tsx @@ -69,8 +69,10 @@ const FraudProtectionLogEntryScreen: React.VFC = ? data.node : null; - const createdAt = - node != null ? formatDatetime(locale, node.createdAt) ?? "—" : "—"; + const createdAt = useMemo(() => { + return node != null ? formatDatetime(locale, node.createdAt) ?? "—" : "—"; + }, [locale, node]); + const action = (() => { if (node == null) { return "—"; @@ -79,24 +81,29 @@ const FraudProtectionLogEntryScreen: React.VFC = "FraudProtectionConfigurationScreen.logs.action.smsotp" ); })(); + + const triggeredWarnings: readonly string[] = node?.triggeredWarnings ?? []; + const decision: FraudProtectionDecision | null = node?.decision ?? null; + const verdict = (() => { - if (node == null) { - return "—"; - } - return renderToString( - getResultMessageID(node.decision, node.triggeredWarnings) - ); + if (decision == null) return "—"; + return renderToString(getResultMessageID(decision, triggeredWarnings)); })(); + const verdictClassName = (() => { - if (node?.decision === FraudProtectionDecision.Blocked) { + if (decision === FraudProtectionDecision.Blocked) { return styles.summaryBadgeBlocked; } - if ((node?.triggeredWarnings.length ?? 0) > 0) { + if (triggeredWarnings.length > 0) { return styles.summaryBadgeFlagged; } return styles.summaryBadgeAllowed; })(); + const ipAddress = node?.ipAddress || "—"; + const geoLocationCode = node?.geoLocationCode || "—"; + const userAgent = node?.userAgent || "—"; + const phoneNumber = (() => { switch (node?.actionDetail.__typename) { case "FraudProtectionDecisionSendSMSActionDetail": @@ -113,17 +120,13 @@ const FraudProtectionLogEntryScreen: React.VFC = return "—"; } })(); - // eslint-disable-next-line react-hooks/preserve-manual-memoization + const rawEventLog = useMemo(() => { - if (node?.data == null) { - return "{}"; - } + if (node?.data == null) return "{}"; return JSON.stringify(node.data, null, 2); }, [node?.data]); if (!loading && error == null && node == null) { - // The log entry does not exist in this project (e.g. after switching - // projects); fall back to the fraud protection logs. return ( - - {node?.ipAddress || "—"} - + {ipAddress}
- - {node?.geoLocationCode || "—"} - + {geoLocationCode}
@@ -191,7 +190,7 @@ const FraudProtectionLogEntryScreen: React.VFC = - {node?.userAgent || "—"} + {userAgent}
@@ -224,9 +223,9 @@ const FraudProtectionLogEntryScreen: React.VFC = - {node != null && node.triggeredWarnings.length > 0 ? ( + {triggeredWarnings.length > 0 ? (
- {node.triggeredWarnings.map((code) => ( + {triggeredWarnings.map((code) => ( {code} diff --git a/portal/src/util/formatDatetime.ts b/portal/src/util/formatDatetime.ts index fbaa45d303..83482e1a14 100644 --- a/portal/src/util/formatDatetime.ts +++ b/portal/src/util/formatDatetime.ts @@ -1,16 +1,84 @@ import { DateTime } from "luxon"; -// Ref: https://tc39.es/ecma402/#sec-datetimeformat-abstracts -const dateTimeWithTimezoneFormatOption = { +const dateTimeFormatOption = { year: "numeric" as const, month: "short" as const, day: "numeric" as const, hour: "numeric" as const, minute: "numeric" as const, second: "numeric" as const, +}; + +const dateTimeWithoutSecondsFormatOption = { + month: "short" as const, + day: "numeric" as const, + hour: "numeric" as const, + minute: "numeric" as const, +}; + +const dateTimeWithYearWithoutSecondsFormatOption = { + ...dateTimeWithoutSecondsFormatOption, + year: "numeric" as const, +}; + +// Ref: https://tc39.es/ecma402/#sec-datetimeformat-abstracts +const dateTimeWithTimezoneFormatOption = { + ...dateTimeFormatOption, timeZoneName: "longOffset" as const, }; +function toDateTime(date: Date | string | null): DateTime | null { + if (date instanceof Date) { + return DateTime.fromJSDate(date); + } + if (typeof date === "string") { + return DateTime.fromISO(date); + } + return null; +} + +export function formatDatetimeWithoutTimezone( + locale: string, + date: Date | string | null +): string | null { + const datetime = toDateTime(date); + if (datetime == null) { + return null; + } + + return datetime.setLocale(locale).toLocaleString(dateTimeFormatOption); +} + +export function formatCustomDateRangeLabel( + locale: string, + rangeFrom: Date | null, + rangeTo: Date | null +): string | undefined { + const fromLabel = + rangeFrom != null + ? toDateTime(rangeFrom) + ?.setLocale(locale) + .toLocaleString(dateTimeWithoutSecondsFormatOption) ?? null + : null; + const toLabel = + rangeTo != null + ? toDateTime(rangeTo) + ?.setLocale(locale) + .toLocaleString(dateTimeWithYearWithoutSecondsFormatOption) ?? null + : null; + + if (fromLabel != null && toLabel != null) { + return `${fromLabel} - ${toLabel}`; + } + if (fromLabel != null) { + return fromLabel; + } + if (toLabel != null) { + return toLabel; + } + return undefined; +} + export function formatDatetime( locale: string, date: Date | string | null