From 03aee2674dfabf771ddbfdaf4828aa4f717ef133 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 08:26:29 +0200 Subject: [PATCH 01/26] nav item --- web/messages/en/components.json | 1 + web/src/pages/MfaPage/MfaPage.tsx | 11 ++++++++++ web/src/pages/MfaPage/style.scss | 0 web/src/routeTree.gen.ts | 22 +++++++++++++++++++ .../routes/_authorized/_default/mfa/index.tsx | 6 +++++ web/src/shared/api/types.ts | 1 + .../components/Navigation/Navigation.tsx | 8 +++++++ 7 files changed, 49 insertions(+) create mode 100644 web/src/pages/MfaPage/MfaPage.tsx create mode 100644 web/src/pages/MfaPage/style.scss create mode 100644 web/src/routes/_authorized/_default/mfa/index.tsx 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/src/pages/MfaPage/MfaPage.tsx b/web/src/pages/MfaPage/MfaPage.tsx new file mode 100644 index 0000000000..172cda22b3 --- /dev/null +++ b/web/src/pages/MfaPage/MfaPage.tsx @@ -0,0 +1,11 @@ +import { Page } from '../../shared/components/Page/Page'; +import { m } from '../../paraglide/messages'; +import './style.scss'; + +export const MfaPage = () => { + return ( + +
TODO
+
+ ); +} diff --git a/web/src/pages/MfaPage/style.scss b/web/src/pages/MfaPage/style.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index ce35177230..8fc99f675e 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -50,6 +50,7 @@ import { Route as AuthorizedDefaultEdgesRouteImport } from './routes/_authorized import { Route as AuthorizedDefaultActivityRouteImport } from './routes/_authorized/_default/activity' import { Route as AuthorizedDefaultVpnOverviewIndexRouteImport } from './routes/_authorized/_default/vpn-overview/index' import { Route as AuthorizedDefaultSettingsIndexRouteImport } from './routes/_authorized/_default/settings/index' +import { Route as AuthorizedDefaultMfaIndexRouteImport } from './routes/_authorized/_default/mfa/index' import { Route as AuthorizedDefaultLocationsIndexRouteImport } from './routes/_authorized/_default/locations/index' import { Route as AuthorizedDefaultVpnOverviewLocationIdRouteImport } from './routes/_authorized/_default/vpn-overview/$locationId' import { Route as AuthorizedDefaultUserUsernameRouteImport } from './routes/_authorized/_default/user/$username' @@ -294,6 +295,12 @@ const AuthorizedDefaultSettingsIndexRoute = path: '/settings/', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultMfaIndexRoute = + AuthorizedDefaultMfaIndexRouteImport.update({ + id: '/mfa/', + path: '/mfa/', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultLocationsIndexRoute = AuthorizedDefaultLocationsIndexRouteImport.update({ id: '/locations/', @@ -511,6 +518,7 @@ export interface FileRoutesByFullPath { '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/locations/': typeof AuthorizedDefaultLocationsIndexRoute + '/mfa/': typeof AuthorizedDefaultMfaIndexRoute '/settings/': typeof AuthorizedDefaultSettingsIndexRoute '/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute @@ -577,6 +585,7 @@ export interface FileRoutesByTo { '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/locations': typeof AuthorizedDefaultLocationsIndexRoute + '/mfa': typeof AuthorizedDefaultMfaIndexRoute '/settings': typeof AuthorizedDefaultSettingsIndexRoute '/vpn-overview': typeof AuthorizedDefaultVpnOverviewIndexRoute '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute @@ -647,6 +656,7 @@ export interface FileRoutesById { '/_authorized/_default/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/_authorized/_default/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/_authorized/_default/locations/': typeof AuthorizedDefaultLocationsIndexRoute + '/_authorized/_default/mfa/': typeof AuthorizedDefaultMfaIndexRoute '/_authorized/_default/settings/': typeof AuthorizedDefaultSettingsIndexRoute '/_authorized/_default/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute '/_authorized/_default/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute @@ -716,6 +726,7 @@ export interface FileRouteTypes { | '/user/$username' | '/vpn-overview/$locationId' | '/locations/' + | '/mfa/' | '/settings/' | '/vpn-overview/' | '/edge/$edgeId/edit' @@ -782,6 +793,7 @@ export interface FileRouteTypes { | '/user/$username' | '/vpn-overview/$locationId' | '/locations' + | '/mfa' | '/settings' | '/vpn-overview' | '/edge/$edgeId/edit' @@ -851,6 +863,7 @@ export interface FileRouteTypes { | '/_authorized/_default/user/$username' | '/_authorized/_default/vpn-overview/$locationId' | '/_authorized/_default/locations/' + | '/_authorized/_default/mfa/' | '/_authorized/_default/settings/' | '/_authorized/_default/vpn-overview/' | '/_authorized/_default/edge/$edgeId/edit' @@ -1164,6 +1177,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultSettingsIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/mfa/': { + id: '/_authorized/_default/mfa/' + path: '/mfa' + fullPath: '/mfa/' + preLoaderRoute: typeof AuthorizedDefaultMfaIndexRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/locations/': { id: '/_authorized/_default/locations/' path: '/locations' @@ -1396,6 +1416,7 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultUserUsernameRoute: typeof AuthorizedDefaultUserUsernameRoute AuthorizedDefaultVpnOverviewLocationIdRoute: typeof AuthorizedDefaultVpnOverviewLocationIdRoute AuthorizedDefaultLocationsIndexRoute: typeof AuthorizedDefaultLocationsIndexRoute + AuthorizedDefaultMfaIndexRoute: typeof AuthorizedDefaultMfaIndexRoute AuthorizedDefaultSettingsIndexRoute: typeof AuthorizedDefaultSettingsIndexRoute AuthorizedDefaultVpnOverviewIndexRoute: typeof AuthorizedDefaultVpnOverviewIndexRoute AuthorizedDefaultEdgeEdgeIdEditRoute: typeof AuthorizedDefaultEdgeEdgeIdEditRoute @@ -1442,6 +1463,7 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultVpnOverviewLocationIdRoute: AuthorizedDefaultVpnOverviewLocationIdRoute, AuthorizedDefaultLocationsIndexRoute: AuthorizedDefaultLocationsIndexRoute, + AuthorizedDefaultMfaIndexRoute: AuthorizedDefaultMfaIndexRoute, AuthorizedDefaultSettingsIndexRoute: AuthorizedDefaultSettingsIndexRoute, AuthorizedDefaultVpnOverviewIndexRoute: AuthorizedDefaultVpnOverviewIndexRoute, diff --git a/web/src/routes/_authorized/_default/mfa/index.tsx b/web/src/routes/_authorized/_default/mfa/index.tsx new file mode 100644 index 0000000000..bbcf8041ab --- /dev/null +++ b/web/src/routes/_authorized/_default/mfa/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { MfaPage } from '../../../../pages/MfaPage/MfaPage'; + +export const Route = createFileRoute('/_authorized/_default/mfa/')({ + component: MfaPage, +}); diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index eb21ddf45b..9c65b3cc95 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -496,6 +496,7 @@ export const LicenseFeature = { DevicePosture: 'DevicePosture', AclAllowedIps: 'AclAllowedIps', ComponentHa: 'ComponentHa', + MfaFlow: 'MfaFlow', } as const; export type LicenseFeatureValue = (typeof LicenseFeature)[keyof typeof LicenseFeature]; diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index 4e5f6b06c2..4c10be8c70 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -103,6 +103,14 @@ const navigationConfig: NavGroupProps[] = [ licenseTier: LicenseTier.Enterprise, licenseFeature: LicenseFeature.DevicePosture, }, + { + id: 'mfa', + icon: 'connected-devices', + label: m.cmp_nav_item_mfa(), + link: '/mfa', + licenseTier: LicenseTier.Business, + licenseFeature: LicenseFeature.MfaFlow, + }, ], }, { From bf5f3b0539a13671fc9309e84003d9a1b9a0b839 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 08:50:41 +0200 Subject: [PATCH 02/26] empty state --- web/messages/en/acl.json | 2 +- web/messages/en/postures.json | 3 ++ web/src/pages/MfaPage/MfaPage.tsx | 28 +++++++++++++++---- web/src/pages/MfaPage/style.scss | 0 .../routes/_authorized/_default/mfa/index.tsx | 2 +- 5 files changed, 28 insertions(+), 7 deletions(-) delete mode 100644 web/src/pages/MfaPage/style.scss 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/postures.json b/web/messages/en/postures.json index 248d02cd7c..2c85a923e3 100644 --- a/web/messages/en/postures.json +++ b/web/messages/en/postures.json @@ -3,6 +3,9 @@ "posture_checks_empty_title": "No posture checks added yet.", "posture_checks_empty_subtitle": "Add your first posture check to enhance access security.", "posture_checks_button_add": "Add new posture check", + "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", "posture_checks_row_menu_assign_locations": "Assign to locations", "posture_checks_wizard_title": "Add posture check", "posture_checks_wizard_subtitle": "To activate localization, make sure at least one Edge component is connected.", diff --git a/web/src/pages/MfaPage/MfaPage.tsx b/web/src/pages/MfaPage/MfaPage.tsx index 172cda22b3..acc96be3c2 100644 --- a/web/src/pages/MfaPage/MfaPage.tsx +++ b/web/src/pages/MfaPage/MfaPage.tsx @@ -1,11 +1,29 @@ -import { Page } from '../../shared/components/Page/Page'; import { m } from '../../paraglide/messages'; -import './style.scss'; +import { Page } from '../../shared/components/Page/Page'; +import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; +import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../shared/defguard-ui/types'; +import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; export const MfaPage = () => { + const addMfaFlowButtonProps: ButtonProps = { + text: m.mfa_flows_button_add(), + iconLeft: 'plus', + testId: 'add-mfa-flow', + }; + return ( - -
TODO
+ + + + + ); -} +}; diff --git a/web/src/pages/MfaPage/style.scss b/web/src/pages/MfaPage/style.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/src/routes/_authorized/_default/mfa/index.tsx b/web/src/routes/_authorized/_default/mfa/index.tsx index bbcf8041ab..74cb9ca117 100644 --- a/web/src/routes/_authorized/_default/mfa/index.tsx +++ b/web/src/routes/_authorized/_default/mfa/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router'; import { MfaPage } from '../../../../pages/MfaPage/MfaPage'; export const Route = createFileRoute('/_authorized/_default/mfa/')({ From 4386a7e6c52c20ef2c472ddc585d92c01c412df9 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 09:22:49 +0200 Subject: [PATCH 03/26] editor - navigation and basic form --- web/messages/en/postures.json | 8 ++ web/src/pages/MfaPage/MfaFlowPage.tsx | 77 +++++++++++++++++++ web/src/pages/MfaPage/MfaPage.tsx | 5 ++ web/src/routeTree.gen.ts | 22 ++++++ .../_authorized/_default/mfa/add-flow.tsx | 6 ++ 5 files changed, 118 insertions(+) create mode 100644 web/src/pages/MfaPage/MfaFlowPage.tsx create mode 100644 web/src/routes/_authorized/_default/mfa/add-flow.tsx diff --git a/web/messages/en/postures.json b/web/messages/en/postures.json index 2c85a923e3..4c8d38b8a1 100644 --- a/web/messages/en/postures.json +++ b/web/messages/en/postures.json @@ -6,6 +6,14 @@ "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_flow_breadcrumb_create": "Create new MFA flow", + "mfa_flow_form_title_create": "Create new 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_form_action_create": "Create MFA flow", "posture_checks_row_menu_assign_locations": "Assign to locations", "posture_checks_wizard_title": "Add posture check", "posture_checks_wizard_subtitle": "To activate localization, make sure at least one Edge component is connected.", diff --git a/web/src/pages/MfaPage/MfaFlowPage.tsx b/web/src/pages/MfaPage/MfaFlowPage.tsx new file mode 100644 index 0000000000..2546331dc6 --- /dev/null +++ b/web/src/pages/MfaPage/MfaFlowPage.tsx @@ -0,0 +1,77 @@ +import { Link, useNavigate } from '@tanstack/react-router'; +import z from 'zod'; +import { m } from '../../paraglide/messages'; +import { Controls } from '../../shared/components/Controls/Controls'; +import { DescriptionBlock } from '../../shared/components/DescriptionBlock/DescriptionBlock'; +import { EditPage } from '../../shared/components/EditPage/EditPage'; +import { Button } from '../../shared/defguard-ui/components/Button/Button'; +import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; +import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../shared/defguard-ui/types'; +import { useAppForm } from '../../shared/form'; +import { formChangeLogic } from '../../shared/formLogic'; + +const formSchema = z.object({ + name: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), +}); + +/** Displays the create and, later, edit form for an MFA flow. */ +export const MfaFlowPage = () => { + const navigate = useNavigate(); + const form = useAppForm({ + defaultValues: { + name: '', + }, + validationLogic: formChangeLogic, + validators: { + onChange: formSchema, + onSubmit: formSchema, + }, + }); + + return ( + + {m.cmp_nav_item_mfa()} + , + + {m.mfa_flow_breadcrumb_create()} + , + ]} + onBack={() => navigate({ to: '/mfa' })} + headerProps={{ + icon: 'activity-notes', + title: m.mfa_flow_form_title_create(), + subtitle: m.mfa_flow_form_subtitle(), + }} + > + + + + + + {(field) => } + + + + + + + +

{m.mfa_flow_form_methods_description()}

+
+
+ + +
+
+
+
+
+ ); +}; diff --git a/web/src/pages/MfaPage/MfaPage.tsx b/web/src/pages/MfaPage/MfaPage.tsx index acc96be3c2..8e097dfa30 100644 --- a/web/src/pages/MfaPage/MfaPage.tsx +++ b/web/src/pages/MfaPage/MfaPage.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from '@tanstack/react-router'; import { m } from '../../paraglide/messages'; import { Page } from '../../shared/components/Page/Page'; import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; @@ -7,10 +8,14 @@ import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; export const MfaPage = () => { + const navigate = useNavigate(); const addMfaFlowButtonProps: ButtonProps = { text: m.mfa_flows_button_add(), iconLeft: 'plus', testId: 'add-mfa-flow', + onClick: () => { + void navigate({ to: '/mfa/add-flow' }); + }, }; return ( diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 8fc99f675e..0318c853c4 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -63,6 +63,7 @@ import { Route as AuthorizedDefaultSettingsEditOpenidRouteImport } from './route import { Route as AuthorizedDefaultSettingsClientRouteImport } from './routes/_authorized/_default/settings/client' import { Route as AuthorizedDefaultSettingsCertsRouteImport } from './routes/_authorized/_default/settings/certs' import { Route as AuthorizedDefaultSettingsCaRouteImport } from './routes/_authorized/_default/settings/ca' +import { Route as AuthorizedDefaultMfaAddFlowRouteImport } from './routes/_authorized/_default/mfa/add-flow' import { Route as AuthorizedDefaultAclRulesRouteImport } from './routes/_authorized/_default/acl/rules' import { Route as AuthorizedDefaultAclPostureChecksRouteImport } from './routes/_authorized/_default/acl/posture-checks' import { Route as AuthorizedDefaultAclEditRuleRouteImport } from './routes/_authorized/_default/acl/edit-rule' @@ -373,6 +374,12 @@ const AuthorizedDefaultSettingsCaRoute = path: '/settings/ca', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultMfaAddFlowRoute = + AuthorizedDefaultMfaAddFlowRouteImport.update({ + id: '/mfa/add-flow', + path: '/mfa/add-flow', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultAclRulesRoute = AuthorizedDefaultAclRulesRouteImport.update({ id: '/acl/rules', @@ -506,6 +513,7 @@ export interface FileRoutesByFullPath { '/acl/edit-rule': typeof AuthorizedDefaultAclEditRuleRoute '/acl/posture-checks': typeof AuthorizedDefaultAclPostureChecksRouteWithChildren '/acl/rules': typeof AuthorizedDefaultAclRulesRoute + '/mfa/add-flow': typeof AuthorizedDefaultMfaAddFlowRoute '/settings/ca': typeof AuthorizedDefaultSettingsCaRoute '/settings/certs': typeof AuthorizedDefaultSettingsCertsRoute '/settings/client': typeof AuthorizedDefaultSettingsClientRoute @@ -573,6 +581,7 @@ export interface FileRoutesByTo { '/acl/edit-rule': typeof AuthorizedDefaultAclEditRuleRoute '/acl/posture-checks': typeof AuthorizedDefaultAclPostureChecksRouteWithChildren '/acl/rules': typeof AuthorizedDefaultAclRulesRoute + '/mfa/add-flow': typeof AuthorizedDefaultMfaAddFlowRoute '/settings/ca': typeof AuthorizedDefaultSettingsCaRoute '/settings/certs': typeof AuthorizedDefaultSettingsCertsRoute '/settings/client': typeof AuthorizedDefaultSettingsClientRoute @@ -644,6 +653,7 @@ export interface FileRoutesById { '/_authorized/_default/acl/edit-rule': typeof AuthorizedDefaultAclEditRuleRoute '/_authorized/_default/acl/posture-checks': typeof AuthorizedDefaultAclPostureChecksRouteWithChildren '/_authorized/_default/acl/rules': typeof AuthorizedDefaultAclRulesRoute + '/_authorized/_default/mfa/add-flow': typeof AuthorizedDefaultMfaAddFlowRoute '/_authorized/_default/settings/ca': typeof AuthorizedDefaultSettingsCaRoute '/_authorized/_default/settings/certs': typeof AuthorizedDefaultSettingsCertsRoute '/_authorized/_default/settings/client': typeof AuthorizedDefaultSettingsClientRoute @@ -714,6 +724,7 @@ export interface FileRouteTypes { | '/acl/edit-rule' | '/acl/posture-checks' | '/acl/rules' + | '/mfa/add-flow' | '/settings/ca' | '/settings/certs' | '/settings/client' @@ -781,6 +792,7 @@ export interface FileRouteTypes { | '/acl/edit-rule' | '/acl/posture-checks' | '/acl/rules' + | '/mfa/add-flow' | '/settings/ca' | '/settings/certs' | '/settings/client' @@ -851,6 +863,7 @@ export interface FileRouteTypes { | '/_authorized/_default/acl/edit-rule' | '/_authorized/_default/acl/posture-checks' | '/_authorized/_default/acl/rules' + | '/_authorized/_default/mfa/add-flow' | '/_authorized/_default/settings/ca' | '/_authorized/_default/settings/certs' | '/_authorized/_default/settings/client' @@ -1268,6 +1281,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultSettingsCaRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/mfa/add-flow': { + id: '/_authorized/_default/mfa/add-flow' + path: '/mfa/add-flow' + fullPath: '/mfa/add-flow' + preLoaderRoute: typeof AuthorizedDefaultMfaAddFlowRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/acl/rules': { id: '/_authorized/_default/acl/rules' path: '/acl/rules' @@ -1404,6 +1424,7 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultAclEditRuleRoute: typeof AuthorizedDefaultAclEditRuleRoute AuthorizedDefaultAclPostureChecksRoute: typeof AuthorizedDefaultAclPostureChecksRouteWithChildren AuthorizedDefaultAclRulesRoute: typeof AuthorizedDefaultAclRulesRoute + AuthorizedDefaultMfaAddFlowRoute: typeof AuthorizedDefaultMfaAddFlowRoute AuthorizedDefaultSettingsCaRoute: typeof AuthorizedDefaultSettingsCaRoute AuthorizedDefaultSettingsCertsRoute: typeof AuthorizedDefaultSettingsCertsRoute AuthorizedDefaultSettingsClientRoute: typeof AuthorizedDefaultSettingsClientRoute @@ -1447,6 +1468,7 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultAclPostureChecksRoute: AuthorizedDefaultAclPostureChecksRouteWithChildren, AuthorizedDefaultAclRulesRoute: AuthorizedDefaultAclRulesRoute, + AuthorizedDefaultMfaAddFlowRoute: AuthorizedDefaultMfaAddFlowRoute, AuthorizedDefaultSettingsCaRoute: AuthorizedDefaultSettingsCaRoute, AuthorizedDefaultSettingsCertsRoute: AuthorizedDefaultSettingsCertsRoute, AuthorizedDefaultSettingsClientRoute: AuthorizedDefaultSettingsClientRoute, diff --git a/web/src/routes/_authorized/_default/mfa/add-flow.tsx b/web/src/routes/_authorized/_default/mfa/add-flow.tsx new file mode 100644 index 0000000000..d31fd0a993 --- /dev/null +++ b/web/src/routes/_authorized/_default/mfa/add-flow.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { MfaFlowPage } from '../../../../pages/MfaPage/MfaFlowPage'; + +export const Route = createFileRoute('/_authorized/_default/mfa/add-flow')({ + component: MfaFlowPage, +}); From eee0c691437c9778b1eaea86943d2c6e4ee2f8b1 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 09:57:56 +0200 Subject: [PATCH 04/26] rename MfaFlowPage -> MfaFormPage --- web/src/pages/MfaPage/{MfaFlowPage.tsx => MfaFormPage.tsx} | 3 +-- web/src/routes/_authorized/_default/mfa/add-flow.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) rename web/src/pages/MfaPage/{MfaFlowPage.tsx => MfaFormPage.tsx} (96%) diff --git a/web/src/pages/MfaPage/MfaFlowPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx similarity index 96% rename from web/src/pages/MfaPage/MfaFlowPage.tsx rename to web/src/pages/MfaPage/MfaFormPage.tsx index 2546331dc6..b25259ba32 100644 --- a/web/src/pages/MfaPage/MfaFlowPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -16,8 +16,7 @@ const formSchema = z.object({ name: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), }); -/** Displays the create and, later, edit form for an MFA flow. */ -export const MfaFlowPage = () => { +export const MfaFormPage = () => { const navigate = useNavigate(); const form = useAppForm({ defaultValues: { diff --git a/web/src/routes/_authorized/_default/mfa/add-flow.tsx b/web/src/routes/_authorized/_default/mfa/add-flow.tsx index d31fd0a993..fcd44aa2fd 100644 --- a/web/src/routes/_authorized/_default/mfa/add-flow.tsx +++ b/web/src/routes/_authorized/_default/mfa/add-flow.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router'; -import { MfaFlowPage } from '../../../../pages/MfaPage/MfaFlowPage'; +import { MfaFormPage } from '../../../../pages/MfaPage/MfaFormPage'; export const Route = createFileRoute('/_authorized/_default/mfa/add-flow')({ - component: MfaFlowPage, + component: MfaFormPage, }); From d95ecb7a34fd4d6fb1909b90b24b87f8a9825962 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 10:30:32 +0200 Subject: [PATCH 05/26] add the steps editor --- web/messages/en/mfa_flow.json | 26 ++ web/messages/en/postures.json | 11 - web/project.inlang/settings.json | 3 +- web/src/pages/MfaPage/MfaFormPage.tsx | 47 +++- .../tabs/PlaygroundMfa/PlaygroundMfa.tsx | 12 +- .../LocationMfaConfiguration.tsx | 238 ------------------ .../LocationMfaConfigurationStep.tsx | 85 ------- .../LocationMfaConfiguration/types.ts | 50 ---- .../MfaConfiguration/MfaConfiguration.tsx | 114 +++++++++ .../components/MfaConfigurationStep.tsx | 93 +++++++ .../components/MfaMethodsMenu.tsx} | 20 +- .../style.scss | 29 +-- .../components/MfaConfiguration/types.ts | 39 +++ 13 files changed, 339 insertions(+), 428 deletions(-) create mode 100644 web/messages/en/mfa_flow.json delete mode 100644 web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx delete mode 100644 web/src/shared/components/LocationMfaConfiguration/components/LocationMfaConfigurationStep.tsx delete mode 100644 web/src/shared/components/LocationMfaConfiguration/types.ts create mode 100644 web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx create mode 100644 web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx rename web/src/shared/components/{LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx => MfaConfiguration/components/MfaMethodsMenu.tsx} (79%) rename web/src/shared/components/{LocationMfaConfiguration => MfaConfiguration}/style.scss (83%) create mode 100644 web/src/shared/components/MfaConfiguration/types.ts diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json new file mode 100644 index 0000000000..fe41e2b792 --- /dev/null +++ b/web/messages/en/mfa_flow.json @@ -0,0 +1,26 @@ +{ + "$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_flow_breadcrumb_create": "Create new MFA flow", + "mfa_flow_form_title_create": "Create new 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_form_action_create": "Create MFA flow" +} diff --git a/web/messages/en/postures.json b/web/messages/en/postures.json index 4c8d38b8a1..248d02cd7c 100644 --- a/web/messages/en/postures.json +++ b/web/messages/en/postures.json @@ -3,17 +3,6 @@ "posture_checks_empty_title": "No posture checks added yet.", "posture_checks_empty_subtitle": "Add your first posture check to enhance access security.", "posture_checks_button_add": "Add new posture check", - "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_flow_breadcrumb_create": "Create new MFA flow", - "mfa_flow_form_title_create": "Create new 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_form_action_create": "Create MFA flow", "posture_checks_row_menu_assign_locations": "Assign to locations", "posture_checks_wizard_title": "Add posture check", "posture_checks_wizard_subtitle": "To activate localization, make sure at least one Edge component is connected.", 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/MfaFormPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx index b25259ba32..b6bf96a2f6 100644 --- a/web/src/pages/MfaPage/MfaFormPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -2,18 +2,30 @@ import { Link, useNavigate } from '@tanstack/react-router'; import z from 'zod'; import { m } from '../../paraglide/messages'; import { Controls } from '../../shared/components/Controls/Controls'; -import { DescriptionBlock } from '../../shared/components/DescriptionBlock/DescriptionBlock'; import { EditPage } from '../../shared/components/EditPage/EditPage'; +import { MfaConfiguration } from '../../shared/components/MfaConfiguration/MfaConfiguration'; +import { + type MfaConfigurationStepData, + MfaMethod, +} from '../../shared/components/MfaConfiguration/types'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; -import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { MarkedSectionHeader } from '../../shared/defguard-ui/components/MarkedSectionHeader/MarkedSectionHeader'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { useAppForm } from '../../shared/form'; import { formChangeLogic } from '../../shared/formLogic'; const formSchema = z.object({ name: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), + steps: z + .array( + z.object({ + id: z.union([z.string(), z.number()]), + methods: z.array(z.enum(MfaMethod)).min(1), + }), + ) + .min(1, m.mfa_flow_step_required()), }); export const MfaFormPage = () => { @@ -21,6 +33,7 @@ export const MfaFormPage = () => { const form = useAppForm({ defaultValues: { name: '', + steps: [] as MfaConfigurationStepData[], }, validationLogic: formChangeLogic, validators: { @@ -49,19 +62,29 @@ export const MfaFormPage = () => { > - - - - {(field) => } - - + + + {(field) => } + - - -

{m.mfa_flow_form_methods_description()}

-
+ + + {(field) => { + const error = field.state.meta.errors[0]; + return ( + + ); + }} +
diff --git a/web/src/pages/PlaygroundPage/tabs/PlaygroundMfa/PlaygroundMfa.tsx b/web/src/pages/PlaygroundPage/tabs/PlaygroundMfa/PlaygroundMfa.tsx index 645e05d88c..b41ef7aefd 100644 --- a/web/src/pages/PlaygroundPage/tabs/PlaygroundMfa/PlaygroundMfa.tsx +++ b/web/src/pages/PlaygroundPage/tabs/PlaygroundMfa/PlaygroundMfa.tsx @@ -1,18 +1,18 @@ import { useState } from 'react'; import { Card } from '../../../../shared/components/Card/Card'; -import { LocationMfaConfiguration } from '../../../../shared/components/LocationMfaConfiguration/LocationMfaConfiguration'; -import type { LocationMfaConfigurationStepData } from '../../../../shared/components/LocationMfaConfiguration/types'; +import { MfaConfiguration } from '../../../../shared/components/MfaConfiguration/MfaConfiguration'; +import type { MfaConfigurationStepData } from '../../../../shared/components/MfaConfiguration/types'; export const PlaygroundMfa = () => { - const [steps, setSteps] = useState([ - { id: 'step-1', order: 1, factors: ['email', 'totp'] }, - { id: 'step-2', order: 2, factors: ['biometry'] }, + const [steps, setSteps] = useState([ + { id: 'step-1', methods: ['email', 'totp'] }, + { id: 'step-2', methods: ['mobileapprove'] }, ]); return (
- +
); diff --git a/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx b/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx deleted file mode 100644 index d2b270171a..0000000000 --- a/web/src/shared/components/LocationMfaConfiguration/LocationMfaConfiguration.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import './style.scss'; -import { useQuery } from '@tanstack/react-query'; -import { Reorder } from 'motion/react'; -import { sort } from 'radashi'; -import { useCallback, useMemo, useState } from 'react'; -import { m } from '../../../paraglide/messages'; -import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; -import { useApp } from '../../hooks/useApp'; -import { getLicenseInfoQueryOptions } from '../../query'; -import { canUseEnterpriseFeature } from '../../utils/license'; -import { LocationMfaConfigurationStep } from './components/LocationMfaConfigurationStep'; -import { LocationMfaMethodsMenu } from './components/LocationMfaMethodsMenu'; -import type { - LocationMfaConfigurationProps, - LocationMfaConfigurationStepData, - LocationMfaMethodValue, -} from './types'; -import { LocationMfaMethod, locationMfaMethodLabels } from './types'; - -type InternalStepsMap = Map; - -const mapToSortedArray = (map: InternalStepsMap): LocationMfaConfigurationStepData[] => - sort(Array.from(map.values()), (s) => s.order); - -const deleteAndReorder = ( - map: InternalStepsMap, - deleted: LocationMfaConfigurationStepData, -): void => { - map.delete(deleted.id); - for (const [key, s] of map) { - if (s.order > deleted.order) map.set(key, { ...s, order: s.order - 1 }); - } -}; - -export const LocationMfaConfiguration = ({ - onChange, - steps, - error, -}: LocationMfaConfigurationProps) => { - const smtpAvailable = useApp((s) => s.appInfo.smtp_enabled); - - const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); - - const isEnterprise = useMemo( - () => canUseEnterpriseFeature(licenseInfo ?? null), - [licenseInfo], - ); - - const [internalSteps, setInternalSteps] = useState( - () => new Map(steps.map((step) => [step.id, step])), - ); - - const onReorder = useCallback( - (newSteps: LocationMfaConfigurationStepData[]) => { - const reordered = newSteps.map((s, i) => ({ ...s, order: i + 1 })); - const next: InternalStepsMap = new Map(reordered.map((s) => [s.id, s])); - setInternalSteps(next); - onChange(reordered); - }, - [onChange], - ); - - const onDeleteStep = useCallback( - (id: string) => { - const step = internalSteps.get(id); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - deleteAndReorder(next, step); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onAddStep = useCallback( - (initialFactor: LocationMfaMethodValue) => { - const id = crypto.randomUUID(); - const next: InternalStepsMap = new Map(internalSteps); - next.set(id, { id, order: next.size + 1, factors: [initialFactor] }); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onAddFactor = useCallback( - (stepId: string, factor: LocationMfaMethodValue) => { - const step = internalSteps.get(stepId); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - next.set(stepId, { ...step, factors: [...step.factors, factor] }); - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const onDeleteFactor = useCallback( - (stepId: string, factor: LocationMfaMethodValue) => { - const step = internalSteps.get(stepId); - if (!step) return; - - const next: InternalStepsMap = new Map(internalSteps); - const remaining = step.factors.filter((f) => f !== factor); - if (remaining.length === 0) { - deleteAndReorder(next, step); - } else { - next.set(stepId, { ...step, factors: remaining }); - } - - setInternalSteps(next); - onChange(mapToSortedArray(next)); - }, - [internalSteps, onChange], - ); - - const sortedSteps = useMemo(() => mapToSortedArray(internalSteps), [internalSteps]); - - const usedFactors = useMemo( - () => new Set(sortedSteps.flatMap((s) => s.factors)), - [sortedSteps], - ); - - const availableMethods = useMemo( - () => - [ - LocationMfaMethod.Email, - LocationMfaMethod.MobileConfirm, - LocationMfaMethod.Totp, - LocationMfaMethod.Biometry, - LocationMfaMethod.Tpm, - LocationMfaMethod.OpenId, - // HardwareKey ('hardware_key') is not yet supported in the UI - ].filter((method) => !usedFactors.has(method)), - [usedFactors], - ); - - const buildOption = useCallback( - (method: LocationMfaMethodValue, onClick: () => void) => { - const isEmailWithoutSmtp = method === LocationMfaMethod.Email && !smtpAvailable; - let disabledHelper: string | undefined; - let disabled = false; - - if (isEmailWithoutSmtp) { - disabledHelper = m.cmp_location_mfa_smtp_disabled(); - disabled = true; - } - - if (!isEnterprise) { - if (method === LocationMfaMethod.Tpm || method === LocationMfaMethod.OpenId) { - disabled = true; - disabledHelper = m.cmp_location_mfa_enterprise_required(); - } - } - return { - text: locationMfaMethodLabels[method], - disabled, - disabledHelper, - onClick, - }; - }, - [isEnterprise, smtpAvailable], - ); - - const methodGroups = useMemo(() => { - if (isEnterprise) { - return [{ header: undefined, items: availableMethods }]; - } - - const planMethods = [ - LocationMfaMethod.Email, - LocationMfaMethod.MobileConfirm, - LocationMfaMethod.Totp, - LocationMfaMethod.Biometry, - ].filter((m) => availableMethods.includes(m)); - - const higherPlanMethods = [LocationMfaMethod.Tpm, LocationMfaMethod.OpenId].filter( - (m) => availableMethods.includes(m), - ); - - return [ - { header: { text: 'Available in your plan' }, items: planMethods }, - { header: { text: 'Available in higher plans' }, items: higherPlanMethods }, - ]; - }, [availableMethods, isEnterprise]); - - const addStepMenuOptions = useMemo( - () => - methodGroups.map((group) => ({ - ...group, - items: group.items.map((method) => buildOption(method, () => onAddStep(method))), - })), - [methodGroups, buildOption, onAddStep], - ); - - return ( -
-
- - {sortedSteps.map((step) => ( - - ))} - -
- {availableMethods.length > 0 && ( -
- -
- )} - -
- ); -}; diff --git a/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaConfigurationStep.tsx b/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaConfigurationStep.tsx deleted file mode 100644 index a87381e95b..0000000000 --- a/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaConfigurationStep.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Reorder, useDragControls } from 'motion/react'; -import { useMemo } from '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 { LocationMfaConfigurationStepProps } from '../types'; -import { locationMfaMethodLabels } from '../types'; -import { LocationMfaMethodsMenu } from './LocationMfaMethodsMenu'; - -export const LocationMfaConfigurationStep = ({ - step, - methodGroups, - onDeleteStep, - onAddFactor, - onDeleteFactor, - buildOption, -}: LocationMfaConfigurationStepProps) => { - const dragControls = useDragControls(); - - const addFactorMenuOptions = useMemo( - () => - methodGroups.map((group) => ({ - ...group, - items: group.items.map((factor) => - buildOption(factor, () => onAddFactor(step.id, factor)), - ), - })), - [methodGroups, buildOption, onAddFactor, step.id], - ); - - 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..75927452ad --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -0,0 +1,114 @@ +import './style.scss'; +import { Reorder } from 'motion/react'; +import { m } from '../../../paraglide/messages'; +import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; +import { MfaConfigurationStep } from './components/MfaConfigurationStep'; +import { MfaMethodsMenu } from './components/MfaMethodsMenu'; +import type { + MfaConfigurationProps, + MfaConfigurationStepData, + MfaMethodValue, +} from './types'; +import { MfaMethod } from './types'; + +const availableMethods: MfaMethodValue[] = [ + MfaMethod.MobileClient, + MfaMethod.Totp, + MfaMethod.OpenId, + MfaMethod.Email, +]; + +/** Configures ordered MFA steps and the methods accepted by each step. */ +export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { + const methodLabels: Record = { + [MfaMethod.MobileClient]: m.mfa_flow_method_mobile_client(), + [MfaMethod.Totp]: m.mfa_flow_method_authenticator_app(), + [MfaMethod.OpenId]: m.mfa_flow_method_external_provider(), + [MfaMethod.Email]: m.mfa_flow_method_email_code(), + }; + /** Builds one selectable MFA method menu item. */ + const buildOption = (method: MfaMethodValue, onClick: () => void) => ({ + text: methodLabels[method], + onClick, + }); + const addStepMenuOptions = [ + { + items: availableMethods.map((method) => + buildOption(method, () => { + onChange([...steps, { id: crypto.randomUUID(), methods: [method] }]); + }), + ), + }, + ]; + + /** Removes a complete MFA step. */ + const deleteStep = (id: MfaConfigurationStepData['id']) => { + onChange(steps.filter((step) => step.id !== id)); + }; + /** Adds a method to an existing MFA step. */ + const addMethod = (stepId: MfaConfigurationStepData['id'], method: MfaMethodValue) => { + onChange( + steps.map((step) => + step.id === stepId ? { ...step, methods: [...step.methods, method] } : step, + ), + ); + }; + /** Removes a method and drops the step when it becomes empty. */ + const deleteMethod = ( + stepId: MfaConfigurationStepData['id'], + method: MfaMethodValue, + ) => { + 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, + ), + ); + }; + + return ( +
+ + {steps.map((step, index) => ( + !step.methods.includes(method), + )} + methodLabels={methodLabels} + onDeleteStep={deleteStep} + onAddMethod={addMethod} + onDeleteMethod={deleteMethod} + buildOption={buildOption} + /> + ))} + +
+ +
+ +
+ ); +}; 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..e204bbdf7d --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx @@ -0,0 +1,93 @@ +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'; + +/** Renders one reorderable MFA step and its accepted methods. */ +export const MfaConfigurationStep = ({ + step, + stepNumber, + availableMethods, + methodLabels, + onDeleteStep, + onAddMethod, + onDeleteMethod, + buildOption, +}: MfaConfigurationStepProps) => { + const dragControls = useDragControls(); + const addMethodMenuOptions = [ + { + items: availableMethods.map((method) => + buildOption(method, () => onAddMethod(step.id, method)), + ), + }, + ]; + + return ( + +
+ +

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

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

{methodLabels[method]}

+
+ +
+
+
+ ))} +
+ {availableMethods.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 79% rename from web/src/shared/components/LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx rename to web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx index 827db7e1fd..44ed7aa79f 100644 --- a/web/src/shared/components/LocationMfaConfiguration/components/LocationMfaMethodsMenu.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx @@ -15,20 +15,23 @@ import { ButtonMenu } from '../../../defguard-ui/components/ButtonMenu/MenuButto import { Menu } from '../../../defguard-ui/components/Menu/Menu'; import type { MenuItemsGroup } from '../../../defguard-ui/components/Menu/types'; +/** Properties for the standard button menu variant. */ type ButtonVariantProps = Omit & { kind: 'button'; options: MenuItemsGroup[]; }; +/** Properties for the compact text menu variant. */ type PlainVariantProps = { kind: 'plain'; options: MenuItemsGroup[]; label: string; }; -type LocationMfaMethodsMenuProps = ButtonVariantProps | PlainVariantProps; +type MfaMethodsMenuProps = ButtonVariantProps | PlainVariantProps; -export const LocationMfaMethodsMenu = (props: LocationMfaMethodsMenuProps) => { +/** Renders the method menu as either an outlined button or a text action. */ +export const MfaMethodsMenu = (props: MfaMethodsMenuProps) => { if (props.kind === 'button') { const { kind, options, ...buttonProps } = props; return ; @@ -37,6 +40,7 @@ export const LocationMfaMethodsMenu = (props: LocationMfaMethodsMenuProps) => { return ; }; +/** Renders the compact add-method menu trigger used inside a step. */ const PlainButton = ({ options, label, @@ -68,14 +72,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..aab572d3e1 --- /dev/null +++ b/web/src/shared/components/MfaConfiguration/types.ts @@ -0,0 +1,39 @@ +import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; + +/** MFA methods supported by the flow editor and backend API. */ +export const MfaMethod = { + Totp: 'totp', + Email: 'email', + MobileClient: 'mobileapprove', + OpenId: 'oidc', +} as const; + +export type MfaMethodValue = (typeof MfaMethod)[keyof typeof MfaMethod]; + +/** One ordered MFA step in the form state. */ +export interface MfaConfigurationStepData { + id: string | number; + methods: MfaMethodValue[]; +} + +/** Properties for an individual MFA step card. */ +export type MfaConfigurationStepProps = { + step: MfaConfigurationStepData; + stepNumber: number; + availableMethods: MfaMethodValue[]; + methodLabels: Record; + onDeleteStep: (id: MfaConfigurationStepData['id']) => void; + onAddMethod: (stepId: MfaConfigurationStepData['id'], method: MfaMethodValue) => void; + onDeleteMethod: ( + stepId: MfaConfigurationStepData['id'], + method: MfaMethodValue, + ) => void; + buildOption: (method: MfaMethodValue, onClick: () => void) => MenuItemProps; +}; + +/** Properties for the controlled MFA configuration editor. */ +export type MfaConfigurationProps = { + steps: MfaConfigurationStepData[]; + onChange: (steps: MfaConfigurationStepData[]) => void; + error?: string; +}; From 66678e60b3688acbaf63ccdb284af81e1076084b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 11:32:03 +0200 Subject: [PATCH 06/26] save mfa flow --- web/messages/en/mfa_flow.json | 8 +- web/src/pages/MfaPage/MfaFormPage.tsx | 174 ++++++++++++------ web/src/shared/api/api.ts | 6 + web/src/shared/api/types.ts | 43 +++++ .../MfaConfiguration/MfaConfiguration.tsx | 38 ++-- .../components/MfaConfiguration/types.ts | 26 +-- 6 files changed, 202 insertions(+), 93 deletions(-) diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index fe41e2b792..949a5586ba 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -22,5 +22,11 @@ "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_form_action_create": "Create MFA flow" + "mfa_flow_method_biometric": "Biometrics", + "mfa_flow_form_action_create": "Create MFA flow", + "mfa_flow_created": "MFA flow created successfully.", + "mfa_flow_save_failed": "Failed to save 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/src/pages/MfaPage/MfaFormPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx index b6bf96a2f6..98c1842345 100644 --- a/web/src/pages/MfaPage/MfaFormPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -1,38 +1,68 @@ +import { useMutation } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; +import type { AxiosError } from 'axios'; import z from 'zod'; import { m } from '../../paraglide/messages'; +import api from '../../shared/api/api'; +import { + type CreateMfaFlowRequest, + type MfaFlowErrorResponse, + MfaFlowMethod, +} from '../../shared/api/types'; import { Controls } from '../../shared/components/Controls/Controls'; import { EditPage } from '../../shared/components/EditPage/EditPage'; import { MfaConfiguration } from '../../shared/components/MfaConfiguration/MfaConfiguration'; -import { - type MfaConfigurationStepData, - MfaMethod, -} from '../../shared/components/MfaConfiguration/types'; +import type { MfaConfigurationStepData } from '../../shared/components/MfaConfiguration/types'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { MarkedSectionHeader } from '../../shared/defguard-ui/components/MarkedSectionHeader/MarkedSectionHeader'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { useAppForm } from '../../shared/form'; import { formChangeLogic } from '../../shared/formLogic'; const formSchema = z.object({ - name: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), + title: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), steps: z .array( z.object({ id: z.union([z.string(), z.number()]), - methods: z.array(z.enum(MfaMethod)).min(1), + methods: z.array(z.enum(MfaFlowMethod)).min(1), }), ) .min(1, m.mfa_flow_step_required()), }); +/** Maps a structured MFA flow API failure to user-facing copy. */ +const getSaveErrorMessage = (error: AxiosError): string => { + const code = error.response?.data.fields?.[0]?.code; + switch (code) { + case 'business_license_required': + return m.mfa_flow_error_business_license(); + case 'smtp_not_configured': + return m.mfa_flow_error_smtp_not_configured(); + case 'oidc_provider_missing': + return m.mfa_flow_error_oidc_provider_missing(); + default: + return m.mfa_flow_save_failed(); + } +}; + export const MfaFormPage = () => { const navigate = useNavigate(); + const { mutateAsync: createMfaFlow } = useMutation({ + mutationFn: api.mfaFlow.create, + meta: { + invalidate: ['mfa-flow'], + }, + onError: (error: AxiosError) => { + Snackbar.error(getSaveErrorMessage(error)); + }, + }); const form = useAppForm({ defaultValues: { - name: '', + title: '', steps: [] as MfaConfigurationStepData[], }, validationLogic: formChangeLogic, @@ -40,60 +70,90 @@ export const MfaFormPage = () => { onChange: formSchema, onSubmit: formSchema, }, + onSubmit: async ({ value }) => { + const request: CreateMfaFlowRequest = { + title: value.title, + steps: value.steps.map(({ methods }) => ({ methods })), + }; + + try { + await createMfaFlow(request); + } catch { + return; + } + + Snackbar.success(m.mfa_flow_created()); + await navigate({ to: '/mfa' }); + }, }); return ( - - {m.cmp_nav_item_mfa()} - , - - {m.mfa_flow_breadcrumb_create()} - , - ]} - onBack={() => navigate({ to: '/mfa' })} - headerProps={{ - icon: 'activity-notes', - title: m.mfa_flow_form_title_create(), - subtitle: m.mfa_flow_form_subtitle(), +
{ + event.preventDefault(); + event.stopPropagation(); + void form.handleSubmit(); }} > - - - - - {(field) => } - - - - - - - {(field) => { - const error = field.state.meta.errors[0]; - return ( - - ); - }} - - - - -
-
-
-
- + + {m.cmp_nav_item_mfa()} + , + + {m.mfa_flow_breadcrumb_create()} + , + ]} + onBack={() => navigate({ to: '/mfa' })} + headerProps={{ + icon: 'activity-notes', + title: m.mfa_flow_form_title_create(), + subtitle: m.mfa_flow_form_subtitle(), + }} + > + + + + + {(field) => } + + + + + + + {(field) => { + const error = field.state.meta.errors[0]; + return ( + + ); + }} + + + + +
+
+
+
+
+
); }; diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 30ea205834..ba212fea55 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -46,6 +46,7 @@ import type { CreateAdminRequest, CreateCARequest, CreateGroupRequest, + CreateMfaFlowRequest, DeleteApiTokenRequest, DeleteAuthKeyRequest, Device, @@ -89,6 +90,7 @@ import type { LoginResponse, LoginResponseBasic, MfaCompleteResponse, + MfaFlowDetailResponse, MigrationWizardApiState, NetworkDevice, NetworkLocation, @@ -526,6 +528,10 @@ const api = { getCA: () => client.get('/core/cert/ca'), getCerts: () => client.get('/core/cert/certs'), }, + mfaFlow: { + create: (data: CreateMfaFlowRequest) => + client.post('/mfa-flow', data), + }, acl: { destination: { getCount: () => client.get('acl/destination/count'), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 9c65b3cc95..65c464ad73 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1415,6 +1415,49 @@ export interface CountResponse { count: number; } +export const MfaFlowMethod = { + Totp: 'totp', + Email: 'email', + OpenId: 'oidc', + Biometric: 'biometric', + MobileApprove: 'mobileapprove', +} as const; + +export type MfaFlowMethodValue = (typeof MfaFlowMethod)[keyof typeof MfaFlowMethod]; + +export interface CreateMfaFlowStep { + methods: MfaFlowMethodValue[]; +} + +export interface CreateMfaFlowRequest { + title: string; + steps: CreateMfaFlowStep[]; +} + +export interface MfaFlowStep { + id: number; + position: number; + methods: MfaFlowMethodValue[]; +} + +export interface MfaFlowDetailResponse { + id: number; + title: string; + steps: MfaFlowStep[]; + created_at: string; + updated_at: string; +} + +export interface MfaFlowErrorField { + field: string; + code: string; +} + +export interface MfaFlowErrorResponse { + error: string; + fields: MfaFlowErrorField[]; +} + export interface AclDestination { id: number; parent_id: number | null; diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index 75927452ad..2092e33e73 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -1,33 +1,30 @@ import './style.scss'; import { Reorder } from 'motion/react'; import { m } from '../../../paraglide/messages'; +import { MfaFlowMethod, type MfaFlowMethodValue } from '../../api/types'; import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; import { MfaConfigurationStep } from './components/MfaConfigurationStep'; import { MfaMethodsMenu } from './components/MfaMethodsMenu'; -import type { - MfaConfigurationProps, - MfaConfigurationStepData, - MfaMethodValue, -} from './types'; -import { MfaMethod } from './types'; +import type { MfaConfigurationProps, MfaConfigurationStepData } from './types'; -const availableMethods: MfaMethodValue[] = [ - MfaMethod.MobileClient, - MfaMethod.Totp, - MfaMethod.OpenId, - MfaMethod.Email, +const availableMethods: MfaFlowMethodValue[] = [ + MfaFlowMethod.MobileApprove, + MfaFlowMethod.Totp, + MfaFlowMethod.OpenId, + MfaFlowMethod.Email, ]; /** Configures ordered MFA steps and the methods accepted by each step. */ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { - const methodLabels: Record = { - [MfaMethod.MobileClient]: m.mfa_flow_method_mobile_client(), - [MfaMethod.Totp]: m.mfa_flow_method_authenticator_app(), - [MfaMethod.OpenId]: m.mfa_flow_method_external_provider(), - [MfaMethod.Email]: m.mfa_flow_method_email_code(), + 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(), }; /** Builds one selectable MFA method menu item. */ - const buildOption = (method: MfaMethodValue, onClick: () => void) => ({ + const buildOption = (method: MfaFlowMethodValue, onClick: () => void) => ({ text: methodLabels[method], onClick, }); @@ -46,7 +43,10 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro onChange(steps.filter((step) => step.id !== id)); }; /** Adds a method to an existing MFA step. */ - const addMethod = (stepId: MfaConfigurationStepData['id'], method: MfaMethodValue) => { + const addMethod = ( + stepId: MfaConfigurationStepData['id'], + method: MfaFlowMethodValue, + ) => { onChange( steps.map((step) => step.id === stepId ? { ...step, methods: [...step.methods, method] } : step, @@ -56,7 +56,7 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro /** Removes a method and drops the step when it becomes empty. */ const deleteMethod = ( stepId: MfaConfigurationStepData['id'], - method: MfaMethodValue, + method: MfaFlowMethodValue, ) => { const step = steps.find((item) => item.id === stepId); if (!step) return; diff --git a/web/src/shared/components/MfaConfiguration/types.ts b/web/src/shared/components/MfaConfiguration/types.ts index aab572d3e1..e752787da5 100644 --- a/web/src/shared/components/MfaConfiguration/types.ts +++ b/web/src/shared/components/MfaConfiguration/types.ts @@ -1,34 +1,28 @@ +import type { MfaFlowMethodValue } from '../../api/types'; import type { MenuItemProps } from '../../defguard-ui/components/Menu/types'; -/** MFA methods supported by the flow editor and backend API. */ -export const MfaMethod = { - Totp: 'totp', - Email: 'email', - MobileClient: 'mobileapprove', - OpenId: 'oidc', -} as const; - -export type MfaMethodValue = (typeof MfaMethod)[keyof typeof MfaMethod]; - /** One ordered MFA step in the form state. */ export interface MfaConfigurationStepData { id: string | number; - methods: MfaMethodValue[]; + methods: MfaFlowMethodValue[]; } /** Properties for an individual MFA step card. */ export type MfaConfigurationStepProps = { step: MfaConfigurationStepData; stepNumber: number; - availableMethods: MfaMethodValue[]; - methodLabels: Record; + availableMethods: MfaFlowMethodValue[]; + methodLabels: Record; onDeleteStep: (id: MfaConfigurationStepData['id']) => void; - onAddMethod: (stepId: MfaConfigurationStepData['id'], method: MfaMethodValue) => void; + onAddMethod: ( + stepId: MfaConfigurationStepData['id'], + method: MfaFlowMethodValue, + ) => void; onDeleteMethod: ( stepId: MfaConfigurationStepData['id'], - method: MfaMethodValue, + method: MfaFlowMethodValue, ) => void; - buildOption: (method: MfaMethodValue, onClick: () => void) => MenuItemProps; + buildOption: (method: MfaFlowMethodValue, onClick: () => void) => MenuItemProps; }; /** Properties for the controlled MFA configuration editor. */ From 4b9a9e7dd60246cf293338dfb9967315835b5ee2 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 14 Aug 2026 14:13:19 +0200 Subject: [PATCH 07/26] edit MFA flow --- web/messages/en/mfa_flow.json | 3 + web/src/pages/MfaPage/MfaFormPage.tsx | 82 +++++++++++++++---- web/src/routeTree.gen.ts | 22 +++++ .../_default/mfa-flow/$id/edit.tsx | 25 ++++++ web/src/shared/api/api.ts | 4 + web/src/shared/api/types.ts | 10 +++ 6 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index 949a5586ba..be8c44097a 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -4,7 +4,9 @@ "mfa_flows_empty_subtitle": "Add the first rule by clicking button below.", "mfa_flows_button_add": "Add new 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", @@ -25,6 +27,7 @@ "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_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.", diff --git a/web/src/pages/MfaPage/MfaFormPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx index 98c1842345..e76ef14dc3 100644 --- a/web/src/pages/MfaPage/MfaFormPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -6,8 +6,10 @@ import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; import { type CreateMfaFlowRequest, + type MfaFlowDetailResponse, type MfaFlowErrorResponse, MfaFlowMethod, + type UpdateMfaFlowRequest, } from '../../shared/api/types'; import { Controls } from '../../shared/components/Controls/Controls'; import { EditPage } from '../../shared/components/EditPage/EditPage'; @@ -49,8 +51,13 @@ const getSaveErrorMessage = (error: AxiosError): string => } }; -export const MfaFormPage = () => { +type Props = { + flow?: MfaFlowDetailResponse; +}; + +export const MfaFormPage = ({ flow }: Props) => { const navigate = useNavigate(); + const isEdit = flow !== undefined; const { mutateAsync: createMfaFlow } = useMutation({ mutationFn: api.mfaFlow.create, meta: { @@ -60,29 +67,56 @@ export const MfaFormPage = () => { Snackbar.error(getSaveErrorMessage(error)); }, }); - const form = useAppForm({ - defaultValues: { - title: '', - steps: [] as MfaConfigurationStepData[], + const { mutateAsync: updateMfaFlow } = useMutation({ + mutationFn: (request: UpdateMfaFlowRequest) => { + if (!flow) throw new Error('Cannot update an MFA flow without an ID.'); + return api.mfaFlow.update(flow.id, request); + }, + meta: { + invalidate: ['mfa-flow'], + }, + onError: (error: AxiosError) => { + Snackbar.error(getSaveErrorMessage(error)); }, + }); + const form = useAppForm({ + defaultValues: flow + ? { + title: flow.title, + steps: flow.steps.map(({ id, methods }) => ({ id, methods })), + } + : { + title: '', + steps: [] as MfaConfigurationStepData[], + }, validationLogic: formChangeLogic, validators: { onChange: formSchema, onSubmit: formSchema, }, onSubmit: async ({ value }) => { - const request: CreateMfaFlowRequest = { - title: value.title, - steps: value.steps.map(({ methods }) => ({ methods })), - }; - try { - await createMfaFlow(request); + if (isEdit) { + const request: UpdateMfaFlowRequest = { + title: value.title, + steps: value.steps.map(({ id, methods }) => ({ + ...(typeof id === 'number' ? { id } : {}), + methods, + })), + }; + await updateMfaFlow(request); + } else { + const request: CreateMfaFlowRequest = { + title: value.title, + steps: value.steps.map(({ methods }) => ({ methods })), + }; + await createMfaFlow(request); + } } catch { return; } - Snackbar.success(m.mfa_flow_created()); + Snackbar.default(isEdit ? m.mfa_flow_updated() : m.mfa_flow_created()); await navigate({ to: '/mfa' }); }, }); @@ -101,14 +135,24 @@ export const MfaFormPage = () => { {m.cmp_nav_item_mfa()} , - - {m.mfa_flow_breadcrumb_create()} - , + isEdit ? ( + + {m.mfa_flow_breadcrumb_edit()} + + ) : ( + + {m.mfa_flow_breadcrumb_create()} + + ), ]} onBack={() => navigate({ to: '/mfa' })} headerProps={{ icon: 'activity-notes', - title: m.mfa_flow_form_title_create(), + title: isEdit ? m.mfa_flow_form_title_edit() : m.mfa_flow_form_title_create(), subtitle: m.mfa_flow_form_subtitle(), }} > @@ -149,7 +193,11 @@ export const MfaFormPage = () => { void navigate({ to: '/mfa' }); }} /> - + diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 0318c853c4..99f5299ea6 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -74,6 +74,7 @@ import { Route as AuthorizedDefaultAclAliasesRouteImport } from './routes/_autho import { Route as AuthorizedDefaultAclAddRuleRouteImport } from './routes/_authorized/_default/acl/add-rule' import { Route as AuthorizedDefaultAclAddDestinationRouteImport } from './routes/_authorized/_default/acl/add-destination' import { Route as AuthorizedDefaultAclAddAliasRouteImport } from './routes/_authorized/_default/acl/add-alias' +import { Route as AuthorizedDefaultMfaFlowIdEditRouteImport } from './routes/_authorized/_default/mfa-flow/$id/edit' import { Route as AuthorizedDefaultLocationsLocationIdEditRouteImport } from './routes/_authorized/_default/locations/$locationId/edit' import { Route as AuthorizedDefaultGatewayGatewayIdEditRouteImport } from './routes/_authorized/_default/gateway/$gatewayId/edit' import { Route as AuthorizedDefaultEdgeEdgeIdEditRouteImport } from './routes/_authorized/_default/edge/$edgeId/edit' @@ -440,6 +441,12 @@ const AuthorizedDefaultAclAddAliasRoute = path: '/acl/add-alias', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultMfaFlowIdEditRoute = + AuthorizedDefaultMfaFlowIdEditRouteImport.update({ + id: '/mfa-flow/$id/edit', + path: '/mfa-flow/$id/edit', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultLocationsLocationIdEditRoute = AuthorizedDefaultLocationsLocationIdEditRouteImport.update({ id: '/locations/$locationId/edit', @@ -532,6 +539,7 @@ export interface FileRoutesByFullPath { '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute + '/mfa-flow/$id/edit': typeof AuthorizedDefaultMfaFlowIdEditRoute '/acl/posture-checks/$postureCheckId/edit': typeof AuthorizedDefaultAclPostureChecksPostureCheckIdEditRoute } export interface FileRoutesByTo { @@ -600,6 +608,7 @@ export interface FileRoutesByTo { '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute + '/mfa-flow/$id/edit': typeof AuthorizedDefaultMfaFlowIdEditRoute '/acl/posture-checks/$postureCheckId/edit': typeof AuthorizedDefaultAclPostureChecksPostureCheckIdEditRoute } export interface FileRoutesById { @@ -672,6 +681,7 @@ export interface FileRoutesById { '/_authorized/_default/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/_authorized/_default/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/_authorized/_default/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute + '/_authorized/_default/mfa-flow/$id/edit': typeof AuthorizedDefaultMfaFlowIdEditRoute '/_authorized/_default/acl/posture-checks/$postureCheckId/edit': typeof AuthorizedDefaultAclPostureChecksPostureCheckIdEditRoute } export interface FileRouteTypes { @@ -743,6 +753,7 @@ export interface FileRouteTypes { | '/edge/$edgeId/edit' | '/gateway/$gatewayId/edit' | '/locations/$locationId/edit' + | '/mfa-flow/$id/edit' | '/acl/posture-checks/$postureCheckId/edit' fileRoutesByTo: FileRoutesByTo to: @@ -811,6 +822,7 @@ export interface FileRouteTypes { | '/edge/$edgeId/edit' | '/gateway/$gatewayId/edit' | '/locations/$locationId/edit' + | '/mfa-flow/$id/edit' | '/acl/posture-checks/$postureCheckId/edit' id: | '__root__' @@ -882,6 +894,7 @@ export interface FileRouteTypes { | '/_authorized/_default/edge/$edgeId/edit' | '/_authorized/_default/gateway/$gatewayId/edit' | '/_authorized/_default/locations/$locationId/edit' + | '/_authorized/_default/mfa-flow/$id/edit' | '/_authorized/_default/acl/posture-checks/$postureCheckId/edit' fileRoutesById: FileRoutesById } @@ -1358,6 +1371,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultAclAddAliasRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/mfa-flow/$id/edit': { + id: '/_authorized/_default/mfa-flow/$id/edit' + path: '/mfa-flow/$id/edit' + fullPath: '/mfa-flow/$id/edit' + preLoaderRoute: typeof AuthorizedDefaultMfaFlowIdEditRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/locations/$locationId/edit': { id: '/_authorized/_default/locations/$locationId/edit' path: '/locations/$locationId/edit' @@ -1443,6 +1463,7 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultEdgeEdgeIdEditRoute: typeof AuthorizedDefaultEdgeEdgeIdEditRoute AuthorizedDefaultGatewayGatewayIdEditRoute: typeof AuthorizedDefaultGatewayGatewayIdEditRoute AuthorizedDefaultLocationsLocationIdEditRoute: typeof AuthorizedDefaultLocationsLocationIdEditRoute + AuthorizedDefaultMfaFlowIdEditRoute: typeof AuthorizedDefaultMfaFlowIdEditRoute } const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { @@ -1494,6 +1515,7 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultGatewayGatewayIdEditRoute, AuthorizedDefaultLocationsLocationIdEditRoute: AuthorizedDefaultLocationsLocationIdEditRoute, + AuthorizedDefaultMfaFlowIdEditRoute: AuthorizedDefaultMfaFlowIdEditRoute, } const AuthorizedDefaultRouteWithChildren = diff --git a/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx new file mode 100644 index 0000000000..4527f964b4 --- /dev/null +++ b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, notFound } from '@tanstack/react-router'; +import axios from 'axios'; +import { MfaFormPage } from '../../../../../pages/MfaPage/MfaFormPage'; +import api from '../../../../../shared/api/api'; + +export const Route = createFileRoute('/_authorized/_default/mfa-flow/$id/edit')({ + loader: async ({ params }) => { + const id = Number(params.id); + if (!Number.isSafeInteger(id) || id <= 0) throw notFound(); + + try { + return (await api.mfaFlow.get(id)).data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) throw notFound(); + throw error; + } + }, + component: RouteComponent, +}); + +/** Displays the edit form with flow data loaded by the route. */ +function RouteComponent() { + const flow = Route.useLoaderData(); + return ; +} diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index ba212fea55..a17a3f0e29 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -122,6 +122,7 @@ import type { TestDirectorySyncResponse, TotpInitResponse, UpdateInfo, + UpdateMfaFlowRequest, UploadCARequest, User, UserChangePasswordRequest, @@ -531,6 +532,9 @@ const api = { mfaFlow: { create: (data: CreateMfaFlowRequest) => client.post('/mfa-flow', data), + get: (id: number) => client.get(`/mfa-flow/${id}`), + update: (id: number, data: UpdateMfaFlowRequest) => + client.put(`/mfa-flow/${id}`, data), }, acl: { destination: { diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 65c464ad73..9e6d90a0bd 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1434,6 +1434,16 @@ export interface CreateMfaFlowRequest { steps: CreateMfaFlowStep[]; } +export interface UpdateMfaFlowStep { + id?: number; + methods: MfaFlowMethodValue[]; +} + +export interface UpdateMfaFlowRequest { + title: string; + steps: UpdateMfaFlowStep[]; +} + export interface MfaFlowStep { id: number; position: number; From 2fac7b0dcc34e87d8276e57ec0b24efb59cf9ca3 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sat, 15 Aug 2026 08:29:43 +0200 Subject: [PATCH 08/26] fix mfa flow editor in dark theme --- web/src/shared/components/MfaConfiguration/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/components/MfaConfiguration/style.scss b/web/src/shared/components/MfaConfiguration/style.scss index 5fe676e1aa..a88971b1c3 100644 --- a/web/src/shared/components/MfaConfiguration/style.scss +++ b/web/src/shared/components/MfaConfiguration/style.scss @@ -15,7 +15,7 @@ padding: var(--spacing-lg) var(--spacing-md); border-radius: 12px; border: 1px solid var(--border-default); - background: var(--bg-white); + background: var(--bg-default); position: relative; z-index: 1; user-select: none; From 1db8d50ed325ed7bfd6a975df92e2c8ee8b58e21 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 07:55:21 +0200 Subject: [PATCH 09/26] display validation error only after submit --- web/src/pages/MfaPage/MfaFormPage.tsx | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/web/src/pages/MfaPage/MfaFormPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx index e76ef14dc3..5c1bbe7921 100644 --- a/web/src/pages/MfaPage/MfaFormPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -19,9 +19,10 @@ import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { MarkedSectionHeader } from '../../shared/defguard-ui/components/MarkedSectionHeader/MarkedSectionHeader'; +import { useFormFieldError } from '../../shared/defguard-ui/hooks/useFormFieldError'; import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; -import { useAppForm } from '../../shared/form'; +import { useAppForm, useFieldContext } from '../../shared/form'; import { formChangeLogic } from '../../shared/formLogic'; const formSchema = z.object({ @@ -169,18 +170,7 @@ export const MfaFormPage = ({ flow }: Props) => { title={m.mfa_flow_form_methods_title()} description={m.mfa_flow_form_methods_description()} /> - - {(field) => { - const error = field.state.meta.errors[0]; - return ( - - ); - }} - + {() => } @@ -205,3 +195,16 @@ export const MfaFormPage = ({ flow }: Props) => { ); }; + +const FormMfaConfiguration = () => { + const field = useFieldContext(); + const error = useFormFieldError(); + + return ( + + ); +}; From 616131e5449f5555a7cf6902ae83ae87d66f0f44 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 08:17:01 +0200 Subject: [PATCH 10/26] lock "email code" mfa method if smtp is not configured --- web/messages/en/mfa_flow.json | 1 + .../MfaConfiguration/MfaConfiguration.tsx | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index be8c44097a..54a20ba4a9 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -24,6 +24,7 @@ "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_biometric": "Biometrics", "mfa_flow_form_action_create": "Create MFA flow", "mfa_flow_created": "MFA flow created successfully.", diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index 2092e33e73..7c03ab1159 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -3,6 +3,7 @@ import { Reorder } from 'motion/react'; import { m } from '../../../paraglide/messages'; import { MfaFlowMethod, type MfaFlowMethodValue } from '../../api/types'; import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; +import { useApp } from '../../hooks/useApp'; import { MfaConfigurationStep } from './components/MfaConfigurationStep'; import { MfaMethodsMenu } from './components/MfaMethodsMenu'; import type { MfaConfigurationProps, MfaConfigurationStepData } from './types'; @@ -16,6 +17,7 @@ const availableMethods: MfaFlowMethodValue[] = [ /** Configures ordered MFA steps and the methods accepted by each step. */ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { + const smtpEnabled = useApp((state) => state.appInfo.smtp_enabled); const methodLabels: Record = { [MfaFlowMethod.MobileApprove]: m.mfa_flow_method_mobile_client(), [MfaFlowMethod.Totp]: m.mfa_flow_method_authenticator_app(), @@ -24,10 +26,15 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro [MfaFlowMethod.Biometric]: m.mfa_flow_method_biometric(), }; /** Builds one selectable MFA method menu item. */ - const buildOption = (method: MfaFlowMethodValue, onClick: () => void) => ({ - text: methodLabels[method], - onClick, - }); + const buildOption = (method: MfaFlowMethodValue, onClick: () => void) => { + const smtpRequired = method === MfaFlowMethod.Email && !smtpEnabled; + return { + text: methodLabels[method], + onClick, + disabled: smtpRequired, + disabledHelper: smtpRequired ? m.mfa_flow_method_smtp_required() : undefined, + }; + }; const addStepMenuOptions = [ { items: availableMethods.map((method) => From 99b90c36784e5c55470e9c0378cecb9c5a52337a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 09:12:09 +0200 Subject: [PATCH 11/26] lock external oidc if license < business --- web/messages/en/mfa_flow.json | 3 + .../MfaConfiguration/MfaConfiguration.tsx | 69 +++++++++++++++---- .../components/MfaConfigurationStep.tsx | 17 +++-- .../components/MfaConfiguration/types.ts | 8 ++- 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index 54a20ba4a9..715ebb51b1 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -25,6 +25,9 @@ "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.", diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index 7c03ab1159..eda29a63b8 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -1,23 +1,33 @@ import './style.scss'; +import { useQuery } from '@tanstack/react-query'; import { Reorder } from 'motion/react'; import { m } from '../../../paraglide/messages'; import { MfaFlowMethod, type MfaFlowMethodValue } from '../../api/types'; import { FieldError } from '../../defguard-ui/components/FieldError/FieldError'; import { useApp } from '../../hooks/useApp'; +import { getLicenseInfoQueryOptions } from '../../query'; +import { canUseBusinessFeature } from '../../utils/license'; import { MfaConfigurationStep } from './components/MfaConfigurationStep'; import { MfaMethodsMenu } from './components/MfaMethodsMenu'; -import type { MfaConfigurationProps, MfaConfigurationStepData } from './types'; +import type { + MfaConfigurationProps, + MfaConfigurationStepData, + MfaMethodGroup, +} from './types'; const availableMethods: MfaFlowMethodValue[] = [ + MfaFlowMethod.Email, MfaFlowMethod.MobileApprove, MfaFlowMethod.Totp, MfaFlowMethod.OpenId, - MfaFlowMethod.Email, ]; /** Configures ordered MFA steps and the methods accepted by each step. */ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { const smtpEnabled = useApp((state) => state.appInfo.smtp_enabled); + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + const businessAvailable = + licenseInfo === undefined ? undefined : canUseBusinessFeature(licenseInfo).result; const methodLabels: Record = { [MfaFlowMethod.MobileApprove]: m.mfa_flow_method_mobile_client(), [MfaFlowMethod.Totp]: m.mfa_flow_method_authenticator_app(), @@ -28,22 +38,51 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro /** Builds one selectable MFA method menu item. */ const buildOption = (method: MfaFlowMethodValue, onClick: () => void) => { const smtpRequired = method === MfaFlowMethod.Email && !smtpEnabled; + const businessRequired = + method === MfaFlowMethod.OpenId && businessAvailable === false; + const businessLoading = + method === MfaFlowMethod.OpenId && businessAvailable === undefined; return { text: methodLabels[method], onClick, - disabled: smtpRequired, - disabledHelper: smtpRequired ? m.mfa_flow_method_smtp_required() : undefined, + disabled: smtpRequired || businessRequired || businessLoading, + disabledHelper: smtpRequired + ? m.mfa_flow_method_smtp_required() + : businessRequired + ? m.mfa_flow_method_business_required() + : undefined, }; }; - const addStepMenuOptions = [ - { - items: availableMethods.map((method) => - buildOption(method, () => { - onChange([...steps, { id: crypto.randomUUID(), methods: [method] }]); - }), - ), - }, - ]; + /** Groups locked premium methods separately from methods available in the current plan. */ + const buildMethodGroups = (methods: MfaFlowMethodValue[]): MfaMethodGroup[] => { + const externalIdLocked = + businessAvailable === false && methods.includes(MfaFlowMethod.OpenId); + if (!externalIdLocked) return [{ items: methods }]; + + const planMethods = methods.filter((method) => method !== MfaFlowMethod.OpenId); + 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: [MfaFlowMethod.OpenId], + }, + ]; + }; + const addStepMenuOptions = buildMethodGroups(availableMethods).map((group) => ({ + ...group, + items: group.items.map((method) => + buildOption(method, () => { + onChange([...steps, { id: crypto.randomUUID(), methods: [method] }]); + }), + ), + })); /** Removes a complete MFA step. */ const deleteStep = (id: MfaConfigurationStepData['id']) => { @@ -93,8 +132,8 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro key={step.id} step={step} stepNumber={index + 1} - availableMethods={availableMethods.filter( - (method) => !step.methods.includes(method), + methodGroups={buildMethodGroups( + availableMethods.filter((method) => !step.methods.includes(method)), )} methodLabels={methodLabels} onDeleteStep={deleteStep} diff --git a/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx index e204bbdf7d..c746ddc72b 100644 --- a/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx @@ -11,7 +11,7 @@ import { MfaMethodsMenu } from './MfaMethodsMenu'; export const MfaConfigurationStep = ({ step, stepNumber, - availableMethods, + methodGroups, methodLabels, onDeleteStep, onAddMethod, @@ -19,13 +19,12 @@ export const MfaConfigurationStep = ({ buildOption, }: MfaConfigurationStepProps) => { const dragControls = useDragControls(); - const addMethodMenuOptions = [ - { - items: availableMethods.map((method) => - buildOption(method, () => onAddMethod(step.id, method)), - ), - }, - ]; + const addMethodMenuOptions = methodGroups.map((group) => ({ + ...group, + items: group.items.map((method) => + buildOption(method, () => onAddMethod(step.id, method)), + ), + })); return ( ))} - {availableMethods.length > 0 && ( + {methodGroups.some((group) => group.items.length > 0) && (
; onDeleteStep: (id: MfaConfigurationStepData['id']) => void; onAddMethod: ( From 90f48987bcd99b5aab9f4e36a0bcfce8df74ce75 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 09:29:19 +0200 Subject: [PATCH 12/26] mfa flow table --- web/messages/en/mfa_flow.json | 7 ++ web/src/pages/MfaPage/MfaFlowsTable.tsx | 124 ++++++++++++++++++++++++ web/src/pages/MfaPage/MfaPage.tsx | 29 +++++- web/src/shared/api/api.ts | 3 + web/src/shared/api/types.ts | 9 ++ web/src/shared/query.ts | 6 ++ 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 web/src/pages/MfaPage/MfaFlowsTable.tsx diff --git a/web/messages/en/mfa_flow.json b/web/messages/en/mfa_flow.json index 715ebb51b1..2c1af2201a 100644 --- a/web/messages/en/mfa_flow.json +++ b/web/messages/en/mfa_flow.json @@ -3,6 +3,13 @@ "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", diff --git a/web/src/pages/MfaPage/MfaFlowsTable.tsx b/web/src/pages/MfaPage/MfaFlowsTable.tsx new file mode 100644 index 0000000000..efce60b26b --- /dev/null +++ b/web/src/pages/MfaPage/MfaFlowsTable.tsx @@ -0,0 +1,124 @@ +import { useNavigate } from '@tanstack/react-router'; +import { + createColumnHelper, + getCoreRowModel, + 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 { Button } from '../../shared/defguard-ui/components/Button/Button'; +import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types'; +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 { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; +import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; + +/** Data and actions required by the MFA flow table. */ +type Props = { + flows: MfaFlowListItemResponse[]; + addButtonProps: ButtonProps; +}; + +const columnHelper = createColumnHelper(); + +/** Displays configured MFA flows and their available row actions. */ +export const MfaFlowsTable = ({ flows, addButtonProps }: Props) => { + const navigate = useNavigate(); + const [search, setSearch] = useState(''); + 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, + 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], + ); + const table = useReactTable({ + columns, + data: flows, + enableRowSelection: false, + columnResizeMode: 'onChange', + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <> + + +
diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index b90ec25e95..442e4f5777 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -52,6 +52,12 @@ export const getMfaFlowsQueryOptions = queryOptions({ select: (response) => response.data, }); +export const getMfaMethodAvailabilityQueryOptions = queryOptions({ + queryFn: api.mfaFlow.methodAvailability, + queryKey: ['mfa-flow', 'method-availability'], + select: (response) => response.data, +}); + export const getEdgesQueryOptions = queryOptions({ queryFn: api.edge.getEdges, queryKey: ['edge'], From f482182bc7faada7897726432da40210d1c4705c Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 12:19:18 +0200 Subject: [PATCH 23/26] cleanup --- web/src/pages/MfaPage/MfaFlowsTable.tsx | 2 -- web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx | 1 - web/src/shared/api/types.ts | 2 -- .../components/MfaConfiguration/MfaConfiguration.tsx | 7 +++---- .../MfaConfiguration/components/MfaConfigurationStep.tsx | 1 - .../MfaConfiguration/components/MfaMethodsMenu.tsx | 1 - 6 files changed, 3 insertions(+), 11 deletions(-) diff --git a/web/src/pages/MfaPage/MfaFlowsTable.tsx b/web/src/pages/MfaPage/MfaFlowsTable.tsx index 461070a8c8..7b626be027 100644 --- a/web/src/pages/MfaPage/MfaFlowsTable.tsx +++ b/web/src/pages/MfaPage/MfaFlowsTable.tsx @@ -27,7 +27,6 @@ import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; -/** Data and actions required by the MFA flow table. */ type Props = { flows: MfaFlowListItemResponse[]; addButtonProps: ButtonProps; @@ -43,7 +42,6 @@ const filterByStepCount: FilterFn = ( ) => selectedCounts.includes(row.getValue(columnId)); filterByStepCount.autoRemove = (value) => !Array.isArray(value) || value.length === 0; -/** Displays configured MFA flows and their available row actions. */ export const MfaFlowsTable = ({ flows, addButtonProps }: Props) => { const navigate = useNavigate(); const [search, setSearch] = useState(''); diff --git a/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx index 4527f964b4..cd2bad4fc2 100644 --- a/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx +++ b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx @@ -18,7 +18,6 @@ export const Route = createFileRoute('/_authorized/_default/mfa-flow/$id/edit')( component: RouteComponent, }); -/** Displays the edit form with flow data loaded by the route. */ function RouteComponent() { const flow = Route.useLoaderData(); return ; diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 43be2e7968..98f6344fcf 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1435,7 +1435,6 @@ export const MfaMethodAvailabilityReason = { export type MfaMethodAvailabilityReasonValue = (typeof MfaMethodAvailabilityReason)[keyof typeof MfaMethodAvailabilityReason]; -/** Availability of an MFA method in the current server configuration. */ export interface MfaMethodAvailabilityResponse { method: MfaFlowMethodValue; available: boolean; @@ -1475,7 +1474,6 @@ export interface MfaFlowDetailResponse { updated_at: string; } -/** Summary returned for an MFA flow in the list endpoint. */ export interface MfaFlowListItemResponse { id: number; title: string; diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index 3c7df9abb7..59050aedaf 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -18,7 +18,6 @@ import type { MfaMethodGroup, } from './types'; -/** Configures ordered MFA steps and the methods accepted by each step. */ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { const { data: methodAvailability } = useQuery(getMfaMethodAvailabilityQueryOptions); const availableMethods = methodAvailability?.map(({ method }) => method) ?? []; @@ -77,6 +76,7 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro }, ]; }; + const addStepMenuOptions = buildMethodGroups(availableMethods).map((group) => ({ ...group, items: group.items.map((method) => @@ -86,11 +86,10 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro ), })); - /** Removes a complete MFA step. */ const deleteStep = (id: MfaConfigurationStepData['id']) => { onChange(steps.filter((step) => step.id !== id)); }; - /** Adds a method to an existing MFA step. */ + const addMethod = ( stepId: MfaConfigurationStepData['id'], method: MfaFlowMethodValue, @@ -101,7 +100,7 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro ), ); }; - /** Removes a method and drops the step when it becomes empty. */ + const deleteMethod = ( stepId: MfaConfigurationStepData['id'], method: MfaFlowMethodValue, diff --git a/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx index c746ddc72b..287e64de2e 100644 --- a/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaConfigurationStep.tsx @@ -7,7 +7,6 @@ import { ThemeSpacing, ThemeVariable } from '../../../defguard-ui/types'; import type { MfaConfigurationStepProps } from '../types'; import { MfaMethodsMenu } from './MfaMethodsMenu'; -/** Renders one reorderable MFA step and its accepted methods. */ export const MfaConfigurationStep = ({ step, stepNumber, diff --git a/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx index 44ed7aa79f..3990b6e25f 100644 --- a/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx @@ -30,7 +30,6 @@ type PlainVariantProps = { type MfaMethodsMenuProps = ButtonVariantProps | PlainVariantProps; -/** Renders the method menu as either an outlined button or a text action. */ export const MfaMethodsMenu = (props: MfaMethodsMenuProps) => { if (props.kind === 'button') { const { kind, options, ...buttonProps } = props; From 279a5f2b94d4f09659788974628c4596d48420d0 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 12:37:18 +0200 Subject: [PATCH 24/26] remove comments --- .../components/MfaConfiguration/components/MfaMethodsMenu.tsx | 2 -- web/src/shared/components/MfaConfiguration/types.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx index 3990b6e25f..55ddc69a7c 100644 --- a/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx +++ b/web/src/shared/components/MfaConfiguration/components/MfaMethodsMenu.tsx @@ -15,13 +15,11 @@ import { ButtonMenu } from '../../../defguard-ui/components/ButtonMenu/MenuButto import { Menu } from '../../../defguard-ui/components/Menu/Menu'; import type { MenuItemsGroup } from '../../../defguard-ui/components/Menu/types'; -/** Properties for the standard button menu variant. */ type ButtonVariantProps = Omit & { kind: 'button'; options: MenuItemsGroup[]; }; -/** Properties for the compact text menu variant. */ type PlainVariantProps = { kind: 'plain'; options: MenuItemsGroup[]; diff --git a/web/src/shared/components/MfaConfiguration/types.ts b/web/src/shared/components/MfaConfiguration/types.ts index 6cc6659899..9355dfb40e 100644 --- a/web/src/shared/components/MfaConfiguration/types.ts +++ b/web/src/shared/components/MfaConfiguration/types.ts @@ -31,7 +31,6 @@ export type MfaConfigurationStepProps = { buildOption: (method: MfaFlowMethodValue, onClick: () => void) => MenuItemProps; }; -/** Properties for the controlled MFA configuration editor. */ export type MfaConfigurationProps = { steps: MfaConfigurationStepData[]; onChange: (steps: MfaConfigurationStepData[]) => void; From 2419effb1d1c9fa703e021b81a13b14e74ca0b22 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 17 Aug 2026 15:48:18 +0200 Subject: [PATCH 25/26] show "upgrade to business" modal --- .../MfaConfiguration/MfaConfiguration.tsx | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index 59050aedaf..c3551de614 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -8,8 +8,14 @@ import { 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 { getMfaMethodAvailabilityQueryOptions } from '../../query'; +import { + getLicenseInfoQueryOptions, + getMfaMethodAvailabilityQueryOptions, +} from '../../query'; +import { canUseBusinessFeature, licenseActionCheck } from '../../utils/license'; import { MfaConfigurationStep } from './components/MfaConfigurationStep'; import { MfaMethodsMenu } from './components/MfaMethodsMenu'; import type { @@ -20,6 +26,11 @@ import type { export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationProps) => { const { data: methodAvailability } = useQuery(getMfaMethodAvailabilityQueryOptions); + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + const businessLicenseCheck = + licenseInfo === undefined ? undefined : canUseBusinessFeature(licenseInfo); + const additionalStepRequiresBusiness = + steps.length > 0 && businessLicenseCheck?.result === false; const availableMethods = methodAvailability?.map(({ method }) => method) ?? []; const methodLabels: Record = { [MfaFlowMethod.MobileApprove]: m.mfa_flow_method_mobile_client(), @@ -85,6 +96,16 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro }), ), })); + 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 = (id: MfaConfigurationStepData['id']) => { onChange(steps.filter((step) => step.id !== id)); @@ -145,16 +166,18 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro ))}
- + {additionalStepRequiresBusiness && businessLicenseCheck ? ( +
From 5f2ad8605f42a40606e52f0d3df4209da3ee9f5a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 18 Aug 2026 05:01:48 +0200 Subject: [PATCH 26/26] review fixes --- web/src/pages/MfaPage/MfaFormPage.tsx | 24 +-- web/src/pages/MfaPage/MfaPage.tsx | 1 - .../_default/mfa-flow/$id/edit.tsx | 12 +- .../MfaConfiguration/MfaConfiguration.tsx | 152 ++++++++++-------- .../components/MfaConfiguration/types.ts | 4 +- web/src/shared/query.ts | 5 +- 6 files changed, 112 insertions(+), 86 deletions(-) diff --git a/web/src/pages/MfaPage/MfaFormPage.tsx b/web/src/pages/MfaPage/MfaFormPage.tsx index bfd8aa73e0..f2f65642a0 100644 --- a/web/src/pages/MfaPage/MfaFormPage.tsx +++ b/web/src/pages/MfaPage/MfaFormPage.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; import type { AxiosError } from 'axios'; +import { useMemo } from 'react'; import z from 'zod'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; @@ -82,16 +83,21 @@ export const MfaFormPage = ({ flow }: Props) => { Snackbar.error(getSaveErrorMessage(error)); }, }); + const defaultValues = useMemo( + () => + flow + ? { + title: flow.title, + steps: flow.steps.map(({ id, methods }) => ({ id, methods })), + } + : { + title: '', + steps: [] as MfaConfigurationStepData[], + }, + [flow], + ); const form = useAppForm({ - defaultValues: flow - ? { - title: flow.title, - steps: flow.steps.map(({ id, methods }) => ({ id, methods })), - } - : { - title: '', - steps: [] as MfaConfigurationStepData[], - }, + defaultValues, validationLogic: formChangeLogic, validators: { onChange: formSchema, diff --git a/web/src/pages/MfaPage/MfaPage.tsx b/web/src/pages/MfaPage/MfaPage.tsx index a656acf610..6ccf609c83 100644 --- a/web/src/pages/MfaPage/MfaPage.tsx +++ b/web/src/pages/MfaPage/MfaPage.tsx @@ -13,7 +13,6 @@ import { getLicenseInfoQueryOptions, getMfaFlowsQueryOptions } from '../../share import { canUseBusinessFeature, licenseActionCheck } from '../../shared/utils/license'; import { MfaFlowsTable } from './MfaFlowsTable'; -/** Loads and renders either the configured MFA flows or the first-use empty state. */ const MfaPageContent = () => { const navigate = useNavigate(); const { data: flows } = useSuspenseQuery(getMfaFlowsQueryOptions); diff --git a/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx index cd2bad4fc2..677eb6d9be 100644 --- a/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx +++ b/web/src/routes/_authorized/_default/mfa-flow/$id/edit.tsx @@ -1,7 +1,10 @@ -import { createFileRoute, notFound } from '@tanstack/react-router'; +import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import axios from 'axios'; +import Skeleton from 'react-loading-skeleton'; import { MfaFormPage } from '../../../../../pages/MfaPage/MfaFormPage'; +import { m } from '../../../../../paraglide/messages'; import api from '../../../../../shared/api/api'; +import { Snackbar } from '../../../../../shared/defguard-ui/providers/snackbar/snackbar'; export const Route = createFileRoute('/_authorized/_default/mfa-flow/$id/edit')({ loader: async ({ params }) => { @@ -12,9 +15,14 @@ export const Route = createFileRoute('/_authorized/_default/mfa-flow/$id/edit')( return (await api.mfaFlow.get(id)).data; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) throw notFound(); - throw error; + Snackbar.error(m.error_unknown()); + throw redirect({ + to: '/mfa', + replace: true, + }); } }, + pendingComponent: () => , component: RouteComponent, }); diff --git a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx index c3551de614..4dfe896338 100644 --- a/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx +++ b/web/src/shared/components/MfaConfiguration/MfaConfiguration.tsx @@ -1,6 +1,7 @@ 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, @@ -19,51 +20,57 @@ import { canUseBusinessFeature, licenseActionCheck } from '../../utils/license'; import { MfaConfigurationStep } from './components/MfaConfigurationStep'; import { MfaMethodsMenu } from './components/MfaMethodsMenu'; import type { + MfaConfigurationMethodGroup, MfaConfigurationProps, MfaConfigurationStepData, - MfaMethodGroup, } 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: methodAvailability } = useQuery(getMfaMethodAvailabilityQueryOptions); + 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 availableMethods = methodAvailability?.map(({ method }) => method) ?? []; - 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(), - }; - /** Maps a backend availability reason to menu guidance. */ - 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; - } - }; - /** Builds one selectable MFA method menu item. */ - const buildOption = (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, - }; - }; - /** Groups locked premium methods separately from methods available in the current plan. */ - const buildMethodGroups = (methods: MfaFlowMethodValue[]): MfaMethodGroup[] => { + + 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 === @@ -88,7 +95,7 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro ]; }; - const addStepMenuOptions = buildMethodGroups(availableMethods).map((group) => ({ + const addStepMenuOptions = buildMethodGroups(methods).map((group) => ({ ...group, items: group.items.map((method) => buildOption(method, () => { @@ -107,44 +114,47 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro (steps.length > 0 && businessLicenseCheck === undefined), }; - const deleteStep = (id: MfaConfigurationStepData['id']) => { - onChange(steps.filter((step) => step.id !== id)); - }; + const deleteStep = useCallback( + (id: MfaConfigurationStepData['id']) => { + onChange(steps.filter((step) => step.id !== id)); + }, + [onChange, steps], + ); - const addMethod = ( - stepId: MfaConfigurationStepData['id'], - method: MfaFlowMethodValue, - ) => { - onChange( - steps.map((step) => - step.id === stepId ? { ...step, methods: [...step.methods, method] } : step, - ), - ); - }; + 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 = ( - stepId: MfaConfigurationStepData['id'], - method: MfaFlowMethodValue, - ) => { - const step = steps.find((item) => item.id === stepId); - if (!step) return; + 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; - } + if (step.methods.length === 1) { + deleteStep(stepId); + return; + } - onChange( - steps.map((item) => - item.id === stepId - ? { - ...item, - methods: item.methods.filter((itemMethod) => itemMethod !== method), - } - : item, - ), - ); - }; + onChange( + steps.map((item) => + item.id === stepId + ? { + ...item, + methods: item.methods.filter((itemMethod) => itemMethod !== method), + } + : item, + ), + ); + }, + [deleteStep, onChange, steps], + ); return (
@@ -155,7 +165,7 @@ export const MfaConfiguration = ({ onChange, steps, error }: MfaConfigurationPro step={step} stepNumber={index + 1} methodGroups={buildMethodGroups( - availableMethods.filter((method) => !step.methods.includes(method)), + methods.filter((method) => !step.methods.includes(method)), )} methodLabels={methodLabels} onDeleteStep={deleteStep} diff --git a/web/src/shared/components/MfaConfiguration/types.ts b/web/src/shared/components/MfaConfiguration/types.ts index 9355dfb40e..6a4a9d70a8 100644 --- a/web/src/shared/components/MfaConfiguration/types.ts +++ b/web/src/shared/components/MfaConfiguration/types.ts @@ -8,7 +8,7 @@ export interface MfaConfigurationStepData { } /** MFA methods grouped for display in an add-method menu. */ -export type MfaMethodGroup = { +export type MfaConfigurationMethodGroup = { header?: { text: string }; items: MfaFlowMethodValue[]; }; @@ -17,7 +17,7 @@ export type MfaMethodGroup = { export type MfaConfigurationStepProps = { step: MfaConfigurationStepData; stepNumber: number; - methodGroups: MfaMethodGroup[]; + methodGroups: MfaConfigurationMethodGroup[]; methodLabels: Record; onDeleteStep: (id: MfaConfigurationStepData['id']) => void; onAddMethod: ( diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 442e4f5777..7cd6f25348 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -55,7 +55,10 @@ export const getMfaFlowsQueryOptions = queryOptions({ export const getMfaMethodAvailabilityQueryOptions = queryOptions({ queryFn: api.mfaFlow.methodAvailability, queryKey: ['mfa-flow', 'method-availability'], - select: (response) => response.data, + select: (response) => ({ + methodAvailability: response.data, + methods: response.data.map(({ method }) => method), + }), }); export const getEdgesQueryOptions = queryOptions({