From 7808c70f74d80729e7d8afb25630f632e92b853d Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 25 Jun 2026 14:37:57 +0800 Subject: [PATCH 1/5] [Fraud Protection] Add overview time buckets and per-IP geo to Admin API Add hourly timeBuckets to the fraud protection overview query for the requests-by-action chart, and resolve the dominant geo country per source IP via a two-query merge instead of a correlated subquery. Co-authored-by: Cursor --- .../graphql/fraud_protection_overview.go | 130 +++++++++++++++ pkg/lib/audit/fraud_protection_overview.go | 156 +++++++++++++++++- pkg/lib/audit/read_store.go | 18 +- .../graphql/adminapi/globalTypes.generated.ts | 28 ++++ .../fraudProtectionOverviewQuery.generated.ts | 21 ++- .../fraudProtectionOverviewQuery.graphql | 19 +++ portal/src/graphql/adminapi/schema.graphql | 57 +++++++ 7 files changed, 417 insertions(+), 12 deletions(-) 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/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! From 6717d50488a08c8b43ecc304e9a7cb36fcce25c7 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 25 Jun 2026 14:38:09 +0800 Subject: [PATCH 2/5] [Portal] Support time picker and custom range label in date range dialog Add an optional time picker to DateRangeDialog/DateTimePicker, let DateRangeFilterDropdown render a custom range label, and add a formatCustomDateRangeLabel helper for compact date range display. Co-authored-by: Cursor --- portal/src/DateTimePicker.tsx | 6 ++ .../audit-log/DateRangeFilterDropdown.tsx | 21 +++++- .../graphql/portal/DateRangeDialog.module.css | 12 ++++ portal/src/graphql/portal/DateRangeDialog.tsx | 66 ++++++++++++----- portal/src/util/formatDatetime.ts | 72 ++++++++++++++++++- 5 files changed, 155 insertions(+), 22 deletions(-) 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/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} + /> + + ) : ( + <> + + + + )} Date: Thu, 25 Jun 2026 14:38:38 +0800 Subject: [PATCH 3/5] [Fraud Protection] Build overview tab with requests chart and top lists Add the requests-by-action stacked bar chart (hourly for 24h, daily for 7d), wire the overview metrics, SMS destinations, IP locations and top source IPs to real query data, and register the Chart.js Legend plugin. Co-authored-by: Cursor --- .../FraudProtectionOverviewTab.module.css | 33 +- .../FraudProtectionOverviewTab.tsx | 132 +++++--- .../OverviewMetricCard.module.css | 1 + .../OverviewRequestsChart.module.css | 20 ++ .../OverviewRequestsChart.tsx | 237 +++++++++++++ .../OverviewTopSourceIPs.module.css | 97 ++++-- .../fraud-protection/OverviewTopSourceIPs.tsx | 315 ++++++++++++++---- portal/src/index.tsx | 5 +- portal/src/locale-data/en.json | 16 + 9 files changed, 698 insertions(+), 158 deletions(-) create mode 100644 portal/src/components/fraud-protection/OverviewRequestsChart.module.css create mode 100644 portal/src/components/fraud-protection/OverviewRequestsChart.tsx 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/index.tsx b/portal/src/index.tsx index 1569b71efd..109010a9c0 100644 --- a/portal/src/index.tsx +++ b/portal/src/index.tsx @@ -31,6 +31,7 @@ import { LinearScale, BarElement, Tooltip, + Legend, PointElement, LineElement, ArcElement, @@ -63,8 +64,8 @@ registerIcons({ // https://github.com/authgear/authgear-server/issues/2561 setAutoFreeze(false); -// ChartJS registration for Bar chart in the AnalyticsActivityWidget -ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); +// ChartJS registration for Bar chart in the AnalyticsActivityWidget and fraud protection +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend); // ChartJS registration for Line chart in the AnalyticsActivityWidget ChartJS.register( diff --git a/portal/src/locale-data/en.json b/portal/src/locale-data/en.json index a13655bd33..4d03420923 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1884,12 +1884,28 @@ "FraudProtectionConfigurationScreen.overview.blocked.title": "Blocked", "FraudProtectionConfigurationScreen.overview.total.title": "Total SMS OTP Requests", "FraudProtectionConfigurationScreen.overview.topSourceIPs.title": "Top Source IPs", + "FraudProtectionConfigurationScreen.overview.topSourceIPs.subtitle": "By individual source IP address", "FraudProtectionConfigurationScreen.overview.topSourceIPs.loading": "Loading Top Source IPs...", "FraudProtectionConfigurationScreen.overview.topSourceIPs.empty": "No data available", "FraudProtectionConfigurationScreen.overview.topSourceIPs.toggle": "Show Top 10", "FraudProtectionConfigurationScreen.overview.topSourceIPs.showLess": "Show Less", + "FraudProtectionConfigurationScreen.overview.list.column.total": "Total", + "FraudProtectionConfigurationScreen.overview.list.column.blocked": "Blocked", + "FraudProtectionConfigurationScreen.overview.list.column.flagged": "Flagged", + "FraudProtectionConfigurationScreen.overview.topSourceIPs.totalStatus": "{count} total", "FraudProtectionConfigurationScreen.overview.topSourceIPs.blockedStatus": "{count} blocked", "FraudProtectionConfigurationScreen.overview.topSourceIPs.flaggedStatus": "{count} flagged", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.title": "SMS Destinations", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.subtitle": "By recipient phone country", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.toggle": "Show Top 10", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.showLess": "Show Less", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.totalStatus": "{count} total", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.blockedStatus": "{count} blocked", + "FraudProtectionConfigurationScreen.overview.topSMSOrigins.flaggedStatus": "{count} flagged", + "FraudProtectionConfigurationScreen.overview.topIPLocations.title": "IP Location", + "FraudProtectionConfigurationScreen.overview.topIPLocations.subtitle": "By source IP country", + "FraudProtectionConfigurationScreen.overview.topIPLocations.toggle": "Show Top 10", + "FraudProtectionConfigurationScreen.overview.topIPLocations.showLess": "Show Less", "FraudProtectionConfigurationScreen.overview.timeRange.label": "Time range:", "FraudProtectionConfigurationScreen.overview.timeRange.last24Hours": "Last 24 hours", "FraudProtectionConfigurationScreen.overview.timeRange.last7Days": "Last 7 days", From 8a1b547bd5bdd6ed2d90e8b8f4433135927d3a3f Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Thu, 25 Jun 2026 14:38:56 +0800 Subject: [PATCH 4/5] [Fraud Protection] Add logs tab column customization and wire real data Add a customizable columns dropdown with persisted visibility, make the filter bar responsive, fetch logs and log details from the Admin API, and remove the experimental Logs B tab variant. Co-authored-by: Cursor --- .../FraudProtectionLogsTab.module.css | 144 +++- .../FraudProtectionLogsTab.tsx | 658 +++++++++++------- portal/src/locale-data/en.json | 6 + .../FraudProtectionConfigurationScreen.tsx | 10 +- .../FraudProtectionLogEntryScreen.tsx | 49 +- 5 files changed, 565 insertions(+), 302 deletions(-) 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..e766512ac9 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,8 @@ 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 } from "../../util/formatDatetime"; import styles from "./FraudProtectionLogsTab.module.css"; const PAGE_SIZE = 20; @@ -106,32 +109,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 +152,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 +198,260 @@ 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:"; + +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) + ); + 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 +465,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 +505,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 +528,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 +720,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 +733,11 @@ 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 "action": return ( @@ -644,6 +756,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 +771,7 @@ const FraudProtectionLogsTab: React.VFC = } } }, - [onToggleRow] + [] ); const onRenderRow = useCallback( @@ -663,9 +781,6 @@ const FraudProtectionLogsTab: React.VFC = if (item == null) { return ; } - if (item.kind === "details") { - return ; - } return (
= } }} > - +
); }, @@ -693,22 +805,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 +837,12 @@ const FraudProtectionLogsTab: React.VFC =
@@ -741,6 +858,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/locale-data/en.json b/portal/src/locale-data/en.json index 4d03420923..ccceeeedd9 100644 --- a/portal/src/locale-data/en.json +++ b/portal/src/locale-data/en.json @@ -1846,6 +1846,12 @@ "FraudProtectionConfigurationScreen.logs.column.result": "Result", "FraudProtectionConfigurationScreen.logs.column.reasonCodes": "Reason codes", "FraudProtectionConfigurationScreen.logs.column.ip": "IP", + "FraudProtectionConfigurationScreen.logs.column.ipCountry": "IP country", + "FraudProtectionConfigurationScreen.logs.column.phoneCountry": "Phone country", + "FraudProtectionConfigurationScreen.logs.column.phone": "Phone", + "FraudProtectionConfigurationScreen.logs.columns.button": "Columns", + "FraudProtectionConfigurationScreen.logs.columns.alwaysShown": "ALWAYS SHOWN", + "FraudProtectionConfigurationScreen.logs.columns.optional": "OPTIONAL", "FraudProtectionConfigurationScreen.logs.details.ipDetails": "IP DETAILS", "FraudProtectionConfigurationScreen.logs.details.ip": "IP:", "FraudProtectionConfigurationScreen.logs.details.geoLocation": "Geo Location", diff --git a/portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsx b/portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsx index dcf65c1b7f..638e98aabc 100644 --- a/portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsx +++ b/portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsx @@ -362,7 +362,9 @@ const FraudProtectionConfigurationContent: React.VFC 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} From e3323fe15ccaac5a52f5ca9ed7c00342a1737812 Mon Sep 17 00:00:00 2001 From: jimmycwc Date: Fri, 26 Jun 2026 12:38:28 +0800 Subject: [PATCH 5/5] [Fraud Protection] Fix logs timestamp formatting and default columns Format the logs timestamp column with formatDatetime instead of showing the raw ISO string, and restore the default-visible optional columns (reason codes, IP country) when stored preferences are empty. Co-authored-by: Cursor --- .../fraud-protection/FraudProtectionLogsTab.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx b/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx index e766512ac9..1df715c855 100644 --- a/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx +++ b/portal/src/components/fraud-protection/FraudProtectionLogsTab.tsx @@ -48,7 +48,10 @@ import { } from "../../graphql/adminapi/globalTypes.generated"; import { encodeOffsetToCursor } from "../../util/pagination"; import { useDebounced } from "../../hook/useDebounced"; -import { formatCustomDateRangeLabel } from "../../util/formatDatetime"; +import { + formatCustomDateRangeLabel, + formatDatetime, +} from "../../util/formatDatetime"; import styles from "./FraudProtectionLogsTab.module.css"; const PAGE_SIZE = 20; @@ -279,7 +282,7 @@ const COLUMN_DEFS: ColumnDef[] = [ ]; const FRAUD_PROTECTION_LOGS_COLUMNS_STORAGE_KEY_PREFIX = - "fraud-protection-logs-visible-columns:"; + "fraud-protection-logs-visible-columns-v2:"; const OPTIONAL_COLUMN_KEYS = new Set( COLUMN_DEFS.filter((c) => !c.alwaysShown).map((c) => c.key) @@ -309,6 +312,9 @@ function loadVisibleOptionalColumns(appID: string): Set { (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(); @@ -738,6 +744,8 @@ const FraudProtectionLogsTab: React.VFC = switch (column?.key) { case "columnSettings": return null; + case "timestamp": + return {formatDatetime(locale, entry.createdAt) ?? "—"}; case "action": return ( @@ -771,7 +779,7 @@ const FraudProtectionLogsTab: React.VFC = } } }, - [] + [locale] ); const onRenderRow = useCallback(