Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from './components/ConfigureMfaTimeoutProvider';
import { useConfigureMfaStore } from './hooks/useConfigureMfaStore';
import { ConfigureMfaVerify } from './verify/ConfigureMfaVerify';
import { ConfigureSelectMethodsStep } from './verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep';
import { ConfigureMfaWizard } from './wizard/ConfigureMfaWizard';

export const ConfigureMfaPage = () => {
Expand All @@ -19,10 +20,8 @@ export const ConfigureMfaPage = () => {

const ConfigureMfaContent = () => {
const navigate = useNavigate();
// a late authorization on the selection screen waits for the picks
const inWizard = useConfigureMfaStore(
(s) => s.authorized && isPresent(s.selectedMethods),
);
const authorized = useConfigureMfaStore((s) => s.authorized);
const methodsSelected = useConfigureMfaStore((s) => isPresent(s.selectedMethods));
const handleSessionExpired = useConfigureMfaSessionExpired();

const leave = useCallback(() => {
Expand All @@ -31,12 +30,13 @@ const ConfigureMfaContent = () => {

return (
<>
{inWizard && (
<ConfigureMfaWizard onCancel={leave} onSessionExpired={handleSessionExpired} />
)}
{!inWizard && (
{!authorized && (
<ConfigureMfaVerify onCancel={leave} onSessionExpired={handleSessionExpired} />
)}
{authorized && !methodsSelected && <ConfigureSelectMethodsStep onCancel={leave} />}
{authorized && methodsSelected && (
<ConfigureMfaWizard onCancel={leave} onSessionExpired={handleSessionExpired} />
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,10 @@ export const useConfigureMfaSessionExpired = (): (() => void) => {
export const ConfigureMfaTimeoutProvider = ({ children }: PropsWithChildren) => {
const [expired, setExpired] = useState(false);
const deadline = useConfigureMfaStore((s) => s.deadline);
// Picking only the email fallback opens on Finish before verification, hence the auth check.
const settled = useConfigureMfaStore(
(s) =>
s.authorized &&
(s.activeStep === ConfigureMfaStep.RecoveryCodes ||
s.activeStep === ConfigureMfaStep.Finish),
s.activeStep === ConfigureMfaStep.RecoveryCodes ||
s.activeStep === ConfigureMfaStep.Finish,
);

const expire = useCallback(() => setExpired(true), []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Icon } from '../../../../../shared/components/Icon';
import { RadioIndicator } from '../../../../../shared/components/RadioIndicator/RadioIndicator';
import { mfaMethodIcon } from '../../../../../shared/consts';
import { MfaMethod, type MfaMethodValue } from '../../../../../shared/rust-api/types';
import { mfaToText } from '../../../../../shared/utils/mfa';

type FactorText = {
title: string;
Expand All @@ -24,7 +25,7 @@ const factorText = (method: MfaMethodValue): FactorText => {
};
case MfaMethod.Oidc:
return {
title: 'OpenID sign-in',
title: mfaToText(MfaMethod.Oidc),
description: `Sign in with your identity provider in the browser to confirm it's you.`,
};
case MfaMethod.Biometric:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import './style.scss';
import type { ReactNode } 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 { Icon, type IconKindValue } from '../../../../../shared/components/Icon';
import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage';
import { isPresent } from '../../../../../shared/utils/isPresent';

const codeLength = 6;

interface Props {
id: string;
icon: IconKindValue;
title: string;
description: string[];
code: string | null;
error: string | null;
onCodeChange: (value: string) => void;
onSubmit: (pastedCode?: string) => void;
leaveText: string;
onLeave: () => void;
extraAction?: ReactNode;
codeLoading?: boolean;
isLeaving?: boolean;
leaveDisabled?: boolean;
submitLoading?: boolean;
submitDisabled?: boolean;
}

export const MfaCodeVerification = ({
id,
icon,
title,
description,
code,
error,
onCodeChange,
onSubmit,
leaveText,
onLeave,
extraAction,
codeLoading = false,
isLeaving = false,
leaveDisabled = false,
submitLoading = false,
submitDisabled = false,
}: Props) => {
return (
<FullPage id={id} className="mfa-code-verification" hideScrollContainer withControls>
<div className="content">
<Icon icon={icon} size={28} />
<p className="title">{title}</p>
<p className="description">
{description.map((line) => (
<span key={line}>{line}</span>
))}
</p>
<CodeInput
length={codeLength}
value={code}
onChange={onCodeChange}
error={error}
onSubmit={onSubmit}
loading={codeLoading}
/>
</div>
<Controls>
<Button
text={leaveText}
variant={ButtonVariant.Secondary}
loading={isLeaving}
disabled={leaveDisabled}
onClick={onLeave}
/>
<div className="right">
{isPresent(extraAction) && extraAction}
<Button
text="Continue"
variant={ButtonVariant.Primary}
loading={submitLoading}
disabled={submitDisabled}
onClick={() => {
onSubmit();
}}
/>
</div>
</Controls>
</FullPage>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.mfa-code-verification {
padding: var(--spacing-md) var(--spacing-lg);

& > .content {
--icon-color: var(--fg-white-50);

display: flex;
flex: 1;
flex-flow: column;
align-items: center;
justify-content: center;
text-align: center;

& > .title {
padding-top: var(--spacing-2xl);
color: var(--fg-white-100);
font: var(--t-h4);
}

& > .description {
display: flex;
flex-flow: column;
padding: var(--spacing-sm) 0 var(--spacing-2xl);
color: var(--fg-white-70);
font: var(--t-body-sm-400);

span {
color: inherit;
font: inherit;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ describe('applyAuthorization', () => {
});
});

it('waits on the selection when the answer lands after Back', () => {
useConfigureMfaStore.getState().selectMethods([MfaMethod.Fido2]);
useConfigureMfaStore.getState().backFromVerification();
it('authorizes before any factor is picked', () => {
applyAuthorization(sessionId, authorizeResult);

const state = useConfigureMfaStore.getState();
Expand All @@ -66,9 +64,7 @@ describe('applyAuthorization', () => {
expect(state.deadline).not.toBeNull();
});

it('sets up the picks confirmed after a late answer', () => {
useConfigureMfaStore.getState().selectMethods([MfaMethod.Fido2]);
useConfigureMfaStore.getState().backFromVerification();
it('sets up the picks made after authorization', () => {
applyAuthorization(sessionId, authorizeResult);
useConfigureMfaStore.getState().selectMethods([MfaMethod.Fido2]);

Expand All @@ -77,9 +73,7 @@ describe('applyAuthorization', () => {
expect(state.deadline).not.toBeNull();
});

it('finishes when the pick confirmed after a late answer is empty', () => {
useConfigureMfaStore.getState().selectMethods([MfaMethod.Fido2]);
useConfigureMfaStore.getState().backFromVerification();
it('finishes when the pick made after authorization is empty', () => {
applyAuthorization(sessionId, authorizeResult);
useConfigureMfaStore.getState().selectMethods([]);

Expand All @@ -88,15 +82,27 @@ describe('applyAuthorization', () => {
expect(state.deadline).toBeNull();
});

it('keeps the deadline of an unauthorized session with an empty pick', () => {
useConfigureMfaStore.setState({ deadline: '2030-01-01T00:00:00.000Z' });
useConfigureMfaStore.getState().selectMethods([]);
it('returns to the verification picker on Back', () => {
useConfigureMfaStore.getState().selectVerificationMethod(MfaMethod.Email);
useConfigureMfaStore.getState().backFromVerification();

expect(useConfigureMfaStore.getState().verificationMethod).toBeNull();
});

expect(useConfigureMfaStore.getState().deadline).toBe('2030-01-01T00:00:00.000Z');
it('keeps a failed method error for the picker until the next pick', () => {
useConfigureMfaStore.getState().selectVerificationMethod(MfaMethod.Oidc);
useConfigureMfaStore.getState().backFromVerification('Sign-in timed out, try again.');

let state = useConfigureMfaStore.getState();
expect(state.verificationMethod).toBeNull();
expect(state.verificationError).toBe('Sign-in timed out, try again.');

useConfigureMfaStore.getState().selectVerificationMethod(MfaMethod.Email);
state = useConfigureMfaStore.getState();
expect(state.verificationError).toBeNull();
});

it('drops an answer for another session', () => {
useConfigureMfaStore.getState().selectMethods([MfaMethod.Fido2]);
applyAuthorization('session-2', authorizeResult);

expect(useConfigureMfaStore.getState().authorized).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ type StoreValues = {
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. */
/** Null until the selection step, which follows authorization, is done. Empty is valid,
* the email fallback configures a factor on its own. */
selectedMethods: MfaMethodValue[] | null;
/** Pre-ticked in the selection step, from the entry point or an earlier pass. */
/** Pre-ticked in the selection step, from the entry point. */
initialSelection: MfaMethodValue[];
/** most preferred first, from the session since an older Core rejects some factors */
verificationMethods: MfaVerificationMethod[];
/** null until picked, asked only when the session offers more than one method */
verificationMethod: MfaVerificationMethod | null;
/** set when a method fails after the picker handed off to it, shown back on the picker */
verificationError: string | 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. */
Expand Down Expand Up @@ -118,6 +120,7 @@ const defaults: StoreValues = {
initialSelection: [],
verificationMethods: [],
verificationMethod: null,
verificationError: null,
emailFallback: false,
deadline: null,
authorized: false,
Expand All @@ -137,9 +140,7 @@ interface Store extends StoreValues {
) => void;
selectMethods: (methods: MfaMethodValue[]) => void;
selectVerificationMethod: (method: MfaVerificationMethod) => void;
/** Keeps the current picks ticked and the session alive. */
backToSelection: () => void;
backFromVerification: () => void;
backFromVerification: (error?: string) => 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;
Expand Down Expand Up @@ -186,46 +187,22 @@ export const useConfigureMfaStore = create<Store>()(
return {
selectedMethods: methods,
activeStep: firstStep(next),
// picked after a late authorization, so the deadline was kept for it
...(current.authorized && {
deadline: sessionDeadline(next, current.deadline),
}),
deadline: sessionDeadline(next, current.deadline),
};
});
},
selectVerificationMethod: (method) => {
set({ verificationMethod: method });
set({ verificationMethod: method, verificationError: null });
},
backToSelection: () => {
set((current) => ({
initialSelection: current.selectedMethods ?? current.initialSelection,
selectedMethods: null,
verificationMethod: null,
activeStep: defaults.activeStep,
}));
},
backFromVerification: () => {
if (isPresent(get().verificationMethod)) {
set({ verificationMethod: null });
return;
}
get().backToSelection();
backFromVerification: (error) => {
set({ verificationMethod: null, verificationError: error ?? null });
},
authorize: (response) => {
set((current) => {
const deadline = dayjs.unix(response.deadline_timestamp).toISOString();
// a late answer can land after Back, the steps wait for its Continue
if (!isPresent(current.selectedMethods)) {
return { authorized: true, recoveryCodes: response.recovery_codes, deadline };
}
// 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, deadline),
activeStep: firstStep(next),
};
// The fallback enables email as it verifies, so only this authorization issues codes.
set({
authorized: true,
recoveryCodes: response.recovery_codes,
deadline: dayjs.unix(response.deadline_timestamp).toISOString(),
});
},
factorConfigured: (method, recoveryCodes) => {
Expand Down Expand Up @@ -263,7 +240,7 @@ export const useConfigureMfaStore = create<Store>()(
name: 'configure-mfa-store',
storage: createJSONStorage(() => sessionStorage),
// Bumped on every shape change: a stored session is never resumable across one.
version: 13,
version: 15,
},
),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import { useCallback } from 'react';
import { showsVerificationPicker } from '../utils';
import { discardMfaConfiguration, useConfigureMfaStore } from './useConfigureMfaStore';

/** Verification opens the flow, so leaving it ends the session unless a method picker came first. */
export const useLeaveVerification = (onCancel: () => void) => {
const hasPicker = useConfigureMfaStore((s) =>
showsVerificationPicker(s.verificationMethods),
);

const { mutate: cancel, isPending: isLeaving } = useMutation({
mutationFn: discardMfaConfiguration,
onSettled: onCancel,
});

const leave = useCallback(() => {
if (hasPicker) {
useConfigureMfaStore.getState().backFromVerification();
return;
}
cancel();
}, [hasPicker, cancel]);

return { leaveText: hasPicker ? 'Back' : 'Cancel', leave, isLeaving };
};
Loading
Loading