diff --git a/new-ui/package.json b/new-ui/package.json index d0c429887..ef13652aa 100644 --- a/new-ui/package.json +++ b/new-ui/package.json @@ -16,6 +16,7 @@ "dependencies": { "@biomejs/biome": "2.5.14", "@floating-ui/react": "^0.27.20", + "@fluentui/keyboard-keys": "^9.0.9", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@tanstack/react-form": "^1.33.5", diff --git a/new-ui/pnpm-lock.yaml b/new-ui/pnpm-lock.yaml index 398de27a1..d17a43657 100644 --- a/new-ui/pnpm-lock.yaml +++ b/new-ui/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@floating-ui/react': specifier: ^0.27.20 version: 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@fluentui/keyboard-keys': + specifier: ^9.0.9 + version: 9.0.9 '@stablelib/base64': specifier: ^2.0.1 version: 2.0.1 @@ -414,6 +417,9 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fluentui/keyboard-keys@9.0.9': + resolution: {integrity: sha512-DjX4z+dDCB49rsmyDuOwtD28Zbe9CUf86JKi8aUEvO5/TetsiuGPokgJiFIR0S9SGeFbquj5CUmAslk1RpgMzw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -803,6 +809,9 @@ packages: '@stablelib/x25519@2.0.1': resolution: {integrity: sha512-qi04HS2puHaBf50kM/kes5QcZFGsx8yF0YmCjLCOa/LPmnBaKEKX9ZR82OnnCwMn72YH13R/bBZgr/UP0aPFfA==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tanstack/devtools-bundler-core@0.1.3': resolution: {integrity: sha512-F0tlxIyfFqXkZ1mJP1EjtkiSeJA+ztXY2AYOHf7r4goCIEAOp86N9PFJ/yv8vu1TnmxGS6vKsZCS3Kyls9xQQA==} engines: {node: '>=18'} @@ -2725,6 +2734,10 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fluentui/keyboard-keys@9.0.9': + dependencies: + '@swc/helpers': 0.5.23 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2975,6 +2988,10 @@ snapshots: '@stablelib/random': 2.0.1 '@stablelib/wipe': 2.0.1 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tanstack/devtools-bundler-core@0.1.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@tanstack/devtools-client': 0.0.8 diff --git a/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx b/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx index b63cd1ecd..eadd6f505 100644 --- a/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx +++ b/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx @@ -101,7 +101,7 @@ export const AddInstancePage = () => { }); return ( - +

{`To add an instance, provide the instance URL along with a valid provisioning token. These credentials are issued by your administrator and are required to initiate the setup.`}

.scroll-container { - display: flex; - flex-flow: column; - } - .page-description { font: var(--t-body-sm-400); color: var(--fg-white-70); @@ -13,8 +8,4 @@ form { display: contents; } - - .controls { - margin-top: auto; - } } diff --git a/new-ui/src/pages/full/AddPage/AddPage.tsx b/new-ui/src/pages/full/AddPage/AddPage.tsx index e7e0baf00..7ef84b90f 100644 --- a/new-ui/src/pages/full/AddPage/AddPage.tsx +++ b/new-ui/src/pages/full/AddPage/AddPage.tsx @@ -1,18 +1,30 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; +import { useMemo } from 'react'; import { FullPageTitle } from '../../../shared/components/FullPageTitle/FullPageTitle'; import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; import { getInstancesQueryOptions, + mfaConfigurableInstances, tunnelsDisabled, } from '../../../shared/rust-api/query'; import { ThemeSpacing } from '../../../shared/types'; import { AddCard } from './components/AddCard/AddCard'; +import { useStartMfaConfiguration } from './hooks/useStartMfaConfiguration'; export const AddPage = () => { const navigate = useNavigate(); - const { data: instances } = useQuery(getInstancesQueryOptions); + const { data: instances, isPending } = useQuery(getInstancesQueryOptions); + + const mfaInstances = useMemo( + () => mfaConfigurableInstances(instances ?? []), + [instances], + ); + + const { mutate: startConfigureMfa, isPending: configureMfaPending } = + useStartMfaConfiguration(); + return ( @@ -28,7 +40,7 @@ export const AddPage = () => { actionText="Add instance" description={`Establish a secure connection to your Defguard instance effortlessly by configuring it with a single token—no manual setup.`} /> - {!tunnelsDisabled(instances ?? []) && ( + {!isPending && !tunnelsDisabled(instances ?? []) && ( { @@ -41,6 +53,25 @@ export const AddPage = () => { description={`Add and configure a WireGuard tunnel to securely route traffic through an encrypted connection using predefined configuration.`} /> )} + {!isPending && mfaInstances.length > 0 && ( + { + // Below two instances there is nothing to pick, so the flow starts here. + if (mfaInstances.length > 1) { + navigate({ + to: '/full/add/mfa', + }); + return; + } + startConfigureMfa(mfaInstances[0]); + }} + loading={configureMfaPending} + /> + )} ); diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx b/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx index 9db24bd33..123752a31 100644 --- a/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx +++ b/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx @@ -1,19 +1,29 @@ import './style.scss'; import clsx from 'clsx'; import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonSize, ButtonVariant } from '../../../../../shared/components/Button/types'; import defaultImage from './assets/image_1.png'; import yubiImage from './assets/image_2.png'; import wireguardImage from './assets/image_3.png'; +import lockImage from './assets/image_4.png'; interface Props { actionText: string; title: string; description: string; - image: 'default' | 'yubi' | 'wireguard'; + image: 'default' | 'yubi' | 'wireguard' | 'lock'; + loading?: boolean; onClick?: () => void; } -export const AddCard = ({ actionText, description, title, image, onClick }: Props) => { +export const AddCard = ({ + actionText, + description, + title, + image, + loading = false, + onClick, +}: Props) => { const renderImage = () => { switch (image) { case 'default': @@ -46,6 +56,16 @@ export const AddCard = ({ actionText, description, title, image, onClick }: Prop loading="eager" /> ); + case 'lock': + return ( + + ); } }; @@ -56,7 +76,13 @@ export const AddCard = ({ actionText, description, title, image, onClick }: Prop

{title}

{description}

-
diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png b/new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png new file mode 100644 index 000000000..a6cceae01 Binary files /dev/null and b/new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png differ diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/style.scss b/new-ui/src/pages/full/AddPage/components/AddCard/style.scss index e8d89154f..22674bda7 100644 --- a/new-ui/src/pages/full/AddPage/components/AddCard/style.scss +++ b/new-ui/src/pages/full/AddPage/components/AddCard/style.scss @@ -40,6 +40,16 @@ } } + &.image-lock { + .images { + img { + position: absolute; + top: -10px; + left: -10px; + } + } + } + .title { font: var(--t-body-sm-500); color: var(--fg-white-100); @@ -49,7 +59,7 @@ .description { color: var(--color); font: var(--t-body-xs-400); - padding-bottom: var(--spacing-xl); + padding-bottom: var(--spacing-2xl); transition-duration: 160ms; transition-timing-function: ease-out; transition-property: color; @@ -72,11 +82,6 @@ background: var(--bg-dark-blue-20); box-sizing: border-box; padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) var(--spacing-xl); - - .btn-wrap, - .btn { - width: 100%; - } } } } diff --git a/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts b/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts new file mode 100644 index 000000000..f8ce17751 --- /dev/null +++ b/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts @@ -0,0 +1,41 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { error as logError } from '@tauri-apps/plugin-log'; +import { Snackbar } from '../../../../shared/providers/snackbar/snackbar'; +import { + isMfaConfigMissingToken, + isMfaConfigUnsupported, +} from '../../../../shared/rust-api/mfaError'; +import type { InstanceInfo } from '../../../../shared/rust-api/types'; +import { startMfaConfiguration } from '../../ConfigureMfaPage/hooks/useConfigureMfaStore'; + +/** Opens a session and enters the wizard. On failure it stays put, so another instance + * can be tried. */ +export const useStartMfaConfiguration = () => { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: (instance: InstanceInfo) => startMfaConfiguration(instance), + onSuccess: () => { + navigate({ + to: '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/full/configure-mfa', + }); + }, + onError: (err) => { + void logError(`MFA configuration start failed: ${err}`); + if (isMfaConfigUnsupported(err)) { + Snackbar.error( + 'This Defguard instance does not support configuring MFA from the client.', + ); + return; + } + if (isMfaConfigMissingToken(err)) { + Snackbar.error( + 'This device has no polling token; update the instance and try again.', + ); + return; + } + Snackbar.error('Could not start MFA configuration.'); + }, + }); +}; diff --git a/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx b/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx index 4b27747f7..7acb6508f 100644 --- a/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx +++ b/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx @@ -10,7 +10,7 @@ import { useTunnelWizardStore } from '../TunnelWizardPage/hooks/useTunnelWizardS export const AddTunnelPage = () => { const navigate = useNavigate(); return ( - +

{`A WireGuard tunnel is a secure, encrypted connection that allows your device or network to communicate safely over the internet.It ensures that your data is protected and transmitted through a private, trusted channel.`}

diff --git a/new-ui/src/pages/full/AddTunnelPage/styles.scss b/new-ui/src/pages/full/AddTunnelPage/styles.scss index 1ef9f59b9..44dc80847 100644 --- a/new-ui/src/pages/full/AddTunnelPage/styles.scss +++ b/new-ui/src/pages/full/AddTunnelPage/styles.scss @@ -10,8 +10,4 @@ color: var(--fg-white-70); } } - - .controls { - margin-top: auto; - } } diff --git a/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx b/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx new file mode 100644 index 000000000..862898d1b --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx @@ -0,0 +1,33 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useCallback } from 'react'; +import { ConfigureMfaTimeoutProvider } from './components/ConfigureMfaTimeoutProvider'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from './hooks/useConfigureMfaStore'; +import { ConfigureMfaVerify } from './verify/ConfigureMfaVerify'; +import { ConfigureMfaWizard } from './wizard/ConfigureMfaWizard'; + +export const ConfigureMfaPage = () => { + const navigate = useNavigate(); + const authorized = useConfigureMfaStore((s) => s.authorized); + + const leave = useCallback(() => { + navigate({ to: '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/full/add' }); + }, [navigate]); + + const handleSessionExpired = useCallback(() => { + void discardMfaConfiguration(); + leave(); + }, [leave]); + + return ( + + {authorized ? ( + + ) : ( + + )} + + ); +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx new file mode 100644 index 000000000..c3c5c3c83 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx @@ -0,0 +1,34 @@ +import { useNavigate } from '@tanstack/react-router'; +import { type PropsWithChildren, useEffect } from 'react'; +import { Snackbar } from '../../../../shared/providers/snackbar/snackbar'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../hooks/useConfigureMfaStore'; + +/** Recover at the deadline rather than let the user type a code that cannot land. */ +export const ConfigureMfaTimeoutProvider = ({ children }: PropsWithChildren) => { + const navigate = useNavigate(); + const deadline = useConfigureMfaStore((s) => s.deadline); + + useEffect(() => { + if (!deadline) return; + + const expire = () => { + void discardMfaConfiguration(); + Snackbar.error('MFA configuration session expired, start again.'); + void navigate({ to: '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/full/add', replace: true }); + }; + + const ms = new Date(deadline).getTime() - Date.now(); + if (ms <= 0) { + expire(); + return; + } + + const timer = setTimeout(expire, ms); + return () => clearTimeout(timer); + }, [deadline, navigate]); + + return children; +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx new file mode 100644 index 000000000..332cf1c56 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx @@ -0,0 +1,223 @@ +import { error as logError } from '@tauri-apps/plugin-log'; +import dayjs from 'dayjs'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { api } from '../../../../shared/rust-api/api'; +import { + type InstanceInfo, + type MfaConfigAuthorizeResult, + type MfaConfigStartResult, + MfaMethod, + type MfaMethodValue, +} from '../../../../shared/rust-api/types'; +import { isPresent } from '../../../../shared/utils/isPresent'; +import { + ConfigureMfaStep, + type ConfigureMfaStepValue, + isCodeMfaMethod, + isMfaFactorOfferable, + isMfaSetupStep, + MFA_WIZARD_STEPS, + mfaFactorStep, +} from '../types'; + +type StoreValues = { + activeStep: ConfigureMfaStepValue; + instance: InstanceInfo | null; + sessionId: string | null; + /** Snapshot taken at the start of the session and never moved, or a repeatable factor would + * look configured before it was offered. */ + configuredMethods: MfaMethodValue[]; + /** Factors this run has set up, which is what the wizard steps through. */ + completedMethods: MfaMethodValue[]; + /** Null until the selection step is done. Empty is valid, the email fallback configures + * a factor on its own. */ + selectedMethods: MfaMethodValue[] | null; + /** No factor was configured, so an emailed code was the only way in. */ + emailFallback: boolean; + /** ISO timestamp the session dies at, null once nothing in the flow needs it. */ + deadline: string | null; + authorized: boolean; + /** Issued for the account's first factor only, so an empty list is an ordinary success. */ + recoveryCodes: string[]; +}; + +type FlowState = Pick< + StoreValues, + 'configuredMethods' | 'completedMethods' | 'selectedMethods' | 'recoveryCodes' +>; + +/** Picked factors still to set up, in wizard order. */ +const pendingMethods = (state: FlowState): MfaMethodValue[] => + state.selectedMethods?.filter( + (method) => + !state.completedMethods.includes(method) && + // Guards against a pick the selection screen should already have refused. + isMfaFactorOfferable(method, state.configuredMethods), + ) ?? []; + +/** Setup steps with a factor still pending, plus the closing steps that have something to show. */ +const remainingSteps = (state: FlowState): ConfigureMfaStepValue[] => { + const pending = pendingMethods(state); + return MFA_WIZARD_STEPS.filter((step) => { + switch (step) { + case ConfigureMfaStep.RecoveryCodes: + return state.recoveryCodes.length > 0; + case ConfigureMfaStep.Finish: + return true; + default: + return pending.some((method) => mfaFactorStep(method) === step); + } + }); +}; + +/** Picking only the email fallback leaves no setup step, so the wizard opens on what follows. */ +const firstStep = (state: FlowState): ConfigureMfaStepValue => + remainingSteps(state)[0] ?? ConfigureMfaStep.Finish; + +/** The session is only needed while a setup is still to come. Letting it run past the last one + * would expire the flow under a user still reading their recovery codes. */ +const sessionDeadline = (state: FlowState, deadline: string | null): string | null => + pendingMethods(state).length > 0 ? deadline : null; + +const defaults: StoreValues = { + activeStep: ConfigureMfaStep.Configuration, + instance: null, + sessionId: null, + configuredMethods: [], + completedMethods: [], + selectedMethods: null, + emailFallback: false, + deadline: null, + authorized: false, + recoveryCodes: [], +}; + +interface Store extends StoreValues { + start: (instance: InstanceInfo, response: MfaConfigStartResult) => void; + selectMethods: (methods: MfaMethodValue[]) => void; + /** The fresh deadline bounds every setup still to come, not just the next one. */ + authorize: (response: MfaConfigAuthorizeResult) => void; + factorConfigured: (method: MfaMethodValue, recoveryCodes: string[]) => void; + next: () => void; + back: () => void; + reset: () => void; +} + +export const useConfigureMfaStore = create()( + persist( + (set, get) => ({ + ...defaults, + start: (instance, response) => { + // The fallback mails a code to the address on file, registering email along the way. + const codeFactors = response.email_fallback + ? [MfaMethod.Email] + : response.available_methods; + const configuredMethods = [ + ...codeFactors, + ...(instance.mfa_configured_methods ?? []).filter( + (method) => !isCodeMfaMethod(method), + ), + ]; + set({ + ...defaults, + instance, + sessionId: response.session_id, + configuredMethods, + emailFallback: response.email_fallback, + deadline: dayjs.unix(response.deadline_timestamp).toISOString(), + }); + }, + selectMethods: (methods) => { + set((current) => ({ + selectedMethods: methods, + activeStep: firstStep({ ...current, selectedMethods: methods }), + })); + }, + authorize: (response) => { + set((current) => { + // The fallback enables email as it verifies, so only this authorization issues codes. + const next = { ...current, recoveryCodes: response.recovery_codes }; + return { + authorized: true, + recoveryCodes: response.recovery_codes, + deadline: sessionDeadline( + next, + dayjs.unix(response.deadline_timestamp).toISOString(), + ), + activeStep: firstStep(next), + }; + }); + }, + factorConfigured: (method, recoveryCodes) => { + set((current) => { + const completedMethods = current.completedMethods.includes(method) + ? current.completedMethods + : [...current.completedMethods, method]; + const codes = recoveryCodes.length > 0 ? recoveryCodes : current.recoveryCodes; + return { + completedMethods, + recoveryCodes: codes, + deadline: sessionDeadline( + { ...current, completedMethods, recoveryCodes: codes }, + current.deadline, + ), + }; + }); + }, + next: () => { + const current = get(); + const from = MFA_WIZARD_STEPS.indexOf(current.activeStep); + // A setup step holds the flow while it still has a picked factor to configure. + const next = remainingSteps(current).find( + (step) => + MFA_WIZARD_STEPS.indexOf(step) > from || + (step === current.activeStep && isMfaSetupStep(step)), + ); + if (isPresent(next)) set({ activeStep: next }); + }, + back: () => { + const current = get(); + const from = MFA_WIZARD_STEPS.indexOf(current.activeStep); + // A step with nothing left to do is not one to go back to. + const previous = remainingSteps(current) + .filter((step) => MFA_WIZARD_STEPS.indexOf(step) < from) + .at(-1); + if (isPresent(previous)) set({ activeStep: previous }); + }, + reset: () => { + set({ ...defaults }); + }, + }), + { + name: 'configure-mfa-store', + storage: createJSONStorage(() => sessionStorage), + // Bumped when setup progress moved to its own list, so older sessions start over rather + // than resume believing a configured factor is still pending. + version: 7, + }, + ), +); + +/** The factor a setup step is currently working on. */ +export const selectPendingMethod = + (step: ConfigureMfaStepValue) => + (state: Store): MfaMethodValue | undefined => + pendingMethods(state).find((method) => mfaFactorStep(method) === step); + +export const startMfaConfiguration = async (instance: InstanceInfo): Promise => { + const response = await api.mfaConfigStart(instance.id); + useConfigureMfaStore.getState().start(instance, response); +}; + +/** A copy the proxy still holds expires on its own, so a failed cancel is not worth raising. */ +export const discardMfaConfiguration = async (): Promise => { + const { sessionId } = useConfigureMfaStore.getState(); + useConfigureMfaStore.getState().reset(); + if (!isPresent(sessionId)) return; + try { + await api.mfaConfigCancel(sessionId); + } catch (err) { + void logError(`Failed to cancel MFA configuration session: ${err}`); + } +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts new file mode 100644 index 000000000..37e54e52b --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts @@ -0,0 +1,59 @@ +import { error as logError } from '@tauri-apps/plugin-log'; +import { useCallback } from 'react'; +import { + isMfaConfigCancelled, + isMfaConfigInvalidCode, + isMfaConfigProxyError, + isMfaConfigSecurityKeyError, + isMfaConfigSessionExpired, + mfaErrorMessage, +} from '../../../../shared/rust-api/mfaError'; + +type Options = { + context: string; + setError: (message: string) => void; + onSessionExpired: () => void; + /** Copy for anything untagged, never the raw string, which may be a Rust message. */ + fallback: string; + /** Whether this step has a code field. Without one, a rejection gets Defguard's own message + * instead, since "Invalid code" would point at an input the user cannot see. */ + hasCodeInput?: boolean; +}; + +/** Shared `MfaConfigError` classification, so every step of the flow reacts the same way. */ +export const useMfaConfigErrorHandler = ({ + context, + setError, + onSessionExpired, + fallback, + hasCodeInput = true, +}: Options) => + useCallback( + (err: unknown) => { + // A cancel is the user's own doing, so it is neither logged nor shown. + if (isMfaConfigCancelled(err)) { + return; + } + void logError(`${context}: ${err}`); + if (isMfaConfigInvalidCode(err)) { + setError(hasCodeInput ? 'Invalid code' : mfaErrorMessage(err)); + return; + } + if (isMfaConfigSessionExpired(err)) { + setError('Configuration session expired, start again.'); + onSessionExpired(); + return; + } + // The backend writes these for the user (no key, wrong PIN, no touch), so show as is. + if (isMfaConfigSecurityKeyError(err)) { + setError(mfaErrorMessage(err)); + return; + } + if (isMfaConfigProxyError(err)) { + setError('Service temporarily unavailable, try again.'); + return; + } + setError(fallback); + }, + [context, setError, onSessionExpired, fallback, hasCodeInput], + ); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/types.ts b/new-ui/src/pages/full/ConfigureMfaPage/types.ts new file mode 100644 index 000000000..5e88c19c0 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/types.ts @@ -0,0 +1,69 @@ +import { MfaMethod, type MfaMethodValue } from '../../../shared/rust-api/types'; + +/** Wizard steps, in the order they run. Setup steps are the ones a factor can claim. */ +export const ConfigureMfaStep = { + Configuration: 'configuration', + Fido2: 'fido2', + RecoveryCodes: 'recovery-codes', + Finish: 'finish', +} as const; + +export type ConfigureMfaStepValue = + (typeof ConfigureMfaStep)[keyof typeof ConfigureMfaStep]; + +export const MFA_WIZARD_STEPS: ConfigureMfaStepValue[] = [ + ConfigureMfaStep.Configuration, + ConfigureMfaStep.Fido2, + ConfigureMfaStep.RecoveryCodes, + ConfigureMfaStep.Finish, +]; + +type MfaFactor = { + method: MfaMethodValue; + /** Factors sharing a step are configured one after another on it. */ + step: ConfigureMfaStepValue; + /** Whether the account can hold several of these, so it stays offerable once configured. */ + repeatable: boolean; +}; + +/** Every factor this client can set up, in selection and wizard order. */ +export const MFA_CONFIGURABLE_FACTORS: MfaFactor[] = [ + { method: MfaMethod.Totp, step: ConfigureMfaStep.Configuration, repeatable: false }, + { method: MfaMethod.Email, step: ConfigureMfaStep.Configuration, repeatable: false }, + { method: MfaMethod.Fido2, step: ConfigureMfaStep.Fido2, repeatable: true }, +]; + +export const mfaFactor = (method: MfaMethodValue): MfaFactor | undefined => + MFA_CONFIGURABLE_FACTORS.find((factor) => factor.method === method); + +export const mfaFactorStep = ( + method: MfaMethodValue, +): ConfigureMfaStepValue | undefined => mfaFactor(method)?.step; + +/** Whether the step sets a factor up, as opposed to closing the flow. */ +export const isMfaSetupStep = (step: ConfigureMfaStepValue): boolean => + MFA_CONFIGURABLE_FACTORS.some((factor) => factor.step === step); + +/** The steps the given factors are set up on, in wizard order and without repeats. */ +export const mfaStepsOf = (methods: MfaMethodValue[]): ConfigureMfaStepValue[] => + MFA_WIZARD_STEPS.filter((step) => + methods.some((method) => mfaFactorStep(method) === step), + ); + +export const isMfaFactorOfferable = ( + method: MfaMethodValue, + configuredMethods: MfaMethodValue[], +): boolean => { + const factor = mfaFactor(method); + if (!factor) return false; + return factor.repeatable || !configuredMethods.includes(method); +}; + +/** Factors that can authorize a session, most preferred first. Core only accepts code factors. */ +export const MFA_VERIFICATION_METHODS = [MfaMethod.Totp, MfaMethod.Email] as const; + +export type MfaVerificationMethod = (typeof MFA_VERIFICATION_METHODS)[number]; + +/** Code factors are the only ones a session reports, the rest come from the instance snapshot. */ +export const isCodeMfaMethod = (method: MfaMethodValue): boolean => + MFA_VERIFICATION_METHODS.some((code) => code === method); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx new file mode 100644 index 000000000..9df6ff016 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx @@ -0,0 +1,43 @@ +import { MfaMethod } from '../../../../shared/rust-api/types'; +import { isPresent } from '../../../../shared/utils/isPresent'; +import { useConfigureMfaStore } from '../hooks/useConfigureMfaStore'; +import { MFA_VERIFICATION_METHODS, type MfaVerificationMethod } from '../types'; +import { ConfigureSelectMethodsStep } from './ConfigureSelectMethodsStep/ConfigureSelectMethodsStep'; +import { ConfigureVerifyEmailStep } from './ConfigureVerifyEmailStep/ConfigureVerifyEmailStep'; +import { ConfigureVerifyTotpStep } from './ConfigureVerifyTotpStep/ConfigureVerifyTotpStep'; + +type Props = { + onCancel: () => void; + onSessionExpired: () => void; +}; + +/** Picks what the wizard sets up, then verifies the session with an existing factor. */ +export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => { + const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); + const methodsSelected = useConfigureMfaStore((s) => isPresent(s.selectedMethods)); + + const method: MfaVerificationMethod = + MFA_VERIFICATION_METHODS.find((candidate) => configuredMethods.includes(candidate)) ?? + MfaMethod.Email; + + if (!methodsSelected) { + return ; + } + + switch (method) { + case MfaMethod.Totp: + return ( + + ); + case MfaMethod.Email: + return ( + + ); + } +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx new file mode 100644 index 000000000..b86e7b04e --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx @@ -0,0 +1,135 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { Checkbox } from '../../../../../shared/components/Checkbox/Checkbox'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { FieldError } from '../../../../../shared/components/FieldError/FieldError'; +import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +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 } from '../../../../../shared/rust-api/types'; +import { mfaToText } from '../../../../../shared/utils/mfa'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { isMfaFactorOfferable, MFA_CONFIGURABLE_FACTORS } from '../../types'; +import '../style.scss'; +import './style.scss'; + +interface Props { + onCancel: () => void; +} + +const EMAIL_REQUIRED_TOOLTIP = + 'Email is required: it is the factor this session is being verified with.'; + +/** Opens the flow, the picks here are what the wizard sets up afterwards. */ +export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { + const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); + const emailFallback = useConfigureMfaStore((s) => s.emailFallback); + + const [selected, setSelected] = useState([]); + const [error, setError] = useState(null); + + const { mutate: cancel, isPending: isCancelling } = useMutation({ + mutationFn: discardMfaConfiguration, + onSettled: onCancel, + }); + + const toggle = useCallback((method: MfaMethodValue) => { + setError(null); + // Listing order is the order the wizard sets them up in. + setSelected((current) => + current.includes(method) + ? current.filter((picked) => picked !== method) + : MFA_CONFIGURABLE_FACTORS.map((factor) => factor.method).filter( + (candidate) => candidate === method || current.includes(candidate), + ), + ); + }, []); + + const handleSubmit = useCallback(() => { + // The fallback configures email on its own, so it satisfies the one-factor minimum. + if (selected.length === 0 && !emailFallback) { + setError('Select at least one method'); + return; + } + useConfigureMfaStore.getState().selectMethods(selected); + }, [selected, emailFallback]); + + return ( + + +

+ {`Pick the multi-factor authentication methods you want to set up.`} + {`Methods already configured on your account are listed for reference.`} +

+
+ {MFA_CONFIGURABLE_FACTORS.map(({ method }) => { + // Not a pick the user can drop, the fallback registers email as it verifies. + const isRequiredEmail = emailFallback && method === MfaMethod.Email; + if (isRequiredEmail) { + return ( + + +
+ +
+
+ +

{EMAIL_REQUIRED_TOOLTIP}

+
+
+ ); + } + if (!isMfaFactorOfferable(method, configuredMethods)) { + return ( +
+

{mfaToText(method)}

+ Configured +
+ ); + } + return ( +
+ { + toggle(method); + }} + /> +
+ ); + })} +
+ + +
+ +
+ ); +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss new file mode 100644 index 000000000..725cc0ed9 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss @@ -0,0 +1,22 @@ +#configure-select-methods-step { + .methods { + @include surface-row-stack; + } + + .method-row { + @include surface-row; + + &.configured { + .name { + font: var(--t-body-sm-400); + color: var(--fg-white-70); + } + + .tag { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + white-space: nowrap; + } + } + } +} diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx new file mode 100644 index 000000000..a5cc9a970 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx @@ -0,0 +1,165 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { CodeInput } from '../../../../../shared/components/CodeInput/CodeInput'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; +import { api } from '../../../../../shared/rust-api/api'; +import { MfaMethod } from '../../../../../shared/rust-api/types'; +import { isPresent } from '../../../../../shared/utils/isPresent'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { useMfaConfigErrorHandler } from '../../hooks/useMfaConfigErrorHandler'; +import '../style.scss'; + +const CODE_LENGTH = 6; + +interface Props { + onCancel: () => void; + /** The session outlived its deadline, so the whole flow has to start over. */ + onSessionExpired: () => void; +} + +/** The fallback when no factor exists, or email picked from the configured code factors. */ +export const ConfigureVerifyEmailStep = ({ onCancel, onSessionExpired }: Props) => { + const sessionId = useConfigureMfaStore((s) => s.sessionId); + + const [code, setCode] = useState(null); + const [error, setError] = useState(null); + + const handleApiError = useMfaConfigErrorHandler({ + context: 'Email MFA configuration verification failed', + setError, + onSessionExpired, + fallback: 'Verification failed', + }); + + // A second call invalidates the code already sent, so fire once, StrictMode included. + const requestedFor = useRef(null); + + const { mutate: requestCode, isPending: isRequestingCode } = useMutation({ + mutationFn: async () => { + if (!isPresent(sessionId)) { + throw new Error('No MFA configuration session'); + } + await api.mfaConfigSendCode(sessionId); + }, + onError: (err) => { + requestedFor.current = null; + handleApiError(err); + }, + }); + + useEffect(() => { + if (!isPresent(sessionId)) return; + if (requestedFor.current === sessionId) return; + requestedFor.current = sessionId; + requestCode(); + }, [sessionId, requestCode]); + + const { mutate: submitCode, isPending: isSubmitting } = useMutation({ + mutationFn: async (value: string) => { + if (!isPresent(sessionId)) { + throw new Error('No MFA configuration session'); + } + const result = await api.mfaConfigAuthorize(sessionId, MfaMethod.Email, value); + useConfigureMfaStore.getState().authorize(result); + }, + onError: handleApiError, + }); + + const { mutate: cancel, isPending: isCancelling } = useMutation({ + mutationFn: discardMfaConfiguration, + onSettled: onCancel, + }); + + const isBusy = isRequestingCode || isSubmitting || isCancelling; + + const handleSubmit = useCallback( + (pastedCode?: string) => { + if (isBusy) return; + const toSubmit = (pastedCode ?? code)?.trim(); + if (toSubmit?.length !== CODE_LENGTH) { + setError('Enter a valid code'); + return; + } + submitCode(toSubmit); + }, + [code, isBusy, submitCode], + ); + + const handleResend = useCallback(() => { + if (isBusy || !isPresent(sessionId)) return; + requestedFor.current = sessionId; + setCode(null); + setError(null); + requestCode(); + }, [isBusy, requestCode, sessionId]); + + // Only real input clears the error, CodeInput's own reset passes ''. + const handleCodeChange = useCallback((value: string) => { + setCode(value); + if (value.length > 0) setError(null); + }, []); + + return ( + + +

+ {`We've sent a verification code to your email address.`} + {`Please check your inbox and enter the 6-digit code from the email to continue.`} +

+
+ { + handleSubmit(); + }} + onSuccessPaste={(value) => { + handleSubmit(value); + }} + /> +
+ + + + ); + })} + + +