diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts index def50ed596..05ab6a0804 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.style.ts @@ -24,13 +24,14 @@ const buildPickerThemeColors = ( isDark: boolean, textColor?: string, backgroundColor?: string, + inputBackgroundColor?: string, ): PickerThemeColors => { const inputText = textColor || themeConfig.fontColor const borderColor = isDark ? 'transparent' : themeConfig.borderColor const hoverBg = getLoadingOverlayRgba(themeConfig.fontColor, getHoverOpacity(isDark)) return { labelBackground: backgroundColor || themeConfig.background, - inputBackground: themeConfig.inputBackground, + inputBackground: inputBackgroundColor || themeConfig.inputBackground, inputTextColor: inputText, labelColor: inputText, borderColor, @@ -113,6 +114,7 @@ const buildTextFieldSx = ( '& .MuiInputBase-root, & .MuiPickersInputBase-root': { color: tc.inputTextColor, backgroundColor: tc.inputBackground, + borderRadius: `${BORDER_RADIUS.SMALL}px`, ...(inputHeight != null ? { height: inputHeight, minHeight: inputHeight } : {}), // With the calendar icon hidden on mobile, center the date in the freed space. ...(forceIcon ? {} : { [HIDE_ICON_QUERY]: { justifyContent: 'center' } }), @@ -132,7 +134,10 @@ const buildTextFieldSx = ( color: tc.inputTextColor, }, '& .MuiOutlinedInput-root, & .MuiPickersOutlinedInput-root': { - '& fieldset, & .MuiPickersOutlinedInput-notchedOutline': { borderColor: tc.borderColor }, + '& fieldset, & .MuiPickersOutlinedInput-notchedOutline': { + borderColor: tc.borderColor, + borderRadius: `${BORDER_RADIUS.SMALL}px`, + }, '&:hover fieldset, &:hover .MuiPickersOutlinedInput-notchedOutline': { borderColor: tc.borderColor, }, @@ -462,13 +467,15 @@ export const useDatePickerStyles = (params: GluuDatePickerStyleParams) => { isDark, textColor, backgroundColor, + inputBackgroundColor, inputHeight, labelShrink = true, forceIcon = false, } = params const pickerTheme = useMemo( - () => buildPickerThemeColors(themeColors, isDark, textColor, backgroundColor), - [themeColors, isDark, textColor, backgroundColor], + () => + buildPickerThemeColors(themeColors, isDark, textColor, backgroundColor, inputBackgroundColor), + [themeColors, isDark, textColor, backgroundColor, inputBackgroundColor], ) const { classes } = useLayoutStyles({ labelColor: pickerTheme.labelColor }) diff --git a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx index 6ef08697cf..fa908c62e0 100644 --- a/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx +++ b/admin-ui/app/components/GluuDatePicker/GluuDatePicker.tsx @@ -40,6 +40,7 @@ const rangePropsEqual = (a: GluuDatePickerRangeProps, b: GluuDatePickerRangeProp a.inputHeight === b.inputHeight && a.textColor === b.textColor && a.backgroundColor === b.backgroundColor && + a.inputBackgroundColor === b.inputBackgroundColor && (a.dateFormat ?? a.format) === (b.dateFormat ?? b.format) && a.onStartDateChange === b.onStartDateChange && a.onEndDateChange === b.onEndDateChange && @@ -68,6 +69,7 @@ const GluuDatePicker = memo( isDark: isDarkTheme, textColor: props.textColor, backgroundColor: props.backgroundColor, + inputBackgroundColor: props.inputBackgroundColor, inputHeight: props.inputHeight, labelShrink, forceIcon: props.forceIcon, @@ -135,6 +137,7 @@ const GluuDatePicker = memo( prev.inputHeight === next.inputHeight && prev.textColor === next.textColor && prev.backgroundColor === next.backgroundColor && + prev.inputBackgroundColor === next.inputBackgroundColor && prev.minDate === next.minDate && prev.maxDate === next.maxDate && prev.disabled === next.disabled diff --git a/admin-ui/app/components/GluuDatePicker/types.ts b/admin-ui/app/components/GluuDatePicker/types.ts index 41c467867e..2d8d741e46 100644 --- a/admin-ui/app/components/GluuDatePicker/types.ts +++ b/admin-ui/app/components/GluuDatePicker/types.ts @@ -24,6 +24,7 @@ export type GluuDatePickerStyleParams = { isDark: boolean textColor?: string backgroundColor?: string + inputBackgroundColor?: string inputHeight?: number labelShrink?: boolean forceIcon?: boolean @@ -34,6 +35,7 @@ type GluuDatePickerBase = { dateFormat?: string textColor?: string backgroundColor?: string + inputBackgroundColor?: string inputHeight?: number showTime?: boolean forceIcon?: boolean diff --git a/admin-ui/app/constants/ui.ts b/admin-ui/app/constants/ui.ts index 4ba9cbc323..ad0522e65f 100644 --- a/admin-ui/app/constants/ui.ts +++ b/admin-ui/app/constants/ui.ts @@ -310,7 +310,7 @@ export const SUMMARY_CARD = { } as const export const getSegmentedButtonStyle = (isFirst: boolean, isLast: boolean) => { - const radius = BORDER_RADIUS.SMALL_MEDIUM + const radius = BORDER_RADIUS.SMALL return { borderRadius: isFirst ? `${radius}px 0 0 ${radius}px` diff --git a/admin-ui/app/helpers/navigation.ts b/admin-ui/app/helpers/navigation.ts index 49303fe1d6..9aee4c4a1b 100644 --- a/admin-ui/app/helpers/navigation.ts +++ b/admin-ui/app/helpers/navigation.ts @@ -138,6 +138,7 @@ const ROUTES = { FIDO_BASE: `${PLUGIN_BASE_PATHS.FIDO}/configuration`, FIDO_METRICS: `${PLUGIN_BASE_PATHS.FIDO}/metrics`, FIDO_SECURITY_MONITOR: `${PLUGIN_BASE_PATHS.FIDO}/security-monitor`, + FIDO_AUTH_METRICS: `${PLUGIN_BASE_PATHS.FIDO}/auth-metrics`, // ========== SMTP Plugin ========== SMTP_BASE: `${PLUGIN_BASE_PATHS.SMTP}/smtpmanagement`, diff --git a/admin-ui/app/locales/en/translation.json b/admin-ui/app/locales/en/translation.json index dc9566c244..ff97a4a5fd 100644 --- a/admin-ui/app/locales/en/translation.json +++ b/admin-ui/app/locales/en/translation.json @@ -832,7 +832,36 @@ "metric": "Metric", "label": "Label", "secondary_value": "Secondary value", - "suspicious_ips": "Suspicious IP addresses" + "suspicious_ips": "Suspicious IP addresses", + "auth_metrics_unavailable": "Metrics are unavailable. Check that the metric plugin is deployed and your token carries the metric.readonly scope.", + "auth_metrics_partial": "Some metric types could not be loaded. The figures below are incomplete.", + "auth_activity_subtitle": "Successful and failed authentications over time.", + "auth_by_acr_subtitle": "Successful authentications split by authentication context.", + "token_issuance_subtitle": "Tokens and authorization codes issued over time.", + "auth_success": "Successful", + "auth_failure": "Failed", + "auth_attempts": "Attempts", + "acr_in_use": "ACRs in use", + "access_tokens": "Access tokens", + "id_tokens": "ID tokens", + "refresh_tokens": "Refresh tokens", + "authorization_codes": "Authorization codes", + "select_granularity": "Select granularity", + "date_preset_24h": "24 Hours", + "date_preset_7d": "7 Days", + "date_preset_30d": "30 Days", + "granularity": "Granularity", + "granularity_hourly": "Hourly", + "granularity_hours_3": "3 Hours", + "granularity_hours_12": "12 Hours", + "granularity_hours_24": "24 Hours", + "granularity_daily": "Daily", + "granularity_days_3": "3 Days", + "granularity_days_7": "7 Days", + "granularity_days_15": "15 Days", + "granularity_days_21": "21 Days", + "granularity_days_30": "30 Days", + "auth_metrics_truncated": "Showing partial data: this range holds more entries than one page walk can read. Narrow the range for exact totals." }, "languages": { "language": "Language", @@ -941,7 +970,8 @@ "allCategory": "All Category", "notification": "Notification", "mobileNavigation": "Mobile navigation", - "security_monitor": "Security Monitor" + "security_monitor": "Security Monitor", + "auth_metrics": "Auth Metrics" }, "footer": { "company_name": "Gluu Inc." @@ -1696,7 +1726,12 @@ "error_intelligence": "Error Types", "velocity_watch": "Attempts by User", "device_fingerprint_shift": "Authenticator Types", - "threat_origins_ips": "Threat Origins by IP" + "threat_origins_ips": "Threat Origins by IP", + "auth_metrics": "Auth Metrics", + "auth_activity": "Authentication Activity", + "auth_by_acr": "Authentications by ACR", + "token_issuance": "Token Issuance", + "auth_token_activity": "Authentication & Token Activity" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/locales/es/translation.json b/admin-ui/app/locales/es/translation.json index 9f03d51e26..231d8a022c 100644 --- a/admin-ui/app/locales/es/translation.json +++ b/admin-ui/app/locales/es/translation.json @@ -832,7 +832,36 @@ "metric": "Métrica", "label": "Etiqueta", "secondary_value": "Valor secundario", - "suspicious_ips": "Direcciones IP sospechosas" + "suspicious_ips": "Direcciones IP sospechosas", + "auth_metrics_unavailable": "Las métricas no están disponibles. Compruebe que el plugin de métricas esté desplegado y que su token tenga el permiso metric.readonly.", + "auth_metrics_partial": "No se pudieron cargar algunos tipos de métrica. Las cifras siguientes están incompletas.", + "auth_activity_subtitle": "Autenticaciones correctas y fallidas a lo largo del tiempo.", + "auth_by_acr_subtitle": "Autenticaciones correctas divididas por contexto de autenticación.", + "token_issuance_subtitle": "Tokens y códigos de autorización emitidos a lo largo del tiempo.", + "auth_success": "Correctas", + "auth_failure": "Fallidas", + "auth_attempts": "Intentos", + "acr_in_use": "ACR en uso", + "access_tokens": "Tokens de acceso", + "id_tokens": "Tokens de ID", + "refresh_tokens": "Tokens de actualización", + "authorization_codes": "Códigos de autorización", + "select_granularity": "Seleccionar granularidad", + "date_preset_24h": "24 horas", + "date_preset_7d": "7 días", + "date_preset_30d": "30 días", + "granularity": "Granularidad", + "granularity_hourly": "Cada hora", + "granularity_hours_3": "3 horas", + "granularity_hours_12": "12 horas", + "granularity_hours_24": "24 horas", + "granularity_daily": "Diario", + "granularity_days_3": "3 días", + "granularity_days_7": "7 días", + "granularity_days_15": "15 días", + "granularity_days_21": "21 días", + "granularity_days_30": "30 días", + "auth_metrics_truncated": "Mostrando datos parciales: este intervalo contiene más entradas de las que se pueden leer. Reduzca el intervalo para obtener totales exactos." }, "languages": { "language": "Idioma", @@ -941,7 +970,8 @@ "allCategory": "Todas las categorías", "notification": "Notificación", "mobileNavigation": "Navegación móvil", - "security_monitor": "Monitor de Seguridad" + "security_monitor": "Monitor de Seguridad", + "auth_metrics": "Métricas de autenticación" }, "footer": { "company_name": "Gluu Inc." @@ -1699,7 +1729,12 @@ "error_intelligence": "Tipos de error", "velocity_watch": "Intentos por usuario", "device_fingerprint_shift": "Tipos de autenticador", - "threat_origins_ips": "Orígenes de amenazas por IP" + "threat_origins_ips": "Orígenes de amenazas por IP", + "auth_metrics": "Métricas de autenticación", + "auth_activity": "Actividad de autenticación", + "auth_by_acr": "Autenticaciones por ACR", + "token_issuance": "Emisión de tokens", + "auth_token_activity": "Actividad de autenticación y tokens" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/locales/fr/translation.json b/admin-ui/app/locales/fr/translation.json index 439970ff9c..2c5cb2059f 100644 --- a/admin-ui/app/locales/fr/translation.json +++ b/admin-ui/app/locales/fr/translation.json @@ -162,7 +162,8 @@ "allCategory": "Toutes les catégories", "notification": "Notification", "mobileNavigation": "Navigation mobile", - "security_monitor": "Moniteur de Sécurité" + "security_monitor": "Moniteur de Sécurité", + "auth_metrics": "Métriques d'authentification" }, "actions": { "accept": "J'accepte", @@ -945,7 +946,36 @@ "metric": "Métrique", "label": "Libellé", "secondary_value": "Valeur secondaire", - "suspicious_ips": "Adresses IP suspectes" + "suspicious_ips": "Adresses IP suspectes", + "auth_metrics_unavailable": "Les métriques sont indisponibles. Vérifiez que le plugin de métriques est déployé et que votre jeton possède la portée metric.readonly.", + "auth_metrics_partial": "Certains types de métrique n'ont pas pu être chargés. Les chiffres ci-dessous sont incomplets.", + "auth_activity_subtitle": "Authentifications réussies et échouées au fil du temps.", + "auth_by_acr_subtitle": "Authentifications réussies réparties par contexte d'authentification.", + "token_issuance_subtitle": "Jetons et codes d'autorisation émis au fil du temps.", + "auth_success": "Réussies", + "auth_failure": "Échouées", + "auth_attempts": "Tentatives", + "acr_in_use": "ACR utilisés", + "access_tokens": "Jetons d'accès", + "id_tokens": "Jetons ID", + "refresh_tokens": "Jetons de rafraîchissement", + "authorization_codes": "Codes d'autorisation", + "select_granularity": "Sélectionner la granularité", + "date_preset_24h": "24 heures", + "date_preset_7d": "7 jours", + "date_preset_30d": "30 jours", + "granularity": "Granularité", + "granularity_hourly": "Horaire", + "granularity_hours_3": "3 heures", + "granularity_hours_12": "12 heures", + "granularity_hours_24": "24 heures", + "granularity_daily": "Quotidien", + "granularity_days_3": "3 jours", + "granularity_days_7": "7 jours", + "granularity_days_15": "15 jours", + "granularity_days_21": "21 jours", + "granularity_days_30": "30 jours", + "auth_metrics_truncated": "Données partielles affichées : cette plage contient plus d'entrées qu'il n'est possible de lire. Réduisez la plage pour obtenir des totaux exacts." }, "footer": { "company_name": "Gluu Inc." @@ -1704,7 +1734,12 @@ "error_intelligence": "Types d'erreur", "velocity_watch": "Tentatives par utilisateur", "device_fingerprint_shift": "Types d'authentificateur", - "threat_origins_ips": "Origines des menaces par IP" + "threat_origins_ips": "Origines des menaces par IP", + "auth_metrics": "Métriques d'authentification", + "auth_activity": "Activité d'authentification", + "auth_by_acr": "Authentifications par ACR", + "token_issuance": "Émission de jetons", + "auth_token_activity": "Activité d'authentification et de jetons" }, "options": { "admin": "ADMINISTRER", diff --git a/admin-ui/app/locales/pt/translation.json b/admin-ui/app/locales/pt/translation.json index 5ed35d8ed6..0c45bc2c23 100644 --- a/admin-ui/app/locales/pt/translation.json +++ b/admin-ui/app/locales/pt/translation.json @@ -161,7 +161,8 @@ "allCategory": "Todas as categorias", "notification": "Notificação", "mobileNavigation": "Navegação móvel", - "security_monitor": "Monitor de Segurança" + "security_monitor": "Monitor de Segurança", + "auth_metrics": "Métricas de autenticação" }, "actions": { "accept": "Aceitar", @@ -941,7 +942,36 @@ "metric": "Métrica", "label": "Rótulo", "secondary_value": "Valor secundário", - "suspicious_ips": "Endereços IP suspeitos" + "suspicious_ips": "Endereços IP suspeitos", + "auth_metrics_unavailable": "As métricas não estão disponíveis. Verifique se o plugin de métricas está implementado e se o seu token tem o âmbito metric.readonly.", + "auth_metrics_partial": "Não foi possível carregar alguns tipos de métrica. Os números abaixo estão incompletos.", + "auth_activity_subtitle": "Autenticações bem-sucedidas e falhadas ao longo do tempo.", + "auth_by_acr_subtitle": "Autenticações bem-sucedidas divididas por contexto de autenticação.", + "token_issuance_subtitle": "Tokens e códigos de autorização emitidos ao longo do tempo.", + "auth_success": "Bem-sucedidas", + "auth_failure": "Falhadas", + "auth_attempts": "Tentativas", + "acr_in_use": "ACR em uso", + "access_tokens": "Tokens de acesso", + "id_tokens": "Tokens de ID", + "refresh_tokens": "Tokens de atualização", + "authorization_codes": "Códigos de autorização", + "select_granularity": "Selecionar granularidade", + "date_preset_24h": "24 horas", + "date_preset_7d": "7 dias", + "date_preset_30d": "30 dias", + "granularity": "Granularidade", + "granularity_hourly": "Por hora", + "granularity_hours_3": "3 horas", + "granularity_hours_12": "12 horas", + "granularity_hours_24": "24 horas", + "granularity_daily": "Diário", + "granularity_days_3": "3 dias", + "granularity_days_7": "7 dias", + "granularity_days_15": "15 dias", + "granularity_days_21": "21 dias", + "granularity_days_30": "30 dias", + "auth_metrics_truncated": "A mostrar dados parciais: este intervalo contém mais entradas do que é possível ler. Reduza o intervalo para obter totais exatos." }, "footer": { "company_name": "Gluu Inc." @@ -1700,7 +1730,12 @@ "error_intelligence": "Tipos de erro", "velocity_watch": "Tentativas por usuário", "device_fingerprint_shift": "Tipos de autenticador", - "threat_origins_ips": "Origens de ameaças por IP" + "threat_origins_ips": "Origens de ameaças por IP", + "auth_metrics": "Métricas de autenticação", + "auth_activity": "Atividade de autenticação", + "auth_by_acr": "Autenticações por ACR", + "token_issuance": "Emissão de tokens", + "auth_token_activity": "Atividade de autenticação e tokens" }, "options": { "admin": "ADMIN", diff --git a/admin-ui/app/utils/dayjsUtils.ts b/admin-ui/app/utils/dayjsUtils.ts index c8a839c775..f70259a0cf 100644 --- a/admin-ui/app/utils/dayjsUtils.ts +++ b/admin-ui/app/utils/dayjsUtils.ts @@ -1,10 +1,12 @@ import dayjs from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' import customParseFormat from 'dayjs/plugin/customParseFormat' +import utc from 'dayjs/plugin/utc' import type { Dayjs, OpUnitType, ManipulateType } from 'dayjs' dayjs.extend(isSameOrBefore) dayjs.extend(customParseFormat) +dayjs.extend(utc) export type { Dayjs } from 'dayjs' @@ -73,6 +75,13 @@ export const createDate = ( return dayjs(date) } +export const createUtcDate = (date?: string | number | Date | Dayjs | null): Dayjs => { + if (date == null) { + return dayjs.utc() + } + return dayjs.utc(date) +} + export const parseDateStrict = (date: string, format: string): Dayjs | null => { const parsed = dayjs(date, format, true) return parsed.isValid() ? parsed : null diff --git a/admin-ui/openapi-merge.json b/admin-ui/openapi-merge.json index d173ed096d..d05ca8d3c1 100644 --- a/admin-ui/openapi-merge.json +++ b/admin-ui/openapi-merge.json @@ -26,6 +26,9 @@ }, { "inputURL": "https://raw.githubusercontent.com/JanssenProject/jans/main/jans-config-api/plugins/docs/lock-plugin-swagger.yaml" + }, + { + "inputURL": "https://raw.githubusercontent.com/JanssenProject/jans/main/jans-config-api/plugins/docs/metric-plugin-swagger.yaml" } ], "output": "./configApiSpecs.yaml" diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts index b3ffa69f26..af4c74cab9 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.style.ts @@ -1,23 +1,28 @@ import { makeStyles } from 'tss-react/mui' import { fontFamily, fontWeights, fontSizes, lineHeights, letterSpacing } from '@/styles/fonts' import { BORDER_RADIUS, getSegmentedButtonStyle, SEGMENTED_CONTROL, SPACING } from '@/constants' +import { SHARED_DROPDOWN_STYLES } from '@/components/GluuDropdown/sharedDropdownStyles' const PRESET_BUTTON_MIN_WIDTH = SEGMENTED_CONTROL.BUTTON_MIN_WIDTH const VIEW_BUTTON_MIN_WIDTH = 96 +const PRESET_MENU_Z_INDEX = SHARED_DROPDOWN_STYLES.menuZIndex +const PRESET_MENU_GAP = SHARED_DROPDOWN_STYLES.margin + export const VIEW_BUTTON_STYLE = { minWidth: VIEW_BUTTON_MIN_WIDTH, - borderRadius: BORDER_RADIUS.SMALL_MEDIUM, + borderRadius: BORDER_RADIUS.SMALL, fontFamily, fontStyle: 'normal' as const, lineHeight: lineHeights.normal, letterSpacing: letterSpacing.button, } -export const getPresetButtonStyle = (isFirst: boolean, isLast: boolean) => ({ - minWidth: PRESET_BUTTON_MIN_WIDTH, - ...getSegmentedButtonStyle(isFirst, isLast), -}) +export const getPresetButtonStyle = (isFirst: boolean, isLast: boolean) => { + const segmented = getSegmentedButtonStyle(isFirst, isLast) + + return { minWidth: PRESET_BUTTON_MIN_WIDTH, ...segmented, marginLeft: 0 } +} const useStyles = makeStyles()((theme) => ({ container: { @@ -58,6 +63,13 @@ const useStyles = makeStyles()((theme) => ({ justifyContent: 'flex-end', }, }, + secondaryRow: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap' as const, + gap: SPACING.CARD_BUTTON_GAP, + marginTop: SPACING.CARD_BUTTON_GAP, + }, presetColWrap: { width: '100%', [theme.breakpoints.up('md')]: { @@ -68,18 +80,35 @@ const useStyles = makeStyles()((theme) => ({ 'display': 'flex', 'gap': 0, 'width': '100%', - '& > button': { + '& > *': { flex: 1, minWidth: 0, }, + '& > * > button': { + width: '100%', + }, + '& > * + *': { + marginLeft: SEGMENTED_CONTROL.BORDER_OVERLAP, + }, [theme.breakpoints.up('md')]: { 'width': 'auto', - '& > button': { + '& > *': { flex: 'none', minWidth: PRESET_BUTTON_MIN_WIDTH, }, }, }, + presetSlot: { + position: 'relative', + }, + presetMenu: { + position: 'absolute', + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + zIndex: PRESET_MENU_Z_INDEX, + marginTop: PRESET_MENU_GAP, + }, datePickerCol: { flex: 1, minWidth: 0, diff --git a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx index 8526fd6ea3..7e82126ea9 100644 --- a/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx +++ b/admin-ui/plugins/admin/components/MAU/components/DateRangeSelector.tsx @@ -23,6 +23,11 @@ const DateRangeSelector: React.FC = ({ onPresetSelect, onApply, isLoading, + headingKey = 'titles.usage_token_analytics', + presets = DATE_PRESETS, + applyLabelKey = 'actions.view', + presetMenu, + presetMenuAnchor, }) => { const { t } = useTranslation() const { state } = useTheme() @@ -30,41 +35,44 @@ const DateRangeSelector: React.FC = ({ const themeColors = getThemeColor(selectedTheme) const { classes } = useStyles() + const unselectedBg = themeColors.dashboard.supportCard ?? themeColors.menu.background const presetButtonBg = (isSelected: boolean) => - isSelected - ? themeColors.inputBackground - : (themeColors.dashboard.supportCard ?? themeColors.menu.background) + isSelected ? themeColors.inputBackground : unselectedBg const presetButtonBorder = themeColors.borderColor return ( - {t('titles.usage_token_analytics')} + {t(headingKey)} - {DATE_PRESETS.map((preset, index) => { - const isSelected = selectedPreset === preset.months + {presets.map((preset, index) => { + const isSelected = selectedPreset === preset.value const isFirst = index === 0 - const isLast = index === DATE_PRESETS.length - 1 + const isLast = index === presets.length - 1 return ( - onPresetSelect(preset.months)} - theme={selectedTheme} - outlined={!isSelected} - textColor={themeColors.fontColor} - backgroundColor={presetButtonBg(isSelected)} - borderColor={presetButtonBorder} - disableHoverStyles - style={getPresetButtonStyle(isFirst, isLast)} - > - {t(preset.labelKey)} - + + onPresetSelect(preset.value)} + theme={selectedTheme} + outlined={!isSelected} + textColor={themeColors.fontColor} + backgroundColor={presetButtonBg(isSelected)} + borderColor={presetButtonBorder} + disableHoverStyles + style={getPresetButtonStyle(isFirst, isLast)} + > + {t(preset.labelKey)} + + {presetMenu && presetMenuAnchor === preset.value ? ( + {presetMenu} + ) : null} + ) })} @@ -78,6 +86,8 @@ const DateRangeSelector: React.FC = ({ endDate={endDate} onStartDateChange={onStartDateChange} onEndDateChange={onEndDateChange} + inputBackgroundColor={unselectedBg} + backgroundColor={unselectedBg} /> @@ -91,7 +101,7 @@ const DateRangeSelector: React.FC = ({ fontWeight={fontWeights.bold} style={VIEW_BUTTON_STYLE} > - {t('actions.view')} + {t(applyLabelKey)} diff --git a/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx index 514022d81b..1788ae48be 100644 --- a/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx +++ b/admin-ui/plugins/admin/components/MAU/components/__tests__/DateRangeSelector.test.tsx @@ -33,7 +33,7 @@ describe('DateRangeSelector', () => { const onPresetSelect = jest.fn() renderSelector({ onPresetSelect }) fireEvent.click(screen.getByRole('button', { name: '6 Months' })) - expect(onPresetSelect).toHaveBeenCalledWith(DATE_PRESETS[1].months) + expect(onPresetSelect).toHaveBeenCalledWith(DATE_PRESETS[1].value) }) it('calls onApply when the view button is clicked', () => { diff --git a/admin-ui/plugins/admin/components/MAU/constants.ts b/admin-ui/plugins/admin/components/MAU/constants.ts index d03e068f52..d29fd3a142 100644 --- a/admin-ui/plugins/admin/components/MAU/constants.ts +++ b/admin-ui/plugins/admin/components/MAU/constants.ts @@ -14,9 +14,9 @@ export const CHART_MARGIN = { top: 10, right: 40, left: 0, bottom: 0 } as const export const MOBILE_CHART_MARGIN = { top: 10, right: 40, left: 0, bottom: 0 } as const export const DATE_PRESETS: DateRangePreset[] = [ - { labelKey: 'fields.date_preset_3m', months: 3 }, - { labelKey: 'fields.date_preset_6m', months: 6 }, - { labelKey: 'fields.date_preset_1y', months: 12 }, + { labelKey: 'fields.date_preset_3m', value: 3 }, + { labelKey: 'fields.date_preset_6m', value: 6 }, + { labelKey: 'fields.date_preset_1y', value: 12 }, ] as const const sharedMauColors = { diff --git a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts index 5ed39d473a..b646fbfafb 100644 --- a/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts +++ b/admin-ui/plugins/admin/components/MAU/types/MauTypes.ts @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import type { Dayjs } from 'dayjs' export type MauStatEntry = { @@ -38,7 +39,7 @@ export type MauSummary = { export type DateRangePreset = { labelKey: string - months: number + value: number } export type MauChartProps = { @@ -51,7 +52,12 @@ export type DateRangeSelectorProps = { selectedPreset: number | null onStartDateChange: (date: Dayjs | null) => void onEndDateChange: (date: Dayjs | null) => void - onPresetSelect: (months: number) => void + onPresetSelect: (value: number) => void onApply: () => void isLoading?: boolean + headingKey?: string + presets?: readonly DateRangePreset[] + applyLabelKey?: string + presetMenu?: ReactNode + presetMenuAnchor?: number | null } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts new file mode 100644 index 0000000000..0b3eef46c6 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.style.ts @@ -0,0 +1,97 @@ +import { makeStyles } from 'tss-react/mui' +import type { ThemeConfig } from '@/context/theme/config' +import { + BORDER_RADIUS, + MOBILE_MEDIA_QUERY, + SPACING, + SUMMARY_CARD, + TABLET_MAX_MEDIA_QUERY, + WIDE_MAX_MEDIA_QUERY, +} from '@/constants' +import { fontFamily, fontWeights, fontSizes, lineHeights } from '@/styles/fonts' +import { getCardBorderStyle } from '@/styles/cardBorderStyles' +import { SECURITY_CHART_HEIGHT } from '../SecurityMonitor/constants' + +interface StylesParams { + isDark: boolean + themeColors: ThemeConfig +} + +const KPI_COLUMNS = 5 + +export const useAuthMetricsStyles = makeStyles()(( + _theme, + { isDark, themeColors }, +) => { + const cardBorderStyle = getCardBorderStyle({ isDark, borderRadius: BORDER_RADIUS.DEFAULT }) + const cardBg = themeColors.settings?.cardBackground ?? themeColors.card?.background + + return { + notice: { + fontFamily, + fontSize: fontSizes.base, + color: themeColors.fontColor, + marginBottom: SPACING.CARD_GAP, + }, + filterRow: { + width: '100%', + marginBottom: SPACING.CARD_GAP, + }, + kpiGrid: { + display: 'grid', + gridTemplateColumns: `repeat(${KPI_COLUMNS}, minmax(0, 1fr))`, + gap: SPACING.CARD_BUTTON_GAP, + width: '100%', + marginBottom: SPACING.CARD_GAP, + [`@media ${WIDE_MAX_MEDIA_QUERY}`]: { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + [`@media ${MOBILE_MEDIA_QUERY}`]: { + gridTemplateColumns: 'minmax(0, 1fr)', + }, + }, + kpiCard: { + backgroundColor: cardBg, + ...cardBorderStyle, + borderRadius: BORDER_RADIUS.DEFAULT, + minHeight: SUMMARY_CARD.MIN_HEIGHT, + width: '100%', + padding: `${SUMMARY_CARD.PADDING_VERTICAL}px ${SUMMARY_CARD.PADDING_HORIZONTAL}px`, + boxSizing: 'border-box' as const, + display: 'flex', + flexDirection: 'column' as const, + justifyContent: 'center', + gap: SUMMARY_CARD.CONTENT_GAP, + }, + kpiLabel: { + fontFamily, + fontSize: fontSizes.xl, + fontWeight: fontWeights.medium, + lineHeight: lineHeights.tight, + color: themeColors.fontColor, + margin: 0, + [`@media ${TABLET_MAX_MEDIA_QUERY}`]: { + fontSize: fontSizes.md, + }, + }, + kpiValue: { + fontFamily, + fontSize: fontSizes['4xl'], + fontWeight: fontWeights.semiBold, + lineHeight: lineHeights.normal, + color: themeColors.fontColor, + margin: 0, + [`@media ${TABLET_MAX_MEDIA_QUERY}`]: { + fontSize: fontSizes['2xl'], + }, + }, + fullWidthRow: { + width: '100%', + marginBottom: SPACING.CARD_GAP, + }, + chartCanvas: { + width: '100%', + height: SECURITY_CHART_HEIGHT, + }, + } +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx new file mode 100644 index 0000000000..8be2d031c5 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/AuthMetricsPage.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { GluuPageContent } from 'Components' +import { useTranslation } from 'react-i18next' +import SetTitle from 'Utils/SetTitle' +import GluuLoader from 'Routes/Apps/Gluu/GluuLoader' +import GluuViewWrapper from 'Routes/Apps/Gluu/GluuViewWrapper' +import GluuText from 'Routes/Apps/Gluu/GluuText' +import { usePermission } from '@/cedarling/hooks/usePermission' +import { ADMIN_UI_RESOURCES } from '@/cedarling/utility' +import dayjs, { type Dayjs } from 'dayjs' +import DateRangeSelector from 'Plugins/admin/components/MAU/components/DateRangeSelector' +import { useSecurityTheme } from '../SecurityMonitor/hooks' +import { useAuthMetricsCharts } from './hooks' +import { endOfDay, granularitiesForRange, resolveGranularity, startOfDay } from './utils' +import { + AcrBreakdownChart, + AuthActivityChart, + AuthMetricsKpiStrip, + GranularityMenu, + TokenIssuanceChart, +} from './components' +import { DATE_PRESETS, DEFAULT_GRANULARITY, DEFAULT_SELECTED_RANGE_DAYS } from './constants' +import { useAuthMetricsStyles } from './AuthMetricsPage.style' +import type { Granularity, MetricRange } from './types' + +const AUTH_METRICS_RESOURCE_ID = ADMIN_UI_RESOURCES.FIDO + +const startOfWindow = (days: number) => startOfDay(dayjs().subtract(days - 1, 'day')) + +const endOfToday = () => endOfDay(dayjs()) + +const AuthMetricsPage: React.FC = () => { + const { t } = useTranslation() + SetTitle(t('titles.auth_metrics')) + + const { themeColors, isDark } = useSecurityTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const { canRead: canView } = usePermission(AUTH_METRICS_RESOURCE_ID) + + const [startDate, setStartDate] = useState(() => + startOfWindow(DEFAULT_SELECTED_RANGE_DAYS), + ) + const [endDate, setEndDate] = useState(endOfToday) + const [selectedPreset, setSelectedPreset] = useState(DEFAULT_SELECTED_RANGE_DAYS) + const [granularity, setGranularity] = useState(DEFAULT_GRANULARITY) + const [isGranularityMenuOpen, setIsGranularityMenuOpen] = useState(false) + + const [appliedRange, setAppliedRange] = useState(() => ({ + startDate: startOfWindow(DEFAULT_SELECTED_RANGE_DAYS).toDate(), + endDate: endOfToday().toDate(), + })) + + const handleStartDateChange = useCallback((date: Dayjs | null) => { + if (!date) return + setStartDate(startOfDay(date)) + setSelectedPreset(null) + }, []) + + const handleEndDateChange = useCallback((date: Dayjs | null) => { + if (!date) return + setEndDate(endOfDay(date)) + setSelectedPreset(null) + }, []) + + const handlePresetSelect = useCallback((days: number) => { + setSelectedPreset(days) + setStartDate(startOfWindow(days)) + setEndDate(endOfToday()) + setIsGranularityMenuOpen(true) + }, []) + + const handleApply = useCallback(() => { + setAppliedRange({ startDate: startDate.toDate(), endDate: endDate.toDate() }) + }, [startDate, endDate]) + + const allowedGranularities = useMemo( + () => granularitiesForRange(startDate.toDate(), endDate.toDate()), + [startDate, endDate], + ) + + const effectiveGranularity = resolveGranularity(granularity, allowedGranularities) + + const { + authRows, + acrRows, + acrSeries, + tokenRows, + totals, + isBusy, + isError, + isPartial, + isTruncated, + } = useAuthMetricsCharts({ range: appliedRange, granularity: effectiveGranularity }) + + const granularityLabelOf = useCallback( + (value: Granularity) => t(`fields.granularity_${value.toLowerCase()}`), + [t], + ) + + const granularityAria = t('fields.select_granularity') + + const granularityOptions = useMemo( + () => allowedGranularities.map((value) => ({ value, label: granularityLabelOf(value) })), + [allowedGranularities, granularityLabelOf], + ) + + const closeGranularityMenu = useCallback(() => setIsGranularityMenuOpen(false), []) + + return ( + + + +
+ + } + /> +
+ + {isError ? ( + + {t('fields.auth_metrics_unavailable')} + + ) : null} + + {!isError && isPartial ? ( + + {t('fields.auth_metrics_partial')} + + ) : null} + + {!isError && isTruncated ? ( + + {t('fields.auth_metrics_truncated')} + + ) : null} + + {isError ? null : ( + <> + + +
+ +
+
+ +
+
+ +
+ + )} +
+
+
+ ) +} + +export default AuthMetricsPage diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx new file mode 100644 index 0000000000..efe65f4267 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/AuthMetricsKpiStrip.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import AppTestWrapper from 'Routes/Apps/Gluu/Tests/Components/AppTestWrapper' +import AuthMetricsKpiStrip from 'Plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip' + +type Totals = React.ComponentProps['totals'] + +const totals: Totals = { + attempts: 1234, + success: 1200, + failure: 34, + successRate: 97.24, + acrCount: 2, +} + +const renderStrip = (props: Totals) => + render( + + + , + ) + +describe('AuthMetricsKpiStrip', () => { + it('renders each total with thousands separators', () => { + renderStrip(totals) + + expect(screen.getByText('1,234')).toBeInTheDocument() + expect(screen.getByText('1,200')).toBeInTheDocument() + expect(screen.getByText('34')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('shows the success rate to one decimal place', () => { + renderStrip(totals) + + expect(screen.getByText('97.2%')).toBeInTheDocument() + }) + + it('shows a dash rather than 0% when no attempts were recorded', () => { + renderStrip({ attempts: 0, success: 0, failure: 0, successRate: null, acrCount: 0 }) + + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.queryByText('0.0%')).not.toBeInTheDocument() + }) +}) + +describe('AuthMetricsKpiStrip number locale', () => { + const formatIn = (language: string, value: number) => + new Intl.NumberFormat(language).format(value) + + it('groups digits differently in Spanish than in English', () => { + expect(formatIn('en', 12345)).toBe('12,345') + expect(formatIn('es', 12345)).toBe('12.345') + }) + + it('renders totals using the resolved app language, not the browser default', async () => { + const i18n = (await import('@/i18n')).default + await i18n.changeLanguage('es') + + try { + renderStrip({ ...totals, attempts: 12345 }) + + expect(screen.getByText(formatIn(i18n.resolvedLanguage as string, 12345))).toBeInTheDocument() + } finally { + await i18n.changeLanguage('en') + } + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts new file mode 100644 index 0000000000..a348cb0764 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/fetchAllMetricEntries.test.ts @@ -0,0 +1,115 @@ +import type { GetMetricEntriesParams, MetricDataEntry, MetricEntryPagedResult } from 'JansConfigApi' +import { fetchAllMetricEntries } from 'Plugins/fido/components/AuthMetrics/hooks/useMetricSeries' +import { MAX_ENTRY_PAGES, PAGE_SIZE } from 'Plugins/fido/components/AuthMetrics/constants' + +const BASE_PARAMS: GetMetricEntriesParams = { + metricType: 'user_authentication_success', + start_date: '2026-08-12T00:00:00.000Z', + end_date: '2026-08-19T00:00:00.000Z', +} + +const row = (index: number): MetricDataEntry => ({ + id: `row-${index}`, + startDate: '2026-08-19T06:00:00', + data: { count: 1 }, +}) + +const pagedFetcher = (total: number) => { + const calls: GetMetricEntriesParams[] = [] + + const fetcher = (params: GetMetricEntriesParams): Promise => { + calls.push(params) + const start = params.startIndex ?? 0 + const limit = params.limit ?? PAGE_SIZE.SERIES + const entries = Array.from( + { length: Math.max(0, Math.min(limit, total - start)) }, + (_, offset) => row(start + offset), + ) + return Promise.resolve({ + start, + totalEntriesCount: total, + entriesCount: entries.length, + entries, + }) + } + + return { fetcher, calls } +} + +describe('fetchAllMetricEntries', () => { + it('returns a single page whole when it already covers the total', async () => { + const { fetcher, calls } = pagedFetcher(12) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toHaveLength(12) + expect(result.isTruncated).toBe(false) + expect(calls).toHaveLength(1) + }) + + it('walks every page so a multi-page window is counted in full', async () => { + const total = PAGE_SIZE.SERIES * 3 + 17 + const { fetcher, calls } = pagedFetcher(total) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toHaveLength(total) + expect(result.totalCount).toBe(total) + expect(result.isTruncated).toBe(false) + expect(calls).toHaveLength(4) + expect(calls.map((call) => call.startIndex)).toEqual([ + 0, + PAGE_SIZE.SERIES, + PAGE_SIZE.SERIES * 2, + PAGE_SIZE.SERIES * 3, + ]) + }) + + it('pages in ascending start-date order', async () => { + const { fetcher, calls } = pagedFetcher(5) + + await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls[0]).toMatchObject({ sortBy: 'jansStartDate', sortOrder: 'ascending' }) + }) + + it('reports truncation instead of silently returning a short series', async () => { + const total = PAGE_SIZE.SERIES * (MAX_ENTRY_PAGES + 5) + const { fetcher, calls } = pagedFetcher(total) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls).toHaveLength(MAX_ENTRY_PAGES) + expect(result.isTruncated).toBe(true) + expect(result.totalCount).toBe(total) + }) + + it('stops on an empty page even when the reported total is larger', async () => { + const calls: GetMetricEntriesParams[] = [] + const fetcher = (params: GetMetricEntriesParams): Promise => { + calls.push(params) + const entries = (params.startIndex ?? 0) === 0 ? [row(0)] : [] + return Promise.resolve({ + start: params.startIndex ?? 0, + totalEntriesCount: 9999, + entriesCount: entries.length, + entries, + }) + } + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(calls).toHaveLength(2) + expect(result.entries).toHaveLength(1) + expect(result.isTruncated).toBe(false) + }) + + it('treats a response with no entries array as the end of the walk', async () => { + const fetcher = () => Promise.resolve({} as MetricEntryPagedResult) + + const result = await fetchAllMetricEntries(BASE_PARAMS, { fetcher }) + + expect(result.entries).toEqual([]) + expect(result.isTruncated).toBe(false) + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts new file mode 100644 index 0000000000..dfc57cce1e --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/__tests__/utils.test.ts @@ -0,0 +1,373 @@ +import type { MetricAggregationEntry, MetricDataEntry } from 'JansConfigApi' +import { AXIS_KEYS } from 'Plugins/fido/components/AuthMetrics/constants' +import { createDate } from '@/utils/dayjsUtils' +import { + buildChartRows, + endOfDay, + formatDateForApi, + granularitiesForRange, + groupBySubType, + parseMetricData, + plainPoints, + resolveGranularity, + startOfDay, + subTypePoints, + sumCounts, + toMetricPoints, +} from 'Plugins/fido/components/AuthMetrics/utils' + +const ANCHOR = Date.UTC(2026, 7, 20, 0, 0, 0) + +describe('formatDateForApi', () => { + it('sends the selected wall-clock time, with no UTC conversion', () => { + const picked = new Date(2026, 7, 20, 0, 0, 0) + + expect(formatDateForApi(picked)).toBe('2026-08-20T00:00:00') + }) + + it('emits no Z suffix, which would make the server read the value as UTC', () => { + expect(formatDateForApi(new Date(2026, 7, 20, 23, 59, 0))).toBe('2026-08-20T23:59:00') + }) +}) + +describe('UTC handling of server timestamps', () => { + const shapes = [ + ['bare, no offset', '2026-08-20T06:50:00'], + ['Z suffix', '2026-08-20T06:50:00.000Z'], + ['explicit +00:00', '2026-08-20T06:50:00+00:00'], + ] as const + + it.each(shapes)('reads a %s timestamp as UTC', (_name, startDate) => { + const [point] = toMetricPoints([{ startDate, data: { count: 1 } } as MetricDataEntry], 'HH:mm') + + expect(point.label).toBe('06:50') + }) + + it('converts a non-UTC offset rather than dropping it, matching the server', () => { + const [point] = toMetricPoints( + [{ startDate: '2026-08-20T06:50:00+05:00', data: { count: 1 } } as MetricDataEntry], + 'HH:mm', + ) + + expect(point.label).toBe('01:50') + }) + + it('cuts daily buckets at UTC midnight, not the viewer local midnight', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: toMetricPoints( + [ + { startDate: '2026-08-20T00:30:00Z', data: { count: 1 } } as MetricDataEntry, + { startDate: '2026-08-20T23:30:00Z', data: { count: 2 } } as MetricDataEntry, + ], + 'HH:mm', + ), + }, + ], + 'DAILY', + ANCHOR, + ) + + expect(rows).toHaveLength(1) + expect(rows[0].success).toBe(3) + expect(rows[0][AXIS_KEYS.LABEL]).toBe('Aug-20') + }) +}) + +describe('preset window boundaries', () => { + const startOfWindow = (days: number) => startOfDay(createDate().subtract(days - 1, 'day')) + + it.each([ + ['24 Hours', 1], + ['7 Days', 7], + ['30 Days', 30], + ])('has the %s preset cover exactly that many calendar dates', (_name, days) => { + const start = startOfWindow(days) + const end = endOfDay(createDate()) + + expect(end.diff(start, 'day') + 1).toBe(days) + }) + + it.each([ + ['24 Hours', 1, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['7 Days', 7, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['30 Days', 30, ['DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_15', 'DAYS_21', 'DAYS_30']], + ])('keeps the %s preset in its own granularity tier', (_name, days, expected) => { + const granularities = granularitiesForRange( + startOfWindow(days).toDate(), + endOfDay(createDate()).toDate(), + ) + + expect(granularities).toEqual(expected) + }) +}) + +describe('granularitiesForRange', () => { + const spanOf = (days: number): [Date, Date] => [ + new Date(2026, 7, 20 - days, 0, 0, 0), + new Date(2026, 7, 20, 23, 59, 0), + ] + + it.each([ + ['a single day', 0, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['the 24 Hours preset', 1, ['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']], + ['the 7 Days preset', 7, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['a fortnight', 14, ['DAILY', 'DAYS_3', 'DAYS_7']], + ['the 30 Days preset', 30, ['DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_15', 'DAYS_21', 'DAYS_30']], + ])('offers %s the right tier', (_name, days, expected) => { + expect(granularitiesForRange(...spanOf(days))).toEqual(expected) + }) + + it('falls back to the finest tier when the range is reversed', () => { + const [start, end] = spanOf(7) + + expect(granularitiesForRange(end, start)).toEqual(['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24']) + }) +}) + +describe('multi-unit bucket widths', () => { + const hourlyRows = Array.from({ length: 48 }, (_, hour) => ({ + startDate: new Date(ANCHOR + hour * 3_600_000).toISOString(), + data: { count: 1 }, + })) as MetricDataEntry[] + + const rowsAt = (granularity: string) => + buildChartRows( + [{ key: 'success', points: toMetricPoints(hourlyRows, 'HH:mm') }], + granularity as never, + ANCHOR, + ) + + it.each([ + ['HOURLY', 48, 1], + ['HOURS_3', 16, 3], + ['HOURS_12', 4, 12], + ['HOURS_24', 2, 24], + ['DAILY', 2, 24], + ['DAYS_3', 1, 48], + ['DAYS_7', 1, 48], + ])('folds 48 hourly rows into %s buckets', (granularity, expectedRows, expectedPerBucket) => { + const rows = rowsAt(granularity) + + expect(rows).toHaveLength(expectedRows) + expect(rows[0].success).toBe(expectedPerBucket) + }) + + it.each(['HOURLY', 'HOURS_3', 'HOURS_12', 'HOURS_24', 'DAILY', 'DAYS_3', 'DAYS_7', 'DAYS_30'])( + 'preserves the total at %s', + (granularity) => { + const total = rowsAt(granularity).reduce((sum, row) => sum + Number(row.success), 0) + + expect(total).toBe(48) + }, + ) + + it('measures buckets from the range start rather than from the epoch', () => { + const [first] = rowsAt('DAYS_3') + + expect(first[AXIS_KEYS.TIMESTAMP]).toBe(ANCHOR) + }) +}) + +describe('resolveGranularity', () => { + it('keeps the choice when the range still allows it', () => { + expect(resolveGranularity('DAILY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') + }) + + it('falls back to the finest allowed when the choice is out of range', () => { + expect(resolveGranularity('HOURLY', ['DAILY', 'DAYS_3', 'DAYS_7'])).toBe('DAILY') + }) +}) + +describe('startOfDay and endOfDay', () => { + it('widens a mid-afternoon pick to cover the whole day', () => { + const picked = createDate(new Date(2026, 7, 20, 14, 37, 12)) + + expect(formatDateForApi(startOfDay(picked).toDate())).toBe('2026-08-20T00:00:00') + expect(formatDateForApi(endOfDay(picked).toDate())).toBe('2026-08-20T23:59:00') + }) + + it('keeps both ends on the date that was picked', () => { + const picked = createDate(new Date(2026, 7, 20, 0, 0, 0)) + + expect(startOfDay(picked).date()).toBe(20) + expect(endOfDay(picked).date()).toBe(20) + }) +}) + +describe('parseMetricData', () => { + it('parses a JSON string payload', () => { + expect(parseMetricData('{"success":10,"failure":2}')).toEqual({ success: 10, failure: 2 }) + }) + + it('reads an object payload directly', () => { + expect(parseMetricData({ success: 10 })).toEqual({ success: 10 }) + }) + + it('addresses nested values by dotted path', () => { + expect(parseMetricData({ auth: { success: 3, nested: { latency: 12.5 } } })).toEqual({ + 'auth.success': 3, + 'auth.nested.latency': 12.5, + }) + }) + + it('coerces quoted numbers, since counters often arrive as strings', () => { + expect(parseMetricData('{"success":"42"}')).toEqual({ success: 42 }) + }) + + it('skips non-numeric and non-finite values instead of emitting NaN', () => { + expect(parseMetricData({ label: 'basic', ok: true, bad: 'abc', empty: '' })).toEqual({}) + }) + + it.each([['{not json'], [null], [undefined], ['']])('returns no values for %p', (input) => { + expect(parseMetricData(input)).toEqual({}) + }) +}) + +describe('toMetricPoints', () => { + const entry = (startDate: string, data: string): MetricAggregationEntry => ({ + startDate, + applicationType: 'jans_auth', + metricType: 'user_authentication_success', + metricSubType: 'basic', + data, + }) + + it('sorts chronologically regardless of response order', () => { + const points = toMetricPoints( + [entry('2026-08-03T00:00:00Z', '{"n":3}'), entry('2026-08-01T00:00:00Z', '{"n":1}')], + 'MMM-DD', + ) + + expect(points.map((p) => p.values.n)).toEqual([1, 3]) + }) + + it('carries the identifying fields and retains the raw payload', () => { + const [point] = toMetricPoints([entry('2026-08-01T00:00:00Z', '{"n":1}')], 'MMM-DD') + + expect(point).toMatchObject({ + appType: 'jans_auth', + metricType: 'user_authentication_success', + subType: 'basic', + values: { n: 1 }, + raw: '{"n":1}', + }) + }) + + it('keeps an unparseable entry rather than dropping it silently', () => { + const points = toMetricPoints([entry('2026-08-01T00:00:00Z', 'not-json')], 'MMM-DD') + + expect(points).toHaveLength(1) + expect(points[0]!.values).toEqual({}) + expect(points[0]!.raw).toBe('not-json') + }) + + it('returns nothing for a missing entry list', () => { + expect(toMetricPoints(undefined, 'MMM-DD')).toEqual([]) + }) +}) + +const entry = (startDate: string, count: number, metricSubType?: string): MetricDataEntry => ({ + startDate, + metricSubType, + data: { count }, +}) + +describe('plainPoints and subTypePoints', () => { + const points = toMetricPoints( + [entry('2026-08-19T06:00:00Z', 6), entry('2026-08-19T06:00:00Z', 6, 'basic')], + 'MMM-DD', + ) + + it('keeps only the untagged rows for a total', () => { + expect(sumCounts(plainPoints(points))).toBe(6) + }) + + it('keeps only the tagged rows for a breakdown', () => { + expect(subTypePoints(points).map((point) => point.subType)).toEqual(['basic']) + }) +}) + +describe('buildChartRows', () => { + const at = (iso: string, count: number) => toMetricPoints([entry(iso, count)], 'MMM-DD')[0] + + it('sums five-minute rows into the requested bucket', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: [at('2026-08-19T06:05:00Z', 2), at('2026-08-19T06:55:00Z', 3)], + }, + ], + 'HOURLY', + ANCHOR, + ) + + expect(rows).toHaveLength(1) + expect(rows[0].success).toBe(5) + }) + + it('zero-fills a series that has no row in a bucket, so its line stays connected', () => { + const rows = buildChartRows( + [ + { key: 'success', points: [at('2026-08-19T06:00:00Z', 4)] }, + { key: 'failure', points: [] }, + ], + 'HOURLY', + ANCHOR, + ) + + expect(rows[0].failure).toBe(0) + }) + + it('orders buckets chronologically regardless of input order', () => { + const rows = buildChartRows( + [ + { + key: 'success', + points: [at('2026-08-20T06:00:00Z', 1), at('2026-08-19T06:00:00Z', 2)], + }, + ], + 'DAILY', + ANCHOR, + ) + + expect(rows.map((row) => row.success)).toEqual([2, 1]) + }) + + it('keeps the axis fields intact when a series is named after one', () => { + const rows = buildChartRows( + [ + { key: 'label', points: [at('2026-08-19T06:00:00Z', 7)] }, + { key: 'timestamp', points: [at('2026-08-19T06:00:00Z', 9)] }, + ], + 'DAILY', + ANCHOR, + ) + + expect(typeof rows[0]![AXIS_KEYS.LABEL]).toBe('string') + expect(rows[0]![AXIS_KEYS.TIMESTAMP]).toBeGreaterThan(0) + expect(rows[0]!.label).toBe(7) + expect(rows[0]!.timestamp).toBe(9) + }) +}) + +describe('groupBySubType', () => { + it('splits each acr into its own series, sorted so colours stay stable', () => { + const points = toMetricPoints( + [ + entry('2026-08-19T06:00:00Z', 1, 'simple_password_auth'), + entry('2026-08-19T06:00:00Z', 2, 'basic'), + entry('2026-08-19T06:00:00Z', 3), + ], + 'MMM-DD', + ) + + expect(groupBySubType(points).map((series) => series.key)).toEqual([ + 'basic', + 'simple_password_auth', + ]) + }) +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx new file mode 100644 index 0000000000..b7e48189a2 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AcrBreakdownChart.tsx @@ -0,0 +1,114 @@ +import React, { useMemo } from 'react' +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { acrColorAt } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow, NamedSeries } from '../types' + +const AREA_FILL_OPACITY = 0.35 + +type AcrBreakdownChartProps = { + rows: MetricChartRow[] + series: NamedSeries[] +} + +const AcrBreakdownChart: React.FC = ({ rows, series }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + + const legend = useMemo( + () => + series.map((entry, index) => ({ + label: entry.label ?? entry.key, + color: acrColorAt(themeColors, index), + })), + [series, themeColors], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => + Math.max( + max, + series.reduce((total, entry) => total + Number(row[entry.key] ?? 0), 0), + ), + 0, + ), + ), + [rows, series], + ) + + const isEmpty = rows.length === 0 || series.length === 0 + + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + +
+ + + + + + {isEmpty ? null : } + {series.map((entry, index) => { + const color = acrColorAt(themeColors, index) + return ( + + ) + })} + + +
+
+ ) +} + +export default React.memo(AcrBreakdownChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx new file mode 100644 index 0000000000..ac5df6adc9 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthActivityChart.tsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react' +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { SERIES_KEYS } from '../hooks' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow } from '../types' + +type AuthActivityChartProps = { + rows: MetricChartRow[] +} + +const AuthActivityChart: React.FC = ({ rows }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + const legend = useMemo( + () => [ + { label: t('fields.auth_success'), color: palette.success }, + { label: t('fields.auth_failure'), color: palette.failure }, + ], + [t, palette], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => + Math.max(max, Number(row[SERIES_KEYS.SUCCESS]), Number(row[SERIES_KEYS.FAILURE])), + 0, + ), + ), + [rows], + ) + + const isEmpty = rows.length === 0 + + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + +
+ + + + + + {isEmpty ? null : } + + + + +
+
+ ) +} + +export default React.memo(AuthActivityChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx new file mode 100644 index 0000000000..c3aa92644f --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/AuthMetricsKpiStrip.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' + +type AuthMetricsKpiStripProps = { + totals: { + attempts: number + success: number + failure: number + successRate: number | null + acrCount: number + } +} + +const AuthMetricsKpiStrip: React.FC = ({ totals }) => { + const { t, i18n } = useTranslation() + const { themeColors, isDark } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + const formatCount = useMemo(() => { + const formatter = new Intl.NumberFormat(i18n.resolvedLanguage) + return (value: number) => formatter.format(value) + }, [i18n.resolvedLanguage]) + + const successRateLabel = totals.successRate === null ? '—' : `${totals.successRate.toFixed(1)}%` + + const cards = [ + { label: t('fields.auth_attempts'), value: formatCount(totals.attempts) }, + { + label: t('fields.auth_success'), + value: formatCount(totals.success), + color: palette.success, + }, + { + label: t('fields.auth_failure'), + value: formatCount(totals.failure), + color: totals.failure > 0 ? palette.failure : undefined, + }, + { label: t('fields.auth_success_rate'), value: successRateLabel }, + { label: t('fields.acr_in_use'), value: formatCount(totals.acrCount) }, + ] + + return ( +
+ {cards.map((card) => ( +
+

{card.label}

+

+ {card.value} +

+
+ ))} +
+ ) +} + +export default React.memo(AuthMetricsKpiStrip) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts new file mode 100644 index 0000000000..c81fedfbc2 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.style.ts @@ -0,0 +1,54 @@ +import { makeStyles } from 'tss-react/mui' +import customColors, { hexToRgb } from '@/customColors' +import { + SHARED_DROPDOWN_STYLES, + createBaseOptionStyles, +} from '@/components/GluuDropdown/sharedDropdownStyles' + +const OPTION_PADDING = '12px 16px' + +export const useGranularityMenuStyles = makeStyles<{ isDark: boolean }>()((_theme, { isDark }) => { + const dropdownBg = isDark ? customColors.darkDropdownBg : customColors.white + + return { + menu: { + backgroundColor: dropdownBg, + border: 'none', + borderRadius: SHARED_DROPDOWN_STYLES.borderRadius, + boxShadow: `0px 4px 11px 0px rgba(${hexToRgb(customColors.black)}, 0.05)`, + padding: 0, + width: 'max-content', + minWidth: SHARED_DROPDOWN_STYLES.minWidth, + maxHeight: SHARED_DROPDOWN_STYLES.maxHeight, + overflow: 'visible', + position: 'relative', + }, + content: { + padding: SHARED_DROPDOWN_STYLES.padding, + maxHeight: SHARED_DROPDOWN_STYLES.maxHeight, + overflowY: 'auto', + overflowX: 'hidden', + }, + arrow: { + 'position': 'absolute', + 'top': '-15px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'width': SHARED_DROPDOWN_STYLES.arrowWidth, + 'height': SHARED_DROPDOWN_STYLES.arrowHeight, + 'zIndex': SHARED_DROPDOWN_STYLES.arrowZIndex, + 'pointerEvents': 'none', + '& svg': { + width: '100%', + height: '100%', + fill: dropdownBg, + color: dropdownBg, + filter: `drop-shadow(0px -1px 2px rgba(${hexToRgb(customColors.black)}, 0.1))`, + }, + }, + option: { + ...createBaseOptionStyles({ isDark, optionPadding: OPTION_PADDING }), + whiteSpace: 'nowrap' as const, + }, + } +}) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx new file mode 100644 index 0000000000..484d51b813 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/GranularityMenu.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useEffect } from 'react' +import Box from '@mui/material/Box' +import ClickAwayListener from '@mui/material/ClickAwayListener' +import ArrowIcon from '@/components/SVG/Arrow' +import { useSecurityTheme } from '../../SecurityMonitor/hooks' +import { useGranularityMenuStyles } from './GranularityMenu.style' +import type { Granularity, GranularityMenuProps } from '../types' + +const GranularityMenu: React.FC = ({ + options, + value, + onSelect, + onDismiss, + ariaLabel, +}) => { + const { isDark } = useSecurityTheme() + const { classes, cx } = useGranularityMenuStyles({ isDark }) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onDismiss() + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [onDismiss]) + + const handleSelect = useCallback( + (next: Granularity) => { + onSelect(next) + onDismiss() + }, + [onSelect, onDismiss], + ) + + return ( + + +
+ +
+
+ {options.map((option) => { + const isSelected = option.value === value + return ( + handleSelect(option.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleSelect(option.value) + } + }} + > + {option.label} + + ) + })} +
+
+
+ ) +} + +export default React.memo(GranularityMenu) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx new file mode 100644 index 0000000000..134fb7ffde --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/TokenIssuanceChart.tsx @@ -0,0 +1,116 @@ +import React, { useMemo } from 'react' +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useTranslation } from 'react-i18next' +import { useChartTheme } from '@/hooks/useChartTheme' +import { RECHARTS_INITIAL_DIMENSION } from '../../Metrics/constants' +import { buildCountAxis } from '../../SecurityMonitor/utils' +import SecurityChartCard from '../../SecurityMonitor/components/SecurityChartCard' +import { SERIES_KEYS } from '../hooks' +import { AXIS_KEYS, SPARSE_SERIES_MAX_POINTS } from '../constants' +import { getSeriesColors } from '../utils' +import { useAuthMetricsStyles } from '../AuthMetricsPage.style' +import type { MetricChartRow } from '../types' + +type TokenIssuanceChartProps = { + rows: MetricChartRow[] +} + +const TokenIssuanceChart: React.FC = ({ rows }) => { + const { t } = useTranslation() + const { themeColors, isDark, gridProps, axisTick, renderTooltip } = useChartTheme() + const { classes } = useAuthMetricsStyles({ isDark, themeColors }) + const palette = useMemo(() => getSeriesColors(themeColors), [themeColors]) + + const series = useMemo( + () => [ + { + key: SERIES_KEYS.ACCESS_TOKEN, + label: t('fields.access_tokens'), + color: palette.accessToken, + }, + { key: SERIES_KEYS.ID_TOKEN, label: t('fields.id_tokens'), color: palette.idToken }, + { + key: SERIES_KEYS.REFRESH_TOKEN, + label: t('fields.refresh_tokens'), + color: palette.refreshToken, + }, + { + key: SERIES_KEYS.AUTHORIZATION_CODE, + label: t('fields.authorization_codes'), + color: palette.authorizationCode, + }, + ], + [t, palette], + ) + + const countAxis = useMemo( + () => + buildCountAxis( + rows.reduce( + (max, row) => Math.max(max, ...series.map((entry) => Number(row[entry.key] ?? 0))), + 0, + ), + ), + [rows, series], + ) + + const isEmpty = rows.length === 0 + + const dot = rows.length <= SPARSE_SERIES_MAX_POINTS && { r: 3 } + + return ( + ({ label: entry.label, color: entry.color }))} + isEmpty={isEmpty} + emptyLabel={t('fields.no_data')} + > +
+ + + + + + {isEmpty ? null : } + {series.map((entry) => ( + + ))} + + +
+
+ ) +} + +export default React.memo(TokenIssuanceChart) diff --git a/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts new file mode 100644 index 0000000000..4d9790bde9 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/components/index.ts @@ -0,0 +1,5 @@ +export { default as AcrBreakdownChart } from './AcrBreakdownChart' +export { default as AuthActivityChart } from './AuthActivityChart' +export { default as AuthMetricsKpiStrip } from './AuthMetricsKpiStrip' +export { default as GranularityMenu } from './GranularityMenu' +export { default as TokenIssuanceChart } from './TokenIssuanceChart' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/constants.ts b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts new file mode 100644 index 0000000000..60af274044 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/constants.ts @@ -0,0 +1,113 @@ +export const AUTH_METRICS_CACHE_CONFIG = { + STALE_TIME: 5 * 60 * 1000, + GC_TIME: 10 * 60 * 1000, +} as const + +export const PAGE_SIZE = { + SERIES: 500, +} as const + +export const MAX_ENTRY_PAGES = 200 + +export const METRIC_TYPES = { + AUTH_SUCCESS: 'user_authentication_success', + AUTH_FAILURE: 'user_authentication_failure', + AUTH_RATE: 'user_authentication_rate', + DCR_RATE: 'dynamic_client_registration_rate', + ACCESS_TOKEN: 'tkn_access_token_count', + ID_TOKEN: 'tkn_id_token_count', + REFRESH_TOKEN: 'tkn_refresh_token_count', + AUTHORIZATION_CODE: 'tkn_authorization_code_count', + LOGOUT_STATUS_JWT: 'tkn_logout_status_jwt_count', + LONG_LIVED_ACCESS_TOKEN: 'tkn_long_lived_access_token_count', +} as const + +export const COUNT_KEY = 'count' + +export const AXIS_KEYS = { + TIMESTAMP: '__timestamp', + LABEL: '__label', +} as const + +export const GRANULARITIES = { + HOURLY: 'HOURLY', + HOURS_3: 'HOURS_3', + HOURS_12: 'HOURS_12', + HOURS_24: 'HOURS_24', + DAILY: 'DAILY', + DAYS_3: 'DAYS_3', + DAYS_7: 'DAYS_7', + DAYS_15: 'DAYS_15', + DAYS_21: 'DAYS_21', + DAYS_30: 'DAYS_30', +} as const + +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +export const GRANULARITY_STEP_MS = { + [GRANULARITIES.HOURLY]: HOUR_MS, + [GRANULARITIES.HOURS_3]: 3 * HOUR_MS, + [GRANULARITIES.HOURS_12]: 12 * HOUR_MS, + [GRANULARITIES.HOURS_24]: DAY_MS, + [GRANULARITIES.DAILY]: DAY_MS, + [GRANULARITIES.DAYS_3]: 3 * DAY_MS, + [GRANULARITIES.DAYS_7]: 7 * DAY_MS, + [GRANULARITIES.DAYS_15]: 15 * DAY_MS, + [GRANULARITIES.DAYS_21]: 21 * DAY_MS, + [GRANULARITIES.DAYS_30]: 30 * DAY_MS, +} + +export const GRANULARITY_LABEL_FORMATS = { + [GRANULARITIES.HOURLY]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_3]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_12]: 'MMM-DD HH:00', + [GRANULARITIES.HOURS_24]: 'MMM-DD', + [GRANULARITIES.DAILY]: 'MMM-DD', + [GRANULARITIES.DAYS_3]: 'MMM-DD', + [GRANULARITIES.DAYS_7]: 'MMM-DD', + [GRANULARITIES.DAYS_15]: 'MMM-DD', + [GRANULARITIES.DAYS_21]: 'MMM-DD', + [GRANULARITIES.DAYS_30]: 'MMM-DD', +} + +export const GRANULARITY_TIERS = [ + { + maxSpanDays: 2, + granularities: [ + GRANULARITIES.HOURLY, + GRANULARITIES.HOURS_3, + GRANULARITIES.HOURS_12, + GRANULARITIES.HOURS_24, + ], + }, + { + maxSpanDays: 14, + granularities: [GRANULARITIES.DAILY, GRANULARITIES.DAYS_3, GRANULARITIES.DAYS_7], + }, + { + maxSpanDays: Number.POSITIVE_INFINITY, + granularities: [ + GRANULARITIES.DAILY, + GRANULARITIES.DAYS_3, + GRANULARITIES.DAYS_7, + GRANULARITIES.DAYS_15, + GRANULARITIES.DAYS_21, + GRANULARITIES.DAYS_30, + ], + }, +] as const + +export const DEFAULT_GRANULARITY = GRANULARITIES.DAILY + +export const POINT_LABEL_FORMAT = 'MMM-DD HH:mm' + +export const SPARSE_SERIES_MAX_POINTS = 40 + +export const DEFAULT_SELECTED_RANGE_DAYS = 7 + +export const DATE_PRESETS = [ + { labelKey: 'fields.date_preset_24h', value: 1 }, + { labelKey: 'fields.date_preset_7d', value: 7 }, + { labelKey: 'fields.date_preset_30d', value: 30 }, +] as const diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts new file mode 100644 index 0000000000..be4f7d8c17 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/index.ts @@ -0,0 +1,2 @@ +export { fetchAllMetricEntries, useAllMetricEntries } from './useMetricSeries' +export { useAuthMetricsCharts, SERIES_KEYS } from './useAuthMetricsCharts' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts new file mode 100644 index 0000000000..e9410c3ef4 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useAuthMetricsCharts.ts @@ -0,0 +1,119 @@ +import { useMemo } from 'react' +import { METRIC_TYPES } from '../constants' +import { buildChartRows, groupBySubType, plainPoints, sumCounts, toUtcWallClockMs } from '../utils' +import { useAllMetricEntries } from './useMetricSeries' +import type { Granularity, MetricRange, NamedSeries } from '../types' + +const SERIES_KEYS = { + SUCCESS: 'success', + FAILURE: 'failure', + ACCESS_TOKEN: 'accessToken', + ID_TOKEN: 'idToken', + REFRESH_TOKEN: 'refreshToken', + AUTHORIZATION_CODE: 'authorizationCode', +} as const + +const ACR_KEY_PREFIX = 'acr__' + +type UseAuthMetricsChartsArgs = { + range: MetricRange + granularity: Granularity +} + +export const useAuthMetricsCharts = ({ range, granularity }: UseAuthMetricsChartsArgs) => { + const success = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_SUCCESS }) + const failure = useAllMetricEntries({ range, metricType: METRIC_TYPES.AUTH_FAILURE }) + const accessToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.ACCESS_TOKEN }) + const idToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.ID_TOKEN }) + const refreshToken = useAllMetricEntries({ range, metricType: METRIC_TYPES.REFRESH_TOKEN }) + const authorizationCode = useAllMetricEntries({ + range, + metricType: METRIC_TYPES.AUTHORIZATION_CODE, + }) + + const anchorMs = useMemo(() => toUtcWallClockMs(range.startDate), [range.startDate]) + + const successTotals = useMemo(() => plainPoints(success.points), [success.points]) + const failureTotals = useMemo(() => plainPoints(failure.points), [failure.points]) + + const authRows = useMemo( + () => + buildChartRows( + [ + { key: SERIES_KEYS.SUCCESS, points: successTotals }, + { key: SERIES_KEYS.FAILURE, points: failureTotals }, + ], + granularity, + anchorMs, + ), + [successTotals, failureTotals, granularity, anchorMs], + ) + + const acrSeries = useMemo( + () => + groupBySubType(success.points).map((series) => ({ + ...series, + key: `${ACR_KEY_PREFIX}${series.key}`, + label: series.key, + })), + [success.points], + ) + + const acrRows = useMemo( + () => buildChartRows(acrSeries, granularity, anchorMs), + [acrSeries, granularity, anchorMs], + ) + + const tokenRows = useMemo( + () => + buildChartRows( + [ + { key: SERIES_KEYS.ACCESS_TOKEN, points: plainPoints(accessToken.points) }, + { key: SERIES_KEYS.ID_TOKEN, points: plainPoints(idToken.points) }, + { key: SERIES_KEYS.REFRESH_TOKEN, points: plainPoints(refreshToken.points) }, + { key: SERIES_KEYS.AUTHORIZATION_CODE, points: plainPoints(authorizationCode.points) }, + ], + granularity, + anchorMs, + ), + [ + anchorMs, + accessToken.points, + idToken.points, + refreshToken.points, + authorizationCode.points, + granularity, + ], + ) + + const totals = useMemo(() => { + const successCount = sumCounts(successTotals) + const failureCount = sumCounts(failureTotals) + const attempts = successCount + failureCount + + return { + success: successCount, + failure: failureCount, + attempts, + successRate: attempts > 0 ? (successCount / attempts) * 100 : null, + acrCount: acrSeries.length, + } + }, [successTotals, failureTotals, acrSeries]) + + const queries = [success, failure, accessToken, idToken, refreshToken, authorizationCode] + + return { + authRows, + acrRows, + acrSeries, + tokenRows, + totals, + isBusy: queries.some((query) => query.isLoading || query.isFetching), + isError: queries.every((query) => query.isError), + isPartial: queries.some((query) => query.isError) && !queries.every((query) => query.isError), + isTruncated: queries.some((query) => query.isTruncated), + refetch: () => queries.forEach((query) => void query.refetch()), + } +} + +export { SERIES_KEYS, ACR_KEY_PREFIX } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts new file mode 100644 index 0000000000..502201d714 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/hooks/useMetricSeries.ts @@ -0,0 +1,119 @@ +import { useMemo } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useAppSelector } from '@/redux/hooks' +import { + getMetricEntries, + type GetMetricEntriesParams, + type MetricDataEntry, + type MetricEntryPagedResult, +} from 'JansConfigApi' +import { + AUTH_METRICS_CACHE_CONFIG, + POINT_LABEL_FORMAT, + MAX_ENTRY_PAGES, + PAGE_SIZE, +} from '../constants' +import { formatDateForApi, toMetricPoints } from '../utils' +import type { MetricPoint, MetricQueryOptions, MetricRange } from '../types' + +type EntriesFetcher = ( + params: GetMetricEntriesParams, + signal?: AbortSignal, +) => Promise + +type AllEntriesResult = { + entries: MetricDataEntry[] + totalCount: number + isTruncated: boolean +} + +const defaultFetcher: EntriesFetcher = (params, signal) => + getMetricEntries(params, undefined, signal) + +const fetchAllMetricEntries = async ( + baseParams: GetMetricEntriesParams, + { fetcher = defaultFetcher, signal }: { fetcher?: EntriesFetcher; signal?: AbortSignal } = {}, +): Promise => { + const collected: MetricDataEntry[] = [] + let totalCount = 0 + + for (let page = 0; page < MAX_ENTRY_PAGES; page += 1) { + const result = await fetcher( + { + ...baseParams, + startIndex: page * PAGE_SIZE.SERIES, + limit: PAGE_SIZE.SERIES, + sortBy: 'jansStartDate', + sortOrder: 'ascending', + }, + signal, + ) + + const batch = result?.entries ?? [] + collected.push(...batch) + totalCount = result?.totalEntriesCount ?? collected.length + + if (batch.length === 0 || collected.length >= totalCount) { + return { entries: collected, totalCount, isTruncated: false } + } + } + + return { entries: collected, totalCount, isTruncated: collected.length < totalCount } +} + +const useIsEnabled = (range: MetricRange, options?: MetricQueryOptions) => { + const hasSession = useAppSelector((state) => state.authReducer?.hasSession) + return (options?.enabled ?? true) && hasSession === true && !!range.startDate && !!range.endDate +} + +const sharedQueryConfig = { + staleTime: AUTH_METRICS_CACHE_CONFIG.STALE_TIME, + gcTime: AUTH_METRICS_CACHE_CONFIG.GC_TIME, + placeholderData: keepPreviousData, +} + +const useAllMetricEntries = ( + args: { + range: MetricRange + metricType: string + appType?: string + subType?: string + }, + options?: MetricQueryOptions, +) => { + const { range, metricType, appType, subType } = args + const isEnabled = useIsEnabled(range, options) && !!metricType + + const params: GetMetricEntriesParams = useMemo( + () => ({ + metricType, + start_date: formatDateForApi(range.startDate), + end_date: formatDateForApi(range.endDate), + ...(appType ? { appType } : {}), + ...(subType ? { subType } : {}), + }), + [metricType, range.startDate, range.endDate, appType, subType], + ) + + const query = useQuery({ + queryKey: ['metric-entries-all', params], + queryFn: ({ signal }) => fetchAllMetricEntries(params, { signal }), + enabled: isEnabled, + ...sharedQueryConfig, + }) + + const points: MetricPoint[] = useMemo( + () => toMetricPoints(query.data?.entries, POINT_LABEL_FORMAT), + [query.data], + ) + + return { + ...query, + points, + totalCount: query.data?.totalCount ?? 0, + isTruncated: query.data?.isTruncated ?? false, + } +} + +export { fetchAllMetricEntries, useAllMetricEntries } +export type { AllEntriesResult, EntriesFetcher } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/index.ts new file mode 100644 index 0000000000..354b279c03 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/index.ts @@ -0,0 +1,4 @@ +export { default } from './AuthMetricsPage' +export { useAllMetricEntries, useAuthMetricsCharts } from './hooks' +export * from './constants' +export type * from './types' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts new file mode 100644 index 0000000000..90e583065c --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/AuthMetricsTypes.ts @@ -0,0 +1,58 @@ +import type { MetricRawData } from './JsonTypes' +import type { GRANULARITIES } from '../constants' + +type Granularity = (typeof GRANULARITIES)[keyof typeof GRANULARITIES] + +type MetricRange = { + startDate: Date + endDate: Date +} + +type MetricDataValues = Record + +type MetricPoint = { + timestamp: number + label: string + appType?: string + metricType?: string + subType?: string + values: MetricDataValues + raw: MetricRawData +} + +type MetricQueryOptions = { + enabled?: boolean +} + +type NamedSeries = { + key: string + label?: string + points: MetricPoint[] +} + +type MetricChartRow = Record + +export type { + Granularity, + GranularityMenuOption, + GranularityMenuProps, + MetricChartRow, + MetricDataValues, + MetricPoint, + MetricQueryOptions, + MetricRange, + NamedSeries, +} + +type GranularityMenuOption = { + value: Granularity + label: string +} + +type GranularityMenuProps = { + options: readonly GranularityMenuOption[] + value: Granularity + onSelect: (value: Granularity) => void + onDismiss: () => void + ariaLabel: string +} diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts new file mode 100644 index 0000000000..6e63cc38f4 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/JsonTypes.ts @@ -0,0 +1,9 @@ +type JsonPrimitive = string | number | boolean | null + +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } + +type JsonObject = { [key: string]: JsonValue } + +type MetricRawData = JsonValue | undefined + +export type { JsonObject, JsonPrimitive, JsonValue, MetricRawData } diff --git a/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts b/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts new file mode 100644 index 0000000000..efd2683fde --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/types/index.ts @@ -0,0 +1,2 @@ +export type * from './AuthMetricsTypes' +export type * from './JsonTypes' diff --git a/admin-ui/plugins/fido/components/AuthMetrics/utils.ts b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts new file mode 100644 index 0000000000..bead902456 --- /dev/null +++ b/admin-ui/plugins/fido/components/AuthMetrics/utils.ts @@ -0,0 +1,206 @@ +import { createDate, createUtcDate, toApiDatetime, type Dayjs } from '@/utils/dayjsUtils' +import type { ThemeConfig } from '@/context/theme/config' +import { getSecurityPalette } from '../SecurityMonitor/utils' +import type { MetricAggregationEntry, MetricDataEntry } from 'JansConfigApi' +import { + AXIS_KEYS, + COUNT_KEY, + GRANULARITY_LABEL_FORMATS, + GRANULARITY_STEP_MS, + GRANULARITY_TIERS, +} from './constants' +import type { + Granularity, + JsonObject, + JsonValue, + MetricChartRow, + MetricDataValues, + MetricPoint, + MetricRawData, + NamedSeries, +} from './types' + +const formatDateForApi = (date: Date): string => toApiDatetime(createDate(date)) + +const startOfDay = (date: Dayjs): Dayjs => date.startOf('day') + +const endOfDay = (date: Dayjs): Dayjs => date.hour(23).minute(59).second(0).millisecond(0) + +const granularitiesForRange = (startDate: Date, endDate: Date): readonly Granularity[] => { + const spanDays = createDate(endDate).diff(createDate(startDate), 'day') + const tier = + GRANULARITY_TIERS.find((candidate) => spanDays <= candidate.maxSpanDays) ?? + GRANULARITY_TIERS[GRANULARITY_TIERS.length - 1] + + return tier.granularities +} + +const resolveGranularity = ( + granularity: Granularity, + allowed: readonly Granularity[], +): Granularity => (allowed.includes(granularity) ? granularity : allowed[0]) + +const isRecord = (value: JsonValue | undefined): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const toDataObject = (data: MetricRawData): JsonValue | undefined => { + if (typeof data !== 'string') return data + try { + return JSON.parse(data) + } catch { + return null + } +} + +const collectNumericValues = ( + value: JsonValue | undefined, + prefix = '', + acc: MetricDataValues = {}, +): MetricDataValues => { + if (!isRecord(value)) return acc + + for (const [key, leaf] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key + + if (typeof leaf === 'number' && Number.isFinite(leaf)) { + acc[path] = leaf + } else if (typeof leaf === 'string' && leaf.trim() !== '' && Number.isFinite(Number(leaf))) { + acc[path] = Number(leaf) + } else if (isRecord(leaf)) { + collectNumericValues(leaf, path, acc) + } + } + + return acc +} + +const parseMetricData = (data: MetricRawData): MetricDataValues => + collectNumericValues(toDataObject(data)) + +const toTimestamp = (value?: string): number => { + if (!value) return 0 + const parsed = createUtcDate(value) + return parsed.isValid() ? parsed.valueOf() : 0 +} + +const toMetricPoint = ( + entry: MetricAggregationEntry | MetricDataEntry, + labelFormat: string, +): MetricPoint => { + const timestamp = toTimestamp(entry.startDate) + + return { + timestamp, + label: timestamp ? createUtcDate(entry.startDate).format(labelFormat) : '', + appType: entry.applicationType, + metricType: entry.metricType, + subType: entry.metricSubType, + values: parseMetricData(entry.data as MetricRawData), + raw: entry.data as MetricRawData, + } +} + +const toMetricPoints = ( + entries: readonly (MetricAggregationEntry | MetricDataEntry)[] | undefined, + labelFormat: string, +): MetricPoint[] => + (entries ?? []) + .map((entry) => toMetricPoint(entry, labelFormat)) + .sort((a, b) => a.timestamp - b.timestamp) + +const getSeriesColors = (themeColors: ThemeConfig) => { + const { chart } = getSecurityPalette(themeColors) + + return { + success: chart.success, + failure: chart.failures, + accessToken: themeColors.chart.blue, + idToken: themeColors.chart.lightBlue, + refreshToken: themeColors.chart.purple, + authorizationCode: themeColors.chart.cyan, + } +} + +const acrColorAt = (themeColors: ThemeConfig, index: number): string => { + const { errorCategories } = getSecurityPalette(themeColors) + return errorCategories[index % errorCategories.length] +} + +const plainPoints = (points: readonly MetricPoint[]): MetricPoint[] => + points.filter((point) => !point.subType) + +const subTypePoints = (points: readonly MetricPoint[]): MetricPoint[] => + points.filter((point) => !!point.subType) + +const countOf = (point: MetricPoint): number => point.values[COUNT_KEY] ?? 0 + +const sumCounts = (points: readonly MetricPoint[]): number => + points.reduce((total, point) => total + countOf(point), 0) + +const toUtcWallClockMs = (date: Date): number => createUtcDate(formatDateForApi(date)).valueOf() + +const bucketStart = (timestamp: number, granularity: Granularity, anchorMs: number): number => { + const step = GRANULARITY_STEP_MS[granularity] + + return anchorMs + Math.floor((timestamp - anchorMs) / step) * step +} + +const buildChartRows = ( + series: readonly NamedSeries[], + granularity: Granularity, + anchorMs: number, +): MetricChartRow[] => { + const labelFormat = GRANULARITY_LABEL_FORMATS[granularity] + const rows = new Map() + + for (const { key, points } of series) { + for (const point of points) { + if (!point.timestamp) continue + const bucket = bucketStart(point.timestamp, granularity, anchorMs) + const row = rows.get(bucket) ?? { + [AXIS_KEYS.TIMESTAMP]: bucket, + [AXIS_KEYS.LABEL]: createUtcDate(bucket).format(labelFormat), + } + row[key] = (typeof row[key] === 'number' ? row[key] : 0) + countOf(point) + rows.set(bucket, row) + } + } + + const zeroed = Object.fromEntries(series.map(({ key }) => [key, 0])) + + return [...rows.values()] + .sort((a, b) => Number(a[AXIS_KEYS.TIMESTAMP]) - Number(b[AXIS_KEYS.TIMESTAMP])) + .map((row) => ({ ...zeroed, ...row })) +} + +const groupBySubType = (points: readonly MetricPoint[]): NamedSeries[] => { + const groups = new Map() + + for (const point of subTypePoints(points)) { + const key = point.subType as string + groups.set(key, [...(groups.get(key) ?? []), point]) + } + + return [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, grouped]) => ({ key, points: grouped })) +} + +export { + acrColorAt, + buildChartRows, + countOf, + endOfDay, + formatDateForApi, + getSeriesColors, + granularitiesForRange, + toUtcWallClockMs, + groupBySubType, + parseMetricData, + plainPoints, + resolveGranularity, + startOfDay, + subTypePoints, + sumCounts, + toMetricPoints, +} diff --git a/admin-ui/plugins/fido/plugin-metadata.ts b/admin-ui/plugins/fido/plugin-metadata.ts index 3ba4c36f5d..8308c4dd8a 100644 --- a/admin-ui/plugins/fido/plugin-metadata.ts +++ b/admin-ui/plugins/fido/plugin-metadata.ts @@ -6,6 +6,7 @@ import { createLazyRoute } from '@/utils/RouteLoader' const Fido = createLazyRoute(() => import('./components/Configuration')) const MetricsPage = createLazyRoute(() => import('./components/Metrics')) const SecurityMonitorPage = createLazyRoute(() => import('./components/SecurityMonitor')) +const AuthMetricsPage = createLazyRoute(() => import('./components/AuthMetrics')) const pluginMetadata = { menus: [ @@ -32,6 +33,12 @@ const pluginMetadata = { action: CEDAR_ACTIONS.READ, resourceKey: ADMIN_UI_RESOURCES.FIDO, }, + { + title: 'menus.auth_metrics', + path: ROUTES.FIDO_AUTH_METRICS, + action: CEDAR_ACTIONS.READ, + resourceKey: ADMIN_UI_RESOURCES.FIDO, + }, ], }, ], @@ -54,6 +61,12 @@ const pluginMetadata = { action: CEDAR_ACTIONS.READ, resourceKey: ADMIN_UI_RESOURCES.FIDO, }, + { + component: AuthMetricsPage, + path: ROUTES.FIDO_AUTH_METRICS, + action: CEDAR_ACTIONS.READ, + resourceKey: ADMIN_UI_RESOURCES.FIDO, + }, ], reducers: [], }