diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx index 9a9ed86c..0c7ef3bc 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx @@ -1,18 +1,25 @@ import { useMutation } from '@tanstack/react-query'; -import { Fragment, useCallback, useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { fork } from 'radashi'; +import { useCallback, useMemo, useState } from 'react'; import { Button } from '../../../../../shared/components/Button/Button'; import { ButtonVariant } from '../../../../../shared/components/Button/types'; import { Controls } from '../../../../../shared/components/Controls/Controls'; -import { Divider } from '../../../../../shared/components/Divider/Divider'; +import { FactorSelector } from '../../../../../shared/components/FactorSelector/FactorSelector'; import { FieldError } from '../../../../../shared/components/FieldError/FieldError'; import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +import { Icon, IconKind } from '../../../../../shared/components/Icon'; +import { SelectionItemVariant } from '../../../../../shared/components/SelectionItem/types'; import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; +import { TooltipContent } from '../../../../../shared/providers/tooltip/TooltipContent'; +import { TooltipProvider } from '../../../../../shared/providers/tooltip/TooltipContext'; +import { TooltipTrigger } from '../../../../../shared/providers/tooltip/TooltipTrigger'; import { MfaMethod, type MfaMethodValue, type MfaStep, } from '../../../../../shared/rust-api/types'; -import { ThemeSpacing } from '../../../../../shared/types'; +import { ThemeVariable } from '../../../../../shared/types'; import { isPresent } from '../../../../../shared/utils/isPresent'; import { CLIENT_CONFIGURABLE_METHODS, @@ -30,7 +37,16 @@ import { import { isMfaFactorOfferable } from '../../utils'; import '../style.scss'; import './style.scss'; -import { MethodRow, type MethodRowState } from './components/MethodRow'; + +/** What one row has to say, resolved by the step so the markup only reads it. */ +type MethodRowState = { + checked: boolean; + /** Nothing left for the user to decide here, the badge and the hint say why. */ + disabled: boolean; + badge?: string; + /** Why the row is disabled, shown on hover when the badge alone is too terse. */ + hint: string | null; +}; interface Props { onCancel: () => void; @@ -77,24 +93,18 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { [configuredMethods, instance], ); - const groups = useMemo(() => { - let result: MfaMethodValue[][] = [[...CLIENT_CONFIGURABLE_METHODS]]; - if (isLocationAware) { - result = locationSteps.map((step) => step.methods.map((entry) => entry.method)); - } - // With a single list there is no step order to preserve, so park what can't be - // picked at the bottom. Sort is stable, keeping the listing order within a bucket. - if (result.length === 1) { - return [ - [...result[0]].sort( - (a, b) => - Number(!isMfaFactorOfferable(a, configuredMethods, setupMethods)) - - Number(!isMfaFactorOfferable(b, configuredMethods, setupMethods)), - ), - ]; - } - return result; - }, [isLocationAware, locationSteps, configuredMethods, setupMethods]); + const groups = useMemo( + () => locationSteps.map((step) => step.methods.map((entry) => entry.method)), + [locationSteps], + ); + + const [availableMethods, lockedMethods] = useMemo( + () => + fork(CLIENT_CONFIGURABLE_METHODS, (method) => + isMfaFactorOfferable(method, configuredMethods, setupMethods), + ), + [configuredMethods, setupMethods], + ); const describeMethod = useCallback( (method: MfaMethodValue): MethodRowState => { @@ -105,28 +115,35 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { const satisfied = configuredMethods.includes(method) && available; let hint: string | null = null; + let badge: string | undefined; if (!available) { hint = `${mfaToText(method, instance ?? undefined)} is not available on this Defguard instance.`; + badge = 'Unavailable'; } else if (disabled && !configured) { if (satisfied) { // Reached only via the email fallback, which registers the factor as it verifies. hint = `${mfaToText(method)} is required to continue.`; + badge = 'Required'; } else if (isClientConfigurableMethod(method)) { hint = `This Defguard instance does not support configuring ${mfaToText(method)} from the desktop client.`; + badge = 'Not configurable'; } else { hint = `${mfaToText(method)} cannot be configured in the desktop client.`; + badge = 'Not configurable'; } } + if (configured) { + badge = 'Configured'; + } return { - method, // Pre-ticked only for a factor that answers a step on its own; a mobile-only one does // not, however configured it is. checked: selected.includes(method) || (disabled && satisfied && isDesktopDrivableMethod(method)), disabled, - configured, + badge, hint, }; }, @@ -184,9 +201,33 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { useConfigureMfaStore.getState().selectMethods(selected); }, [selected, emailFallback, isLocationAware, locationSteps, isStepSatisfied]); - const renderMethod = (method: MfaMethodValue) => ( - - ); + /** Without `explained` the row drops its badge and hint, leaving the reason to its section. */ + const renderMethod = (method: MfaMethodValue, explained: boolean) => { + const { checked, disabled, badge, hint } = describeMethod(method); + const row = ( + { + toggle(method); + }} + /> + ); + if (!explained || !isPresent(hint)) return row; + return ( + + {row} + +

{hint}

+
+
+ ); + }; return ( { withControls >

{isLocationAware && isPresent(location) ? ( @@ -205,23 +248,60 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { {`If multiple authentication options are available, you can choose your preferred method below. Otherwise, simply start the setup process to get access to the location.`} ) : ( - <> - {`Pick the multi-factor authentication methods you want to set up.`} - {`Methods already configured on your account are listed for reference.`} - + {`Pick the multi-factor authentication methods you want to set up.`} )}

-
- {groups.map((methods, index) => ( - // Index as key: Core owns the order and it never reorders here. - - {index > 0 && } -
- {groups.length > 1 &&

{`Step ${index + 1}:`}

} -
{methods.map(renderMethod)}
+
+ {isLocationAware ? ( + groups.map((methods, index) => ( + // Index as key: Core owns the order and it never reorders here. +
+ {groups.length > 1 && ( +
+

{`Step ${index + 1}:`}

+
+ )} +
+ {methods.map((method) => renderMethod(method, true))} +
- - ))} + )) + ) : ( + <> + {availableMethods.length > 0 && ( +
+
+

Available methods

+
+
+ {availableMethods.map((method) => renderMethod(method, false))} +
+
+ )} + {lockedMethods.length > 0 && ( +
+
+

Not configurable

+ + + + + +

{`Some MFA methods are already configured or unavailable due to version incompatibility.`}

+
+
+
+
+ {lockedMethods.map((method) => renderMethod(method, false))} +
+
+ )} + + )}
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/MethodRow.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/MethodRow.tsx deleted file mode 100644 index 6326db3b..00000000 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/MethodRow.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Badge } from '../../../../../../shared/components/Badge/Badge'; -import { Checkbox } from '../../../../../../shared/components/Checkbox/Checkbox'; -import { Icon } from '../../../../../../shared/components/Icon'; -import { TooltipContent } from '../../../../../../shared/providers/tooltip/TooltipContent'; -import { TooltipProvider } from '../../../../../../shared/providers/tooltip/TooltipContext'; -import { TooltipTrigger } from '../../../../../../shared/providers/tooltip/TooltipTrigger'; -import type { MfaMethodValue } from '../../../../../../shared/rust-api/types'; -import { ThemeVariable } from '../../../../../../shared/types'; -import { isPresent } from '../../../../../../shared/utils/isPresent'; -import { mfaToText } from '../../../../../../shared/utils/mfa'; -import './style.scss'; - -/** What one row has to say, resolved by the step so the markup only reads it. */ -export type MethodRowState = { - method: MfaMethodValue; - checked: boolean; - /** Nothing left for the user to decide here, the hint or the badge says why. */ - disabled: boolean; - /** The factor is already on the account, as opposed to one this flow will register. */ - configured: boolean; - /** Why the row is disabled, when a badge does not already say it. */ - hint: string | null; -}; - -interface Props extends MethodRowState { - onToggle: (method: MfaMethodValue) => void; -} - -export const MethodRow = ({ - method, - checked, - disabled, - configured, - hint, - onToggle, -}: Props) => ( -
- { - onToggle(method); - }} - /> - {(configured || isPresent(hint)) && ( -
- {configured && } - {isPresent(hint) && ( - - - - - -

{hint}

-
-
- )} -
- )} -
-); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/style.scss deleted file mode 100644 index d353dcd6..00000000 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/components/style.scss +++ /dev/null @@ -1,16 +0,0 @@ -.method-row { - display: grid; - grid-template-rows: 1fr; - grid-template-columns: auto minmax(0, 1fr); - column-gap: var(--spacing-md); - align-items: center; - width: 100%; - - > .right { - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-end; - user-select: none; - } -} diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss index 388a93e3..526790f3 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss @@ -1,25 +1,38 @@ #configure-select-methods-step { - .steps { + & .steps { display: flex; flex-flow: column; + row-gap: var(--spacing-2xl); width: 100%; padding-bottom: var(--spacing-xl); - } - - .step { - display: flex; - flex-flow: column; - row-gap: var(--spacing-md); - .step-label { - font: var(--t-body-xs-400); - color: var(--fg-white-70); + &.sections { + row-gap: var(--spacing-4xl); } - .methods { + & > .step { display: flex; flex-flow: column; row-gap: var(--spacing-md); + + & > .label { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-sm); + + & > p { + font: var(--t-body-xs-500); + color: var(--fg-white-100); + } + } + + & > .methods { + display: flex; + flex-flow: column; + row-gap: var(--spacing-md); + } } } }