diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index ceb5cf001a..31f0faecce 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -422,7 +422,7 @@ pub async fn list_mfa_flows( (status = 201, description = "MFA flow created.", body = MfaFlowDetailResponse), (status = 400, description = "Invalid request data: structured `validation_failed` with `fields[]`, e.g. `required`, `min_items`, `max_items`, `max_length`, `duplicate`, `smtp_not_configured`, `oidc_provider_missing`.", body = ApiErrorResponse, example = json!({"error": "validation_failed", "fields": [{"field": "steps[0].methods", "code": "oidc_provider_missing"}]})), (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "Requires admin privileges, or the request needs a higher licence tier (`business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "steps", "code": "business_license_required"}]})), + (status = 403, description = "Requires admin privileges, or the request needs a higher licence tier (`additional_flow_business_license_required` for an additional flow; `business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "flow", "code": "additional_flow_business_license_required"}]})), (status = 500, description = "Unable to create MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( @@ -450,6 +450,13 @@ pub async fn create_mfa_flow( return Ok(resp); } + if !is_business_license_active() && MfaFlow::any_exist(&appstate.pool).await? { + return Ok(license_error_response( + "flow".into(), + "additional_flow_business_license_required", + )); + } + let mut tx = appstate.pool.begin().await?; let (flow, steps) = MfaFlow::create(&mut tx, data.title, step_methods).await?; tx.commit().await?; diff --git a/crates/defguard_core/tests/integration/api/mfa_flow.rs b/crates/defguard_core/tests/integration/api/mfa_flow.rs index 8ce444bb1f..be1b900df5 100644 --- a/crates/defguard_core/tests/integration/api/mfa_flow.rs +++ b/crates/defguard_core/tests/integration/api/mfa_flow.rs @@ -54,6 +54,60 @@ async fn test_mfa_flow_single_step_no_license(_: PgPoolOptions, options: PgConne set_cached_license(saved); } +/// A free instance may create one flow, while subsequent flows require Business. +#[sqlx::test] +async fn test_additional_mfa_flow_requires_business(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + let first_flow = json!({ + "title": "Free Flow", + "steps": [{ "methods": ["totp"] }] + }); + let second_flow = json!({ + "title": "Business Flow", + "steps": [{ "methods": ["biometric"] }] + }); + + set_cached_license(None); + let response = client + .post("/api/v1/mfa-flow") + .json(&first_flow) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + client.drain_all_events(); + + let response = client + .post("/api/v1/mfa-flow") + .json(&second_flow) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "license_required"); + assert_eq!(body["fields"][0]["field"], "flow"); + assert_eq!( + body["fields"][0]["code"], + "additional_flow_business_license_required" + ); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + set_cached_license(saved.clone()); + let response = client + .post("/api/v1/mfa-flow") + .json(&second_flow) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + set_cached_license(saved); +} + /// Multi-step flow (2+ steps) requires a business license. #[sqlx::test] async fn test_mfa_flow_multi_step_requires_business(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/web/messages/en/acl.json b/web/messages/en/acl.json index 53619d289b..128628114e 100644 --- a/web/messages/en/acl.json +++ b/web/messages/en/acl.json @@ -39,7 +39,7 @@ "acl_aliases_table_title_deployed": "Deployed aliases", "acl_aliases_table_title_pending": "Pending aliases", "acl_aliases_empty_deployed_title": "You haven't created any aliases yet.", - "acl_aliases_empty_deployed_subtitle": "Click the first alias by clicking button below.", + "acl_aliases_empty_deployed_subtitle": "Add the first alias by clicking button below.", "acl_aliases_search_empty_title": "No aliases found.", "acl_aliases_search_empty_subtitle": "Try different search.", "acl_destination_col_name": "Destination name", diff --git a/web/messages/en/components.json b/web/messages/en/components.json index 185dac58cd..b243bd3c9f 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -20,6 +20,7 @@ "cmp_nav_item_destinations": "Destinations", "cmp_nav_item_aliases": "Aliases", "cmp_nav_item_posture_checks": "Posture Checks", + "cmp_nav_item_mfa": "MFA Flow", "cmp_nav_item_users": "Users", "cmp_nav_item_groups": "Groups", "cmp_nav_item_enrollment": "Enrollment", diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json new file mode 100644 index 0000000000..303d43e928 --- /dev/null +++ b/web/messages/en/mfa_flow.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "mfa_flows_empty_title": "You don’t have any workflows yet.", + "mfa_flows_empty_subtitle": "Add the first rule by clicking button below.", + "mfa_flows_button_add": "Add new MFA flow", + "mfa_flows_table_heading": "All flows", + "mfa_flows_table_title": "Title", + "mfa_flows_table_steps": "MFA Steps", + "mfa_flow_delete_title": "Delete MFA flow", + "mfa_flow_delete_body": "Are you sure you want to delete MFA flow **{name}**? This action cannot be undone.", + "mfa_flow_deleted": "MFA flow deleted successfully.", + "mfa_flow_delete_failed": "Failed to delete MFA flow.", + "mfa_flow_breadcrumb_create": "Create new MFA flow", + "mfa_flow_breadcrumb_edit": "Edit MFA flow", + "mfa_flow_form_title_create": "Create new workflow", + "mfa_flow_form_title_edit": "Edit workflow", + "mfa_flow_form_subtitle": "Configure different access parameters, including multi-factor authentication requirements, for various user groups.", + "mfa_flow_form_general_settings": "General settings", + "mfa_flow_form_name": "Workflow name", + "mfa_flow_form_methods_title": "Multi-Factor Authentication Methods", + "mfa_flow_form_methods_description": "Define at least one authentication step for users, which will later be used in access conditions for locations.", + "mfa_flow_step_title": "Step {number}", + "mfa_flow_step_add": "Add MFA step", + "mfa_flow_step_required": "Add at least one MFA step.", + "mfa_flow_step_helper": "All factors in a step must be verified. Steps are completed in order.", + "mfa_flow_step_reorder": "Reorder step {number}", + "mfa_flow_step_remove": "Remove step {number}", + "mfa_flow_method_add": "+ Add factor", + "mfa_flow_method_remove": "Remove {method}", + "mfa_flow_method_mobile_client": "Defguard Mobile Client", + "mfa_flow_method_authenticator_app": "Authenticator App", + "mfa_flow_method_external_provider": "External ID Provider", + "mfa_flow_method_email_code": "Email Verification Code", + "mfa_flow_method_smtp_required": "Configure SMTP server in Settings (Notifications tab) to activate this MFA method", + "mfa_flow_method_business_required": "Upgrade your plan to Business to use this MFA method", + "mfa_flow_methods_available_in_plan": "Available in your plan", + "mfa_flow_methods_available_in_higher_plans": "Available in higher plans", + "mfa_flow_method_biometric": "Biometrics", + "mfa_flow_form_action_create": "Create MFA flow", + "mfa_flow_created": "MFA flow created successfully.", + "mfa_flow_updated": "MFA flow updated successfully.", + "mfa_flow_save_failed": "Failed to save MFA flow.", + "mfa_flow_error_additional_flow_business_license": "A Business license is required to create more than one MFA flow.", + "mfa_flow_error_business_license": "A Business license is required for multiple steps and external identity providers.", + "mfa_flow_error_smtp_not_configured": "Configure SMTP before using email verification codes.", + "mfa_flow_error_oidc_provider_missing": "Configure an OpenID provider before using external identity provider verification." +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index 3e3c3450ba..e3604e2f19 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -32,7 +32,8 @@ "./messages/{locale}/flow_end.json", "./messages/{locale}/support.json", "./messages/{locale}/acl.json", - "./messages/{locale}/postures.json" + "./messages/{locale}/postures.json", + "./messages/{locale}/mfa_flow.json" ] } } diff --git a/web/src/pages/MfaPage/MfaFlowsTable.tsx b/web/src/pages/MfaPage/MfaFlowsTable.tsx new file mode 100644 index 0000000000..7b626be027 --- /dev/null +++ b/web/src/pages/MfaPage/MfaFlowsTable.tsx @@ -0,0 +1,172 @@ +import { useNavigate } from '@tanstack/react-router'; +import { + type ColumnFiltersState, + createColumnHelper, + type FilterFn, + getCoreRowModel, + getFilteredRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; +import { m } from '../../paraglide/messages'; +import api from '../../shared/api/api'; +import type { MfaFlowListItemResponse } from '../../shared/api/types'; +import type { SelectionOption } from '../../shared/components/SelectionSection/type'; +import { Button } from '../../shared/defguard-ui/components/Button/Button'; +import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; +import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; +import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types'; +import { Search } from '../../shared/defguard-ui/components/Search/Search'; +import { tableEditColumnSize } from '../../shared/defguard-ui/components/table/consts'; +import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; +import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; +import { TableEditCell } from '../../shared/defguard-ui/components/table/TableEditCell/TableEditCell'; +import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/TableTop'; +import type { TableFilterMessages } from '../../shared/defguard-ui/components/table/types'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; +import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; + +type Props = { + flows: MfaFlowListItemResponse[]; + addButtonProps: ButtonProps; +}; + +const columnHelper = createColumnHelper(); + +/** Matches rows whose step count is one of the selected values. */ +const filterByStepCount: FilterFn = ( + row, + columnId, + selectedCounts: number[], +) => selectedCounts.includes(row.getValue(columnId)); +filterByStepCount.autoRemove = (value) => !Array.isArray(value) || value.length === 0; + +export const MfaFlowsTable = ({ flows, addButtonProps }: Props) => { + const navigate = useNavigate(); + const [search, setSearch] = useState(''); + const [columnFilters, setColumnFilters] = useState([]); + const visibleFlows = useMemo(() => { + const normalizedSearch = search.trim().toLowerCase(); + if (!normalizedSearch) return flows; + + return flows.filter((flow) => flow.title.toLowerCase().includes(normalizedSearch)); + }, [flows, search]); + const stepCountOptions = useMemo( + (): SelectionOption[] => + [...new Set(flows.map((flow) => flow.step_count))] + .sort((left, right) => left - right) + .map((count) => ({ id: count, label: String(count) })), + [flows], + ); + const filterMessages: TableFilterMessages = { + searchPlaceholder: m.controls_search(), + clearButton: m.controls_reset(), + applyButton: m.controls_submit(), + emptyState: m.search_empty_common_title(), + }; + const columns = useMemo( + () => [ + columnHelper.accessor('title', { + header: m.mfa_flows_table_title(), + minSize: 306, + meta: { flex: true }, + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor('step_count', { + header: m.mfa_flows_table_steps(), + size: 140, + enableColumnFilter: true, + filterFn: filterByStepCount, + meta: { filterOptions: stepCountOptions }, + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.display({ + id: 'edit', + header: '', + size: tableEditColumnSize, + enableResizing: false, + cell: (info) => { + const flow = info.row.original; + const menuItems: MenuItemsGroup[] = [ + { + items: [ + { + text: m.controls_edit(), + icon: 'edit', + onClick: () => { + void navigate({ + to: '/mfa-flow/$id/edit', + params: { id: String(flow.id) }, + }); + }, + }, + ], + }, + { + items: [ + { + text: m.controls_delete(), + icon: 'delete', + variant: 'danger', + onClick: () => { + openModal(ModalName.ConfirmAction, { + title: m.mfa_flow_delete_title(), + contentMd: m.mfa_flow_delete_body({ name: flow.title }), + actionPromise: () => api.mfaFlow.delete(flow.id), + invalidateKeys: [['mfa-flow']], + submitProps: { text: m.controls_delete(), variant: 'critical' }, + onSuccess: () => Snackbar.default(m.mfa_flow_deleted()), + onError: () => Snackbar.error(m.mfa_flow_delete_failed()), + }); + }, + }, + ], + }, + ]; + + return ; + }, + }), + ], + [navigate, stepCountOptions], + ); + const table = useReactTable({ + state: { columnFilters }, + meta: { filterMessages }, + columns, + data: visibleFlows, + enableRowSelection: false, + columnResizeMode: 'onChange', + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + getCoreRowModel: getCoreRowModel(), + }); + const rows = table.getRowModel().rows; + const hasActiveFilters = search.trim().length > 0 || columnFilters.length > 0; + + return ( + <> + + + -

{`Step ${step.order}`}

- - - -
- {step.factors.map((factor) => ( -
-
- -

{locationMfaMethodLabels[factor]}

-
- {factor === 'biometry' &&

{`Mobile only`}

} - -
-
-
- ))} -
- {methodGroups.some((g) => g.items.length > 0) && ( -
- - -

{m.test_placeholder_long()}

-
-
- )} - - ); -}; diff --git a/web/src/shared/components/LocationMfaConfiguration/types.ts b/web/src/shared/components/LocationMfaConfiguration/types.ts deleted file mode 100644 index b252802bb1..0000000000 --- a/web/src/shared/components/LocationMfaConfiguration/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; - -export const LocationMfaMethod = { - Totp: 'totp', - Tpm: 'tpm', - Email: 'email', - Biometry: 'biometry', - MobileConfirm: 'confirm_mobile', - HardwareKey: 'hardware_key', - OpenId: 'openid', -} as const; - -export const locationMfaMethodLabels: Record = { - totp: 'Authenticator App', - tpm: 'Hardware key (TPM 2.0)', - email: 'Email Verification Code', - biometry: 'Biometry', - confirm_mobile: 'Defguard Mobile Client', - hardware_key: 'Hardware Key', - openid: 'External ID Provider', -}; - -export type LocationMfaMethodValue = - (typeof LocationMfaMethod)[keyof typeof LocationMfaMethod]; - -export interface LocationMfaConfigurationStepData { - id: string; - order: number; - factors: LocationMfaMethodValue[]; -} - -export type LocationMfaMethodGroup = { - header?: { text: string }; - items: LocationMfaMethodValue[]; -}; - -export type LocationMfaConfigurationStepProps = { - step: LocationMfaConfigurationStepData; - methodGroups: LocationMfaMethodGroup[]; - onDeleteStep: (id: string) => void; - onAddFactor: (stepId: string, factor: LocationMfaMethodValue) => void; - onDeleteFactor: (stepId: string, factor: LocationMfaMethodValue) => void; - buildOption: (method: LocationMfaMethodValue, onClick: () => void) => MenuItemProps; -}; - -export type LocationMfaConfigurationProps = { - steps: LocationMfaConfigurationStepData[]; - onChange: (steps: LocationMfaConfigurationStepData[]) => void; - error?: string; -}; diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx new file mode 100644 index 0000000000..4dfe896338 --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -0,0 +1,195 @@ +import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { Reorder } from 'motion/react'; +import { useCallback } from 'react'; +import { m } from '../../../paraglide/messages'; +import { + MfaFlowMethod, + type MfaFlowMethodValue, + MfaMethodAvailabilityReason, + type MfaMethodAvailabilityReasonValue, +} from '../../api/types'; +import { Button } from '../../defguard-ui/components/Button/Button'; +import type { ButtonProps } from '../../defguard-ui/components/Button/types'; +import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; +import { + getLicenseInfoQueryOptions, + getMfaMethodAvailabilityQueryOptions, +} from '../../query'; +import { canUseBusinessFeature, licenseActionCheck } from '../../utils/license'; +import { MfaConfigurationStep } from './components/MfaConfigurationStep'; +import { MfaMethodsMenu } from './components/MfaMethodsMenu'; +import type { + MfaConfigurationMethodGroup, + MfaConfigurationProps, + MfaConfigurationStepData, +} from './types'; + +const getDisabledHelper = (reason: MfaMethodAvailabilityReasonValue) => { + switch (reason) { + case MfaMethodAvailabilityReason.Licensed: + return m.mfa_flow_method_business_required(); + case MfaMethodAvailabilityReason.SmtpNotConfigured: + return m.mfa_flow_method_smtp_required(); + case MfaMethodAvailabilityReason.OidcProviderMissing: + return m.mfa_flow_error_oidc_provider_missing(); + case MfaMethodAvailabilityReason.Available: + return undefined; + } +}; + +const methodLabels: Record = { + [MfaFlowMethod.MobileApprove]: m.mfa_flow_method_mobile_client(), + [MfaFlowMethod.Totp]: m.mfa_flow_method_authenticator_app(), + [MfaFlowMethod.OpenId]: m.mfa_flow_method_external_provider(), + [MfaFlowMethod.Email]: m.mfa_flow_method_email_code(), + [MfaFlowMethod.Biometric]: m.mfa_flow_method_biometric(), +}; + +export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { + const { data: methodData } = useQuery(getMfaMethodAvailabilityQueryOptions); + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + const methodAvailability = methodData?.methodAvailability; + const methods = methodData?.methods ?? []; + const businessLicenseCheck = + licenseInfo === undefined ? undefined : canUseBusinessFeature(licenseInfo); + const additionalStepRequiresBusiness = + steps.length > 0 && businessLicenseCheck?.result === false; + + const buildOption = useCallback( + (method: MfaFlowMethodValue, onClick: () => void) => { + const availability = methodAvailability?.find((item) => item.method === method); + return { + text: methodLabels[method], + onClick, + disabled: availability?.available !== true, + disabledHelper: availability ? getDisabledHelper(availability.reason) : undefined, + }; + }, + [methodAvailability], + ); + const buildMethodGroups = ( + methods: MfaFlowMethodValue[], + ): MfaConfigurationMethodGroup[] => { + const licensedMethods = methods.filter( + (method) => + methodAvailability?.find((item) => item.method === method)?.reason === + MfaMethodAvailabilityReason.Licensed, + ); + if (licensedMethods.length === 0) return [{ items: methods }]; + + const planMethods = methods.filter((method) => !licensedMethods.includes(method)); + return [ + ...(planMethods.length > 0 + ? [ + { + header: { text: m.mfa_flow_methods_available_in_plan() }, + items: planMethods, + }, + ] + : []), + { + header: { text: m.mfa_flow_methods_available_in_higher_plans() }, + items: licensedMethods, + }, + ]; + }; + + const addStepMenuOptions = buildMethodGroups(methods).map((group) => ({ + ...group, + items: group.items.map((method) => + buildOption(method, () => { + onChange([...steps, { id: crypto.randomUUID(), methods: [method] }]); + }), + ), + })); + const addStepButtonProps: Omit = { + type: 'button', + variant: 'outlined', + iconRight: 'arrow-small', + iconRightRotation: 'down', + text: m.mfa_flow_step_add(), + disabled: + methodAvailability === undefined || + (steps.length > 0 && businessLicenseCheck === undefined), + }; + + const deleteStep = useCallback( + (id: MfaConfigurationStepData['id']) => { + onChange(steps.filter((step) => step.id !== id)); + }, + [onChange, steps], + ); + + const addMethod = useCallback( + (stepId: MfaConfigurationStepData['id'], method: MfaFlowMethodValue) => { + onChange( + steps.map((step) => + step.id === stepId ? { ...step, methods: [...step.methods, method] } : step, + ), + ); + }, + [onChange, steps], + ); + + const deleteMethod = useCallback( + (stepId: MfaConfigurationStepData['id'], method: MfaFlowMethodValue) => { + const step = steps.find((item) => item.id === stepId); + if (!step) return; + + if (step.methods.length === 1) { + deleteStep(stepId); + return; + } + + onChange( + steps.map((item) => + item.id === stepId + ? { + ...item, + methods: item.methods.filter((itemMethod) => itemMethod !== method), + } + : item, + ), + ); + }, + [deleteStep, onChange, steps], + ); + + return ( +
+ + {steps.map((step, index) => ( + !step.methods.includes(method)), + )} + methodLabels={methodLabels} + onDeleteStep={deleteStep} + onAddMethod={addMethod} + onDeleteMethod={deleteMethod} + buildOption={buildOption} + /> + ))} + +
+ {additionalStepRequiresBusiness && businessLicenseCheck ? ( +
+ +
+ ); +}; diff --git a/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx new file mode 100644 index 0000000000..287e64de2e --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx @@ -0,0 +1,91 @@ +import { Reorder, useDragControls } from 'motion/react'; +import { m } from '../../../../paraglide/messages'; +import { Divider } from '../../../defguard-ui/components/Divider/Divider'; +import { Helper } from '../../../defguard-ui/components/Helper/Helper'; +import { Icon } from '../../../defguard-ui/components/Icon'; +import { ThemeSpacing, ThemeVariable } from '../../../defguard-ui/types'; +import type { MfaConfigurationStepProps } from '../types'; +import { MfaMethodsMenu } from './MfaMethodsMenu'; + +export const MfaConfigurationStep = ({ + step, + stepNumber, + methodGroups, + methodLabels, + onDeleteStep, + onAddMethod, + onDeleteMethod, + buildOption, +}: MfaConfigurationStepProps) => { + const dragControls = useDragControls(); + const addMethodMenuOptions = methodGroups.map((group) => ({ + ...group, + items: group.items.map((method) => + buildOption(method, () => onAddMethod(step.id, method)), + ), + })); + + return ( + +
+ +

{m.mfa_flow_step_title({ number: stepNumber })}

+ +
+ +
+ {step.methods.map((method) => ( +
+
+ +

{methodLabels[method]}

+
+ +
+
+
+ ))} +
+ {methodGroups.some((group) => group.items.length > 0) && ( +
+ + +

{m.mfa_flow_step_helper()}

+
+
+ )} +
+ ); +}; diff --git a/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx similarity index 84% rename from web/src/shared/components/LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx rename to web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx index 827db7e1fd..55ddc69a7c 100644 --- a/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx @@ -26,9 +26,9 @@ type PlainVariantProps = { label: string; }; -type LocationMfaMethodsMenuProps = ButtonVariantProps | PlainVariantProps; +type MfaMethodsMenuProps = ButtonVariantProps | PlainVariantProps; -export const LocationMfaMethodsMenu = (props: LocationMfaMethodsMenuProps) => { +export const MfaMethodsMenu = (props: MfaMethodsMenuProps) => { if (props.kind === 'button') { const { kind, options, ...buttonProps } = props; return ; @@ -37,6 +37,7 @@ export const LocationMfaMethodsMenu = (props: LocationMfaMethodsMenuProps) => { return ; }; +/** Renders the compact add-method menu trigger used inside a step. */ const PlainButton = ({ options, label, @@ -68,14 +69,18 @@ const PlainButton = ({ escapeKey: true, outsidePress: (event) => !(event.target as HTMLElement).closest('.menu'), }); - const { getFloatingProps, getReferenceProps } = useInteractions([click, dismiss]); return ( <> -
-

{label}

-
+ {isOpen && ( .factors { + > .methods { display: flex; flex-flow: column; - row-gap: var(--spacing-md); + row-gap: var(--spacing-sm); - > .factor { + > .method { align-items: center; border-radius: 12px; box-sizing: border-box; @@ -97,14 +95,7 @@ > .right { margin-left: auto; display: flex; - flex-flow: row nowrap; align-items: center; - column-gap: var(--spacing-md); - - > p { - font: var(--t-body-xxs-500); - color: var(--fg-disabled); - } } } } @@ -127,7 +118,7 @@ user-select: none; cursor: pointer; - > p { + > span { font: var(--t-body-sm-400); color: var(--fg-action); } diff --git a/web/src/shared/components/MfaConfiguration/types.ts b/web/src/shared/components/MfaConfiguration/types.ts new file mode 100644 index 0000000000..6a4a9d70a8 --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/types.ts @@ -0,0 +1,38 @@ +import type { MfaFlowMethodValue } from '../../api/types'; +import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; + +/** One ordered MFA step in the form state. */ +export interface MfaConfigurationStepData { + id: string | number; + methods: MfaFlowMethodValue[]; +} + +/** MFA methods grouped for display in an add-method menu. */ +export type MfaConfigurationMethodGroup = { + header?: { text: string }; + items: MfaFlowMethodValue[]; +}; + +/** Properties for an individual MFA step card. */ +export type MfaConfigurationStepProps = { + step: MfaConfigurationStepData; + stepNumber: number; + methodGroups: MfaConfigurationMethodGroup[]; + methodLabels: Record; + onDeleteStep: (id: MfaConfigurationStepData['id']) => void; + onAddMethod: ( + stepId: MfaConfigurationStepData['id'], + method: MfaFlowMethodValue, + ) => void; + onDeleteMethod: ( + stepId: MfaConfigurationStepData['id'], + method: MfaFlowMethodValue, + ) => void; + buildOption: (method: MfaFlowMethodValue, onClick: () => void) => MenuItemProps; +}; + +export type MfaConfigurationProps = { + steps: MfaConfigurationStepData[]; + onChange: (steps: MfaConfigurationStepData[]) => void; + error?: string; +}; diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 4e5f6b06c2..ef9c50de11 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -103,6 +103,13 @@ const navigationConfig: NavGroupProps[] = [ licenseTier: LicenseTier.Enterprise, licenseFeature: LicenseFeature.DevicePosture, }, + { + id: 'mfa', + icon: 'mobile-lock', + label: m.cmp_nav_item_mfa(), + licenseTier: LicenseTier.Business, + link: '/mfa', + }, ], }, { diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index b73914b9b3..cf0c28221d 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit b73914b9b32ded9a4e248def32a5b7fbe87708c3 +Subproject commit cf0c28221d43f7f722c50f0db59607008e7c1c71 diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 756988bc65..7cd6f25348 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -46,6 +46,21 @@ export const getLocationsDisplayQueryOptions = queryOptions({ select: (resp) => resourceDisplayMap(resp.data), }); +export const getMfaFlowsQueryOptions = queryOptions({ + queryFn: api.mfaFlow.list, + queryKey: ['mfa-flow'], + select: (response) => response.data, +}); + +export const getMfaMethodAvailabilityQueryOptions = queryOptions({ + queryFn: api.mfaFlow.methodAvailability, + queryKey: ['mfa-flow', 'method-availability'], + select: (response) => ({ + methodAvailability: response.data, + methods: response.data.map(({ method }) => method), + }), +}); + export const getEdgesQueryOptions = queryOptions({ queryFn: api.edge.getEdges, queryKey: ['edge'],