({ [ALERTS]: {} }));
-
const challenge: LoginChallenge = {
challenge_id: "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91",
- state: LOGIN_CHALLENGE_STATE.MFA_REQUIRED,
+ state: LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED,
expires_at: 1758480300,
user_email: "finops.ffc@example.com",
methods: [],
@@ -34,52 +18,39 @@ const challenge: LoginChallenge = {
attempts_left: 5
};
-const renderAt = (entry: { pathname: string; state?: { challenge: LoginChallenge } }) => {
- const container = document.createElement("div");
- const root = createRoot(container);
-
- const wrap = (children: ReactNode) => (
-
-
-
-
-
-
-
- {/* Stands in for the login page, so a redirect is observable in the DOM. */}
- } />
-
-
-
-
-
-
- );
-
- act(() => {
- root.render(
- wrap(
)
- );
+const renderAt = (initialEntry: { pathname: string; state?: { challenge: LoginChallenge } }) =>
+ renderWithProviders({
+ initialEntry,
+ routes: (
+ <>
+
+ }
+ />
+
} />
+ >
+ )
});
- return {
- container,
- unmount: () => act(() => root.unmount())
- };
-};
-
describe("LoginChallengeStep", () => {
it("shows which account is signing in", () => {
- const { container, unmount } = renderAt({ pathname: MFA_CHALLENGE, state: { challenge } });
+ const { container, unmount } = renderAt({ pathname: MFA_ENROLLMENT, state: { challenge } });
expect(container.querySelector("[data-test-id='p_signing_in_as']")?.textContent).toContain("finops.ffc@example.com");
- expect(container.querySelector("[data-test-id='p_login_challenge_title']")?.textContent).toBe("Two-step verification");
+ expect(container.querySelector("[data-test-id='p_login_challenge_title']")?.textContent).toBe(
+ "Set up two-step verification"
+ );
unmount();
});
it("sends a visitor who never passed the password step back to login", () => {
- const { container, unmount } = renderAt({ pathname: MFA_CHALLENGE });
+ const { container, unmount } = renderAt({ pathname: MFA_ENROLLMENT });
expect(container.querySelector("[data-test-id='login_page']")).not.toBeNull();
expect(container.querySelector("[data-test-id='p_signing_in_as']")).toBeNull();
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx
new file mode 100644
index 000000000..6c84e9e8e
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx
@@ -0,0 +1,16 @@
+import Button from "components/Button";
+import { LOGIN } from "urls";
+
+const BackToLoginButton = () => (
+
+);
+
+export default BackToLoginButton;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/ChallengeStatus.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/ChallengeStatus.tsx
new file mode 100644
index 000000000..cb98563bf
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/ChallengeStatus.tsx
@@ -0,0 +1,26 @@
+import Typography from "@mui/material/Typography";
+import { FormattedMessage } from "react-intl";
+import { formatCountdown } from "./utils";
+
+type ChallengeStatusProps = {
+ secondsUntilExpiry: number;
+ attemptsLeft: number;
+};
+
+const ChallengeStatus = ({ secondsUntilExpiry, attemptsLeft }: ChallengeStatusProps) => (
+
+
+ {" · "}
+
+
+
+
+);
+
+export default ChallengeStatus;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeEnded.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeEnded.tsx
new file mode 100644
index 000000000..3aa4d6325
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeEnded.tsx
@@ -0,0 +1,34 @@
+import Stack from "@mui/material/Stack";
+import { MFA_VERIFICATION_FAILURE, MfaTerminalFailureReason } from "@theme/api/auth/signIn";
+import { MfaTerminalFailure } from "@theme/hooks/useLoginChallenge";
+import { getLoginUrl } from "@theme/urls";
+import Button from "components/Button";
+import InlineSeverityAlert from "components/InlineSeverityAlert";
+import { SPACING_3 } from "utils/layouts";
+
+const MESSAGE_ID_BY_REASON: Record
= {
+ [MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED]: "signIn:challengeExpired",
+ [MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED]: "signIn:challengeLocked"
+};
+
+/** The challenge can never succeed now, so the only way forward is a fresh sign-in. */
+const MfaChallengeEnded = ({ failure }: { failure: MfaTerminalFailure }) => (
+
+
+
+
+);
+
+export default MfaChallengeEnded;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.styles.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.styles.ts
new file mode 100644
index 000000000..4324da832
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.styles.ts
@@ -0,0 +1,49 @@
+import { makeStyles } from "tss-react/mui";
+import {
+ MPT_ALERTS_INFO_1,
+ MPT_BOX_SHADOW_HOVER,
+ MPT_BRAND_PRIMARY,
+ MPT_GRAY_1,
+ MPT_GRAY_2,
+ MPT_SPACING_2,
+ MPT_SPACING_3
+} from "@theme/utils/layouts";
+
+const useStyles = makeStyles()((theme) => ({
+ content: {
+ textAlign: "left"
+ },
+ methodCard: {
+ display: "flex",
+ alignItems: "center",
+ gap: MPT_SPACING_2,
+ padding: MPT_SPACING_3,
+ border: `1px solid ${MPT_GRAY_2}`,
+ borderRadius: MPT_SPACING_2
+ },
+ selectableMethodCard: {
+ cursor: "pointer",
+ margin: 0,
+ "&:hover": {
+ boxShadow: MPT_BOX_SHADOW_HOVER
+ }
+ },
+ selectedMethodCard: {
+ borderColor: MPT_BRAND_PRIMARY
+ },
+ methodName: {
+ fontSize: theme.typography.subtitle1.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ color: theme.palette.primary.dark
+ },
+ defaultMethodChip: {
+ backgroundColor: MPT_ALERTS_INFO_1,
+ color: MPT_BRAND_PRIMARY
+ },
+ instructions: {
+ backgroundColor: MPT_GRAY_1,
+ padding: MPT_SPACING_3
+ }
+}));
+
+export default useStyles;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.test.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.test.tsx
new file mode 100644
index 000000000..3d53dc590
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.test.tsx
@@ -0,0 +1,334 @@
+import { act } from "react-dom/test-utils";
+import { Route } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AUTH_ERROR_CODE, AuthApiError, LOGIN_CHALLENGE_STATE } from "@theme/api/auth/signIn";
+import { AuthErrorParam, LoginChallenge, MfaMethod } from "@theme/api/auth/signIn/types";
+import { renderWithProviders } from "@theme/tests/renderWithProviders";
+import { MFA_CHALLENGE } from "@theme/urls";
+import { INITIALIZE, LOGIN } from "urls";
+import { getCurrentUTCTimeInSec } from "utils/datetime";
+import { emailMethod, totpMethod } from "./fixtures";
+import MfaChallengeStep from "./MfaChallengeStep";
+
+const completeLoginChallengeSpy = vi.fn();
+
+// The transport is the boundary: failure sorting and the screen's reaction to it stay real.
+vi.mock("@theme/api/auth/signIn", async (importOriginal) => ({
+ ...(await importOriginal()),
+ completeLoginChallenge: (params: unknown) => completeLoginChallengeSpy(params)
+}));
+
+const FIVE_MINUTES = 5 * 60;
+
+// The 202 body from the TDR's "Obtain token — MFA applies" example, dated from now so it is not
+// already expired on arrival.
+const totpDefaultChallenge: LoginChallenge = {
+ challenge_id: "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91",
+ state: LOGIN_CHALLENGE_STATE.MFA_REQUIRED,
+ expires_at: getCurrentUTCTimeInSec() + FIVE_MINUTES,
+ user_email: "finops.ffc@example.com",
+ methods: [totpMethod, emailMethod],
+ default_method_id: totpMethod.id,
+ code_sent: false,
+ attempts_left: 5
+};
+
+const emailDefaultChallenge: LoginChallenge = {
+ ...totpDefaultChallenge,
+ methods: [
+ { ...totpMethod, is_default: false },
+ { ...emailMethod, is_default: true }
+ ],
+ default_method_id: emailMethod.id,
+ code_sent: true
+};
+
+const renderAt = (challenge?: LoginChallenge) => {
+ const { container, find, text, unmount } = renderWithProviders({
+ initialEntry: { pathname: MFA_CHALLENGE, state: challenge ? { challenge } : undefined },
+ routes: (
+ <>
+ } />
+ } />
+ } />
+ >
+ )
+ });
+
+ const methodRadio = (methodId: string) =>
+ container.querySelector(`[data-test-id='mfa_method_option_${methodId}'] input`);
+
+ return {
+ find,
+ methodRadio,
+ text,
+ click: (testId: string) => act(() => find(testId)!.click()),
+ enterCode: async (code: string) => {
+ const firstDigit = find("input_code_digit_0") as HTMLInputElement;
+ const { set } = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!;
+
+ await act(async () => {
+ set!.call(firstDigit, code);
+ firstDigit.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ },
+ verify: () => act(async () => find("btn_verify")!.click()),
+ digits: () =>
+ Array.from(
+ container.querySelectorAll("[data-test-id^='input_code_digit_']"),
+ ({ value }) => value
+ ).join(""),
+ unmount
+ };
+};
+
+describe("MfaChallengeStep", () => {
+ it("shows which account is signing in", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ expect(screen.text("p_signing_in_as")).toContain("finops.ffc@example.com");
+
+ screen.unmount();
+ });
+
+ it("offers a different method above the Verify button", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ const useDifferentMethod = screen.find("btn_use_different_method")!;
+ const verify = screen.find("btn_verify")!;
+
+ expect(useDifferentMethod.compareDocumentPosition(verify) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+
+ screen.unmount();
+ });
+
+ it("opens straight on the authenticator app when it is the default method", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ expect(screen.find("mfa_method_totp")).not.toBeNull();
+ expect(screen.find("p_default_method")).not.toBeNull();
+ expect(screen.text("p_method_instructions")).toBe("Open your authenticator app and enter the code it shows.");
+ expect(screen.find("input_code_digit_0")).not.toBeNull();
+
+ screen.unmount();
+ });
+
+ it("opens on e-mail with the masked address when e-mail is the default and the code went out", () => {
+ const screen = renderAt(emailDefaultChallenge);
+
+ expect(screen.find("mfa_method_email")).not.toBeNull();
+ expect(screen.text("p_method_instructions")).toBe("A verification code has been sent to fi***@example.com.");
+
+ screen.unmount();
+ });
+
+ it("offers only confirmed methods when the visitor chooses a different one", () => {
+ const pendingMethod: MfaMethod = { ...emailMethod, id: "c3f5", confirmed: false };
+ const screen = renderAt({ ...totpDefaultChallenge, methods: [...totpDefaultChallenge.methods, pendingMethod] });
+
+ screen.click("btn_use_different_method");
+
+ expect(screen.find(`mfa_method_option_${totpMethod.id}`)).not.toBeNull();
+ expect(screen.find(`mfa_method_option_${emailMethod.id}`)).not.toBeNull();
+ expect(screen.find(`mfa_method_option_${pendingMethod.id}`)).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("keeps the current method checked in the picker", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ screen.click("btn_use_different_method");
+
+ expect(screen.methodRadio(totpMethod.id)?.checked).toBe(true);
+
+ screen.unmount();
+ });
+
+ it("switches the screen to the method picked from the list", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ screen.click("btn_use_different_method");
+ act(() => screen.methodRadio(emailMethod.id)!.click());
+ screen.click("btn_use_selected_method");
+
+ expect(screen.find("mfa_method_email")).not.toBeNull();
+ expect(screen.find("mfa_method_totp")).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("asks the visitor to pick, with nothing checked, when there is no default", () => {
+ const screen = renderAt({ ...totpDefaultChallenge, default_method_id: null });
+
+ expect(screen.find("p_verify_your_identity")).not.toBeNull();
+ expect(screen.methodRadio(totpMethod.id)?.checked).toBe(false);
+ expect((screen.find("btn_use_selected_method") as HTMLButtonElement).disabled).toBe(true);
+
+ screen.unmount();
+ });
+
+ it("hides the switch when there is only one method to use", () => {
+ const screen = renderAt({ ...totpDefaultChallenge, methods: [totpMethod] });
+
+ expect(screen.find("btn_use_different_method")).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("explains that sign-in cannot continue when no method is confirmed", () => {
+ const screen = renderAt({ ...totpDefaultChallenge, methods: [{ ...totpMethod, confirmed: false }] });
+
+ expect(screen.find("p_no_verification_methods")).not.toBeNull();
+ expect(screen.find("input_code_digit_0")).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("sends a visitor who never passed the password step back to login", () => {
+ const screen = renderAt();
+
+ expect(screen.find("login_page")).not.toBeNull();
+
+ screen.unmount();
+ });
+});
+
+describe("MfaChallengeStep verification", () => {
+ afterEach(() => {
+ completeLoginChallengeSpy.mockReset();
+ vi.useRealTimers();
+ });
+
+ const rejectWith = (status: number, errorCode: string, params: AuthErrorParam[] = []) =>
+ completeLoginChallengeSpy.mockRejectedValue(new AuthApiError({ status, errorCode, reason: "nope", params }));
+
+ it("shows how long the challenge lasts and how many attempts it allows", () => {
+ const screen = renderAt(totpDefaultChallenge);
+
+ expect(screen.text("p_challenge_status")).toMatch(/^Expires in [45]:\d\d · 5 attempts left$/);
+
+ screen.unmount();
+ });
+
+ it("sends the challenge, the method and the entered code", async () => {
+ completeLoginChallengeSpy.mockReturnValue(new Promise(() => {}));
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("482913");
+ await screen.verify();
+
+ expect(completeLoginChallengeSpy).toHaveBeenCalledWith({
+ challenge_id: totpDefaultChallenge.challenge_id,
+ method_id: totpMethod.id,
+ code: "482913"
+ });
+
+ screen.unmount();
+ });
+
+ it("keeps the form open after a wrong code, with a retryable error, an empty code and the attempts the service reports", async () => {
+ rejectWith(403, AUTH_ERROR_CODE.INVALID_MFA_CODE, [2]);
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("000000");
+ await screen.verify();
+
+ expect(screen.text("p_code_error")).toBe("That code is incorrect. Check it and try again.");
+ expect(screen.digits()).toBe("");
+ expect(screen.text("p_attempts_left")).toBe("2 attempts left");
+ expect(screen.find("p_challenge_ended")).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("counts one attempt spent after a wrong code when the service does not report how many are left", async () => {
+ rejectWith(403, AUTH_ERROR_CODE.INVALID_MFA_CODE);
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("000000");
+ await screen.verify();
+
+ expect(screen.text("p_attempts_left")).toBe("4 attempts left");
+
+ screen.unmount();
+ });
+
+ it("ends the challenge when the last attempt is spent, and offers a fresh sign-in for the same address", async () => {
+ rejectWith(403, AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED);
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("000000");
+ await screen.verify();
+
+ expect(screen.text("p_challenge_ended")).toContain("Too many incorrect codes");
+ // The ended view only renders once the challenge is gone from router state.
+ expect(screen.find("input_code_digit_0")).toBeNull();
+ expect(screen.find("btn_sign_in_again")?.closest("a")?.getAttribute("href")).toBe(
+ `${LOGIN}?email=${encodeURIComponent(totpDefaultChallenge.user_email)}`
+ );
+
+ screen.unmount();
+ });
+
+ it("ends an expired, consumed or unknown challenge the service answers 404 for", async () => {
+ rejectWith(404, AUTH_ERROR_CODE.LOGIN_CHALLENGE_NOT_FOUND);
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("482913");
+ await screen.verify();
+
+ expect(screen.text("p_challenge_ended")).toContain("expired or is no longer valid");
+ expect(screen.text("p_signing_in_as")).toContain(totpDefaultChallenge.user_email);
+
+ screen.unmount();
+ });
+
+ it("ends the challenge on its own when the countdown runs out", () => {
+ vi.useFakeTimers();
+ const screen = renderAt({ ...totpDefaultChallenge, expires_at: getCurrentUTCTimeInSec() + 2 });
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.text("p_challenge_ended")).toContain("expired or is no longer valid");
+
+ screen.unmount();
+ });
+
+ it("keeps the entered code after an unexpected failure, since nothing says the challenge is spent", async () => {
+ completeLoginChallengeSpy.mockRejectedValue(new TypeError("Failed to fetch"));
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("482913");
+ await screen.verify();
+
+ expect(screen.find("p_code_error")).not.toBeNull();
+ expect(screen.digits()).toBe("482913");
+ expect(screen.find("p_challenge_ended")).toBeNull();
+
+ screen.unmount();
+ });
+
+ it("signs in and moves on once the code is accepted", async () => {
+ completeLoginChallengeSpy.mockResolvedValue({
+ token:
+ "MDAxNmxvY2F0aW9uIG9wdHNjYWxlCjAwMzRpZGVudGlmaWVyIDljMmExZjNlLTViNDctNGEyMS04ZjBjLTFkNmU4YjNhN2M0MgowMDExY2lkIG1mYTpub25lCjAwMmZzaWduYXR1cmUgrrkLrCmb0VcdYWsrr-lUxd9rs-V-iq_yANUMhSBaeHIK",
+ user_id: "9c2a1f3e-5b47-4a21-8f0c-1d6e8b3a7c42",
+ user_email: totpDefaultChallenge.user_email,
+ created_at: "2026-09-22T09:00:00",
+ valid_until: "2026-09-29T09:00:00",
+ ip: "127.0.0.1",
+ digest: "f0a1b2c3d4e5f60718293a4b5c6d7e8f"
+ });
+ const screen = renderAt(totpDefaultChallenge);
+
+ await screen.enterCode("123456");
+ await screen.verify();
+
+ expect(screen.find("initialize_page")).not.toBeNull();
+
+ screen.unmount();
+ });
+});
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.tsx
new file mode 100644
index 000000000..b6b19cff9
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.tsx
@@ -0,0 +1,162 @@
+import { useCallback, useEffect, useState } from "react";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { FormattedMessage } from "react-intl";
+import { Navigate, useNavigate } from "react-router-dom";
+import {
+ getMfaVerificationFailure,
+ LoginChallenge,
+ MFA_VERIFICATION_FAILURE,
+ MfaTerminalFailureReason
+} from "@theme/api/auth/signIn";
+import { useCompleteLoginChallenge } from "@theme/hooks/useCompleteLoginChallenge";
+import { useCompleteSignIn } from "@theme/hooks/useCompleteSignIn";
+import { useEndLoginChallenge, useLoginChallenge, useMfaTerminalFailure } from "@theme/hooks/useLoginChallenge";
+import { useSecondsUntil } from "@theme/hooks/useSecondsUntil";
+import Greeter from "components/Greeter";
+import InlineSeverityAlert from "components/InlineSeverityAlert";
+import { INITIALIZE, LOGIN } from "urls";
+import { SPACING_3 } from "utils/layouts";
+import BackToLoginButton from "./BackToLoginButton";
+import { MFA_CHALLENGE_VIEW } from "./constants";
+import MfaChallengeEnded from "./MfaChallengeEnded";
+import useStyles from "./MfaChallengeStep.styles";
+import MfaCodeEntry from "./MfaCodeEntry";
+import MfaMethodPicker from "./MfaMethodPicker";
+import { MfaCodeError } from "./types";
+import { getMfaChallengeStage, getPreselectedMethod, getSelectableMethods } from "./utils";
+
+const NoSelectableMethods = () => (
+
+
+
+
+);
+
+const MfaChallengeContent = ({ challenge }: { challenge: LoginChallenge }) => {
+ const navigate = useNavigate();
+ const endLoginChallenge = useEndLoginChallenge();
+ const completeSignIn = useCompleteSignIn();
+ const { onCompleteLoginChallenge, isLoading: isVerifying } = useCompleteLoginChallenge();
+ const secondsUntilExpiry = useSecondsUntil(challenge.expires_at);
+
+ const selectableMethods = getSelectableMethods(challenge);
+
+ const [selectedMethodId, setSelectedMethodId] = useState(() => getPreselectedMethod(challenge)?.id ?? null);
+ // Only "use a different method" sets this; with nothing selected the stage opens the picker anyway.
+ const [isPickingMethod, setIsPickingMethod] = useState(false);
+ const [attemptsLeft, setAttemptsLeft] = useState(challenge.attempts_left);
+ const [codeError, setCodeError] = useState(null);
+ const [rejectedCodeCount, setRejectedCodeCount] = useState(0);
+
+ const stage = getMfaChallengeStage(challenge, { selectedMethodId, isPickingMethod });
+
+ const endChallenge = useCallback(
+ (reason: MfaTerminalFailureReason) => endLoginChallenge({ reason, userEmail: challenge.user_email }),
+ [endLoginChallenge, challenge.user_email]
+ );
+
+ useEffect(() => {
+ if (secondsUntilExpiry === 0) {
+ endChallenge(MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED);
+ }
+ }, [secondsUntilExpiry, endChallenge]);
+
+ const verifyCode = async (methodId: string, code: string) => {
+ setCodeError(null);
+
+ try {
+ completeSignIn(await onCompleteLoginChallenge({ challenge_id: challenge.challenge_id, method_id: methodId, code }));
+ navigate(INITIALIZE, { replace: true });
+ } catch (error) {
+ const failure = getMfaVerificationFailure(error);
+
+ switch (failure.kind) {
+ case MFA_VERIFICATION_FAILURE.INVALID_CODE:
+ // The service's count wins; without it, one attempt was spent.
+ setAttemptsLeft((previous) => failure.attemptsLeft ?? previous - 1);
+ setCodeError({ messageId: "signIn:invalidVerificationCode" });
+ setRejectedCodeCount((count) => count + 1);
+ return;
+ case MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED:
+ case MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED:
+ endChallenge(failure.kind);
+ return;
+ case MFA_VERIFICATION_FAILURE.UNEXPECTED:
+ // Nothing says the challenge is spent, so the entered code stays for another try.
+ setCodeError({ messageId: "somethingWentWrong" });
+ }
+ }
+ };
+
+ switch (stage.view) {
+ case MFA_CHALLENGE_VIEW.NO_SELECTABLE_METHODS:
+ return ;
+ case MFA_CHALLENGE_VIEW.METHOD_PICKER:
+ return (
+ {
+ setSelectedMethodId(methodId);
+ setIsPickingMethod(false);
+ setCodeError(null);
+ }}
+ onBack={() => (selectedMethodId ? setIsPickingMethod(false) : navigate(LOGIN))}
+ />
+ );
+ case MFA_CHALLENGE_VIEW.CODE_ENTRY:
+ return (
+ verifyCode(stage.method.id, code)}
+ isVerifying={isVerifying}
+ error={codeError}
+ secondsUntilExpiry={secondsUntilExpiry}
+ attemptsLeft={attemptsLeft}
+ onUseDifferentMethod={selectableMethods.length > 1 ? () => setIsPickingMethod(true) : undefined}
+ />
+ );
+ default:
+ return null;
+ }
+};
+
+const MfaChallengeStep = () => {
+ const { classes } = useStyles();
+
+ const challenge = useLoginChallenge();
+ const terminalFailure = useMfaTerminalFailure();
+
+ // Landing here with neither means the password was never verified in this session.
+ if (!challenge && !terminalFailure) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {challenge ? (
+ // Keyed by challenge: a fresh challenge (e.g. re-run from the dev panel) starts from its own default.
+
+ ) : (
+ terminalFailure &&
+ )}
+
+ }
+ />
+ );
+};
+
+export default MfaChallengeStep;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaCodeEntry.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaCodeEntry.tsx
new file mode 100644
index 000000000..ed1c4b3e6
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaCodeEntry.tsx
@@ -0,0 +1,103 @@
+import { FormEvent, useState } from "react";
+import Box from "@mui/material/Box";
+import CircularProgress from "@mui/material/CircularProgress";
+import Link from "@mui/material/Link";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { FormattedMessage } from "react-intl";
+import { MfaMethod } from "@theme/api/auth/signIn";
+import VerificationCodeInput from "@theme/components/VerificationCodeInput";
+import { VERIFICATION_CODE_LENGTH } from "@theme/components/VerificationCodeInput/constants";
+import Button from "components/Button";
+import InlineSeverityAlert from "components/InlineSeverityAlert";
+import { SPACING_2, SPACING_3 } from "utils/layouts";
+import BackToLoginButton from "./BackToLoginButton";
+import ChallengeStatus from "./ChallengeStatus";
+import { MFA_METHOD_NAME_MESSAGE_ID } from "./constants";
+import useStyles from "./MfaChallengeStep.styles";
+import MfaMethodIcon from "./MfaMethodIcon";
+import MfaMethodInstructions from "./MfaMethodInstructions";
+import { MfaCodeError } from "./types";
+
+type MfaCodeEntryProps = {
+ method: MfaMethod;
+ isEmailCodeSent: boolean;
+ onVerify: (code: string) => void;
+ isVerifying: boolean;
+ error: MfaCodeError | null;
+ secondsUntilExpiry: number;
+ attemptsLeft: number;
+ onUseDifferentMethod?: () => void;
+};
+
+const MfaCodeEntry = ({
+ method,
+ isEmailCodeSent,
+ onVerify,
+ isVerifying,
+ error,
+ secondsUntilExpiry,
+ attemptsLeft,
+ onUseDifferentMethod
+}: MfaCodeEntryProps) => {
+ const { classes } = useStyles();
+
+ const [code, setCode] = useState("");
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onVerify(code);
+ };
+
+ return (
+
+ );
+};
+
+export default MfaCodeEntry;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodIcon.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodIcon.tsx
new file mode 100644
index 000000000..4906607b7
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodIcon.tsx
@@ -0,0 +1,21 @@
+import MailOutlineIcon from "@mui/icons-material/MailOutline";
+import Box from "@mui/material/Box";
+import { MFA_METHOD_TYPE, MfaMethodType } from "@theme/api/auth/signIn";
+import AuthenticatorAppIcon from "@theme/icons/AuthenticatorAppIcon";
+
+const ICON_COLOR = "#315CB9";
+
+// Same-width slot for every method, so the method names beside the icons line up in the picker.
+const ICON_SLOT_SIZE = 56;
+
+const MfaMethodIcon = ({ type }: { type: MfaMethodType }) => (
+
+ {type === MFA_METHOD_TYPE.TOTP ? (
+
+ ) : (
+
+ )}
+
+);
+
+export default MfaMethodIcon;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodInstructions.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodInstructions.tsx
new file mode 100644
index 000000000..4258ec96c
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodInstructions.tsx
@@ -0,0 +1,38 @@
+import Typography from "@mui/material/Typography";
+import { FormattedMessage } from "react-intl";
+import { MFA_METHOD_TYPE, MfaMethod } from "@theme/api/auth/signIn";
+import InlineSeverityAlert from "components/InlineSeverityAlert";
+import useStyles from "./MfaChallengeStep.styles";
+
+type MfaMethodInstructionsProps = {
+ method: MfaMethod;
+ isEmailCodeSent: boolean;
+};
+
+const MfaMethodInstructions = ({ method, isEmailCodeSent }: MfaMethodInstructionsProps) => {
+ const { classes } = useStyles();
+
+ // Requesting a code for a non-default email method is POST /login_challenges/{id}/codes — not built yet.
+ if (method.type === MFA_METHOD_TYPE.EMAIL && !isEmailCodeSent) {
+ return ;
+ }
+
+ return (
+
+ {method.is_default && (
+
+
+
+ )}
+
+ {method.type === MFA_METHOD_TYPE.TOTP ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default MfaMethodInstructions;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodPicker.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodPicker.tsx
new file mode 100644
index 000000000..c2bdb8a33
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodPicker.tsx
@@ -0,0 +1,111 @@
+import { FormEvent, useState } from "react";
+import Chip from "@mui/material/Chip";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import Radio from "@mui/material/Radio";
+import RadioGroup from "@mui/material/RadioGroup";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { FormattedMessage } from "react-intl";
+import { MfaMethod } from "@theme/api/auth/signIn";
+import Button from "components/Button";
+import { SPACING_1, SPACING_2, SPACING_3 } from "utils/layouts";
+import { MFA_METHOD_NAME_MESSAGE_ID } from "./constants";
+import useStyles from "./MfaChallengeStep.styles";
+import MfaMethodIcon from "./MfaMethodIcon";
+
+type MfaMethodPickerProps = {
+ methods: MfaMethod[];
+ initiallySelectedMethodId: string | null;
+ onUseSelectedMethod: (methodId: string) => void;
+ onBack: () => void;
+};
+
+const TITLE_ID = "mfa_method_picker_title";
+
+const MfaMethodPicker = ({ methods, initiallySelectedMethodId, onUseSelectedMethod, onBack }: MfaMethodPickerProps) => {
+ const { classes, cx } = useStyles();
+
+ const [selectedMethodId, setSelectedMethodId] = useState(initiallySelectedMethodId ?? "");
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+
+ if (selectedMethodId) {
+ onUseSelectedMethod(selectedMethodId);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default MfaMethodPicker;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/constants.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/constants.ts
new file mode 100644
index 000000000..65ee837ab
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/constants.ts
@@ -0,0 +1,12 @@
+import { MFA_METHOD_TYPE, MfaMethodType } from "@theme/api/auth/signIn";
+
+export const MFA_METHOD_NAME_MESSAGE_ID: Record = {
+ [MFA_METHOD_TYPE.TOTP]: "signIn:authenticatorApp",
+ [MFA_METHOD_TYPE.EMAIL]: "signIn:emailVerificationCode"
+};
+
+export const MFA_CHALLENGE_VIEW = Object.freeze({
+ NO_SELECTABLE_METHODS: "no_selectable_methods",
+ METHOD_PICKER: "method_picker",
+ CODE_ENTRY: "code_entry"
+} as const);
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/fixtures.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/fixtures.ts
new file mode 100644
index 000000000..679488745
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/fixtures.ts
@@ -0,0 +1,19 @@
+import { MFA_METHOD_TYPE, MfaMethod } from "@theme/api/auth/signIn";
+
+/** Spec-only: the two methods from the TDR's "Obtain token — MFA applies" 202 example. */
+
+export const totpMethod: MfaMethod = {
+ id: "a1d3",
+ type: MFA_METHOD_TYPE.TOTP,
+ label: "Configured on Google Pixel 10 Pro",
+ is_default: true,
+ confirmed: true
+};
+
+export const emailMethod: MfaMethod = {
+ id: "b2e4",
+ type: MFA_METHOD_TYPE.EMAIL,
+ label: "fi***@example.com",
+ is_default: false,
+ confirmed: true
+};
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/index.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/index.ts
new file mode 100644
index 000000000..8adba78aa
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/index.ts
@@ -0,0 +1,3 @@
+import MfaChallengeStep from "./MfaChallengeStep";
+
+export default MfaChallengeStep;
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/types.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/types.ts
new file mode 100644
index 000000000..e7e70f8b8
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/types.ts
@@ -0,0 +1,12 @@
+import { MfaMethod } from "@theme/api/auth/signIn";
+import { MFA_CHALLENGE_VIEW } from "./constants";
+
+/** Which screen the challenge shows. */
+export type MfaChallengeStage =
+ | { view: typeof MFA_CHALLENGE_VIEW.NO_SELECTABLE_METHODS }
+ | { view: typeof MFA_CHALLENGE_VIEW.METHOD_PICKER }
+ | { view: typeof MFA_CHALLENGE_VIEW.CODE_ENTRY; method: MfaMethod; isEmailCodeSent: boolean };
+
+export type MfaCodeError = {
+ messageId: "signIn:invalidVerificationCode" | "somethingWentWrong";
+};
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.test.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.test.ts
new file mode 100644
index 000000000..5f3a1b63d
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import { MfaMethod } from "@theme/api/auth/signIn";
+import { MFA_CHALLENGE_VIEW } from "./constants";
+import { emailMethod, totpMethod } from "./fixtures";
+import {
+ formatCountdown,
+ getMfaChallengeStage,
+ getPreselectedMethod,
+ getSelectableMethods,
+ isEmailCodeAlreadySent
+} from "./utils";
+
+const pendingTotpMethod: MfaMethod = { ...totpMethod, id: "c3f5", is_default: false, confirmed: false };
+
+describe("getSelectableMethods", () => {
+ it("leaves out methods whose enrollment was never confirmed", () => {
+ expect(getSelectableMethods({ methods: [totpMethod, pendingTotpMethod, emailMethod] })).toEqual([totpMethod, emailMethod]);
+ });
+});
+
+describe("getPreselectedMethod", () => {
+ it("preselects the method named by default_method_id", () => {
+ expect(getPreselectedMethod({ methods: [emailMethod, totpMethod], default_method_id: emailMethod.id })).toBe(emailMethod);
+ });
+
+ it("asks the visitor to pick when there is no default and several methods", () => {
+ expect(getPreselectedMethod({ methods: [totpMethod, emailMethod], default_method_id: null })).toBeNull();
+ });
+
+ it("does not preselect a default that was never confirmed", () => {
+ expect(
+ getPreselectedMethod({ methods: [pendingTotpMethod, totpMethod, emailMethod], default_method_id: pendingTotpMethod.id })
+ ).toBeNull();
+ });
+
+ it("preselects the only selectable method when there is no default", () => {
+ expect(getPreselectedMethod({ methods: [pendingTotpMethod, emailMethod], default_method_id: null })).toBe(emailMethod);
+ });
+});
+
+describe("isEmailCodeAlreadySent", () => {
+ it("is true for the default email method when the challenge says a code went out", () => {
+ expect(isEmailCodeAlreadySent({ code_sent: true, default_method_id: emailMethod.id }, emailMethod)).toBe(true);
+ });
+
+ it("is false for an email method that is not the default, because code_sent describes the default only", () => {
+ expect(isEmailCodeAlreadySent({ code_sent: true, default_method_id: "another-email" }, emailMethod)).toBe(false);
+ });
+
+ it("is false for an authenticator app, which is never sent anything", () => {
+ expect(isEmailCodeAlreadySent({ code_sent: true, default_method_id: totpMethod.id }, totpMethod)).toBe(false);
+ });
+});
+
+describe("getMfaChallengeStage", () => {
+ const challenge = { methods: [totpMethod, emailMethod], code_sent: false, default_method_id: totpMethod.id };
+
+ it("is the code entry for the selected method", () => {
+ expect(getMfaChallengeStage(challenge, { selectedMethodId: totpMethod.id, isPickingMethod: false })).toEqual({
+ view: MFA_CHALLENGE_VIEW.CODE_ENTRY,
+ method: totpMethod,
+ isEmailCodeSent: false
+ });
+ });
+
+ it("is the picker while the visitor is choosing, or when nothing is selected", () => {
+ expect(getMfaChallengeStage(challenge, { selectedMethodId: totpMethod.id, isPickingMethod: true }).view).toBe(
+ MFA_CHALLENGE_VIEW.METHOD_PICKER
+ );
+ expect(getMfaChallengeStage(challenge, { selectedMethodId: null, isPickingMethod: false }).view).toBe(
+ MFA_CHALLENGE_VIEW.METHOD_PICKER
+ );
+ });
+
+ it("is the dead end when no method is confirmed, whatever was selected", () => {
+ expect(
+ getMfaChallengeStage({ ...challenge, methods: [pendingTotpMethod] }, { selectedMethodId: null, isPickingMethod: false })
+ .view
+ ).toBe(MFA_CHALLENGE_VIEW.NO_SELECTABLE_METHODS);
+ });
+});
+
+describe("formatCountdown", () => {
+ it("reads as minutes and zero-padded seconds", () => {
+ expect([formatCountdown(300), formatCountdown(65), formatCountdown(9)]).toEqual(["5:00", "1:05", "0:09"]);
+ });
+});
diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.ts b/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.ts
new file mode 100644
index 000000000..c46bd81f9
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.ts
@@ -0,0 +1,52 @@
+import { LoginChallenge, MFA_METHOD_TYPE, MfaMethod } from "@theme/api/auth/signIn";
+import { MFA_CHALLENGE_VIEW } from "./constants";
+import { MfaChallengeStage } from "./types";
+
+export const getSelectableMethods = ({ methods }: Pick): MfaMethod[] =>
+ methods.filter(({ confirmed }) => confirmed);
+
+/** The default method when it is selectable, else the only method there is — `null` asks the visitor to pick. */
+export const getPreselectedMethod = (challenge: Pick): MfaMethod | null => {
+ const selectableMethods = getSelectableMethods(challenge);
+
+ const defaultMethod = selectableMethods.find(({ id }) => id === challenge.default_method_id);
+
+ if (defaultMethod) {
+ return defaultMethod;
+ }
+
+ return selectableMethods.length === 1 ? selectableMethods[0] : null;
+};
+
+/** `code_sent` describes the default method only: the service mails a code with the 202 when that method is email. */
+export const isEmailCodeAlreadySent = (
+ challenge: Pick,
+ method: Pick
+) => method.type === MFA_METHOD_TYPE.EMAIL && challenge.code_sent && method.id === challenge.default_method_id;
+
+export const getMfaChallengeStage = (
+ challenge: Pick,
+ { selectedMethodId, isPickingMethod }: { selectedMethodId: string | null; isPickingMethod: boolean }
+): MfaChallengeStage => {
+ const selectableMethods = getSelectableMethods(challenge);
+
+ if (selectableMethods.length === 0) {
+ return { view: MFA_CHALLENGE_VIEW.NO_SELECTABLE_METHODS };
+ }
+
+ const selectedMethod = selectableMethods.find(({ id }) => id === selectedMethodId);
+
+ if (isPickingMethod || !selectedMethod) {
+ return { view: MFA_CHALLENGE_VIEW.METHOD_PICKER };
+ }
+
+ return {
+ view: MFA_CHALLENGE_VIEW.CODE_ENTRY,
+ method: selectedMethod,
+ isEmailCodeSent: isEmailCodeAlreadySent(challenge, selectedMethod)
+ };
+};
+
+/** `m:ss`, the way a countdown reads. */
+export const formatCountdown = (totalSeconds: number) =>
+ `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
diff --git a/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.styles.ts b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.styles.ts
new file mode 100644
index 000000000..a28ce44ab
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.styles.ts
@@ -0,0 +1,25 @@
+import { makeStyles } from "tss-react/mui";
+import { MPT_BRAND_PRIMARY, MPT_GRAY_4, MPT_SPACING_1 } from "@theme/utils/layouts";
+
+const useStyles = makeStyles()((theme) => ({
+ digit: {
+ width: 40,
+ height: 52,
+ padding: 0,
+ textAlign: "center",
+ fontSize: theme.typography.h5.fontSize,
+ fontFamily: theme.typography.fontFamily,
+ border: `1px solid ${MPT_GRAY_4}`,
+ borderRadius: MPT_SPACING_1,
+ outline: "none",
+ "&:focus": {
+ borderColor: MPT_BRAND_PRIMARY,
+ boxShadow: `0 0 0 1px ${MPT_BRAND_PRIMARY}`
+ },
+ "&:disabled": {
+ opacity: theme.palette.action.disabledOpacity
+ }
+ }
+}));
+
+export default useStyles;
diff --git a/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.test.tsx b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.test.tsx
new file mode 100644
index 000000000..1535120f6
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.test.tsx
@@ -0,0 +1,136 @@
+import { useState } from "react";
+import { createRoot } from "react-dom/client";
+import { act } from "react-dom/test-utils";
+import { IntlProvider } from "react-intl";
+import { describe, expect, it } from "vitest";
+import intlConfig from "translations/react-intl-config";
+import VerificationCodeInput from "./VerificationCodeInput";
+
+// Opts this file into React 18's act() environment so state updates are flushed, not warned about.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const ControlledInput = ({ initialValue }: { initialValue: string }) => {
+ const [value, setValue] = useState(initialValue);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const render = (initialValue = "") => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+
+ );
+ });
+
+ const digit = (index: number) => container.querySelector(`[data-test-id='input_code_digit_${index}']`)!;
+
+ return {
+ digit,
+ value: () => container.querySelector("[data-test-id='value']")!.textContent,
+ type: (index: number, text: string) =>
+ act(() => {
+ const { set } = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!;
+ set!.call(digit(index), text);
+ digit(index).dispatchEvent(new Event("input", { bubbles: true }));
+ }),
+ paste: (index: number, text: string) =>
+ act(() => {
+ const event = new Event("paste", { bubbles: true, cancelable: true });
+ Object.assign(event, { clipboardData: { getData: () => text } });
+ digit(index).dispatchEvent(event);
+ }),
+ pressBackspace: (index: number) =>
+ act(() => {
+ digit(index).dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true }));
+ }),
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ }
+ };
+};
+
+describe("VerificationCodeInput", () => {
+ it("renders one labelled box per digit", () => {
+ const input = render();
+
+ expect(input.digit(5)).not.toBeNull();
+ expect(input.digit(0).getAttribute("aria-label")).toBe("Digit 1 of 6");
+
+ input.unmount();
+ });
+
+ it("moves to the next box after a digit is typed", () => {
+ const input = render();
+
+ input.type(0, "4");
+
+ expect(input.value()).toBe("4");
+ expect(document.activeElement).toBe(input.digit(1));
+
+ input.unmount();
+ });
+
+ it("ignores anything that is not a digit", () => {
+ const input = render();
+
+ input.type(0, "a");
+
+ expect(input.value()).toBe("");
+
+ input.unmount();
+ });
+
+ it("spreads a pasted code across the boxes, dropping separators and anything past the last box", () => {
+ const input = render();
+
+ input.paste(0, "482-913 7");
+
+ expect(input.value()).toBe("482913");
+
+ input.unmount();
+ });
+
+ it("takes a whole code autofilled into the first box", () => {
+ const input = render();
+
+ input.type(0, "482913");
+
+ expect(input.value()).toBe("482913");
+
+ input.unmount();
+ });
+
+ it("writes into the first empty box when a later box is typed into, so the code has no gaps", () => {
+ const input = render("4");
+
+ input.type(4, "8");
+
+ expect(input.value()).toBe("48");
+ expect(document.activeElement).toBe(input.digit(2));
+
+ input.unmount();
+ });
+
+ it("clears the previous digit when backspace is pressed on an empty box", () => {
+ const input = render("48");
+
+ input.pressBackspace(2);
+
+ expect(input.value()).toBe("4");
+ expect(document.activeElement).toBe(input.digit(1));
+
+ input.unmount();
+ });
+});
diff --git a/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.tsx b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.tsx
new file mode 100644
index 000000000..be15b3467
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.tsx
@@ -0,0 +1,110 @@
+import { ChangeEvent, ClipboardEvent, FocusEvent, KeyboardEvent, useRef } from "react";
+import Stack from "@mui/material/Stack";
+import { useIntl } from "react-intl";
+import { SPACING_2 } from "utils/layouts";
+import { VERIFICATION_CODE_LENGTH } from "./constants";
+import useStyles from "./VerificationCodeInput.styles";
+
+type VerificationCodeInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+ length?: number;
+ autoFocus?: boolean;
+ disabled?: boolean;
+};
+
+const onlyDigits = (text: string) => text.replace(/\D/g, "");
+
+/** One box per digit. The value is kept contiguous, so the string is always the code as typed. */
+const VerificationCodeInput = ({
+ value,
+ onChange,
+ length = VERIFICATION_CODE_LENGTH,
+ autoFocus = false,
+ disabled = false
+}: VerificationCodeInputProps) => {
+ const { classes } = useStyles();
+ const intl = useIntl();
+
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
+
+ const focusDigit = (index: number) => inputRefs.current[Math.min(Math.max(index, 0), length - 1)]?.focus();
+
+ const fillFrom = (index: number, text: string) => {
+ const typedDigits = onlyDigits(text);
+
+ if (!typedDigits) {
+ return;
+ }
+
+ // Typing into a box past the first empty one lands in the first empty one, so no hole opens up.
+ const startIndex = Math.min(index, value.length);
+
+ onChange((value.slice(0, startIndex) + typedDigits + value.slice(startIndex + typedDigits.length)).slice(0, length));
+ focusDigit(startIndex + typedDigits.length);
+ };
+
+ // Selecting on focus makes a keystroke replace the digit, so onChange only ever sees new input.
+ const handleFocus = (event: FocusEvent) => event.target.select();
+
+ const handleChange = (index: number) => (event: ChangeEvent) => fillFrom(index, event.target.value);
+
+ const handlePaste = (index: number) => (event: ClipboardEvent) => {
+ event.preventDefault();
+ fillFrom(index, event.clipboardData.getData("text"));
+ };
+
+ const handleKeyDown = (index: number) => (event: KeyboardEvent) => {
+ if (event.key === "Backspace") {
+ event.preventDefault();
+
+ const isDigitFilled = index < value.length;
+ const removedIndex = isDigitFilled ? index : value.length - 1;
+
+ if (removedIndex >= 0) {
+ onChange(value.slice(0, removedIndex) + value.slice(removedIndex + 1));
+ focusDigit(removedIndex);
+ }
+ return;
+ }
+
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ focusDigit(index - 1);
+ return;
+ }
+
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ focusDigit(index + 1);
+ }
+ };
+
+ return (
+
+ {Array.from({ length }, (_, index) => (
+ {
+ inputRefs.current[index] = element;
+ }}
+ className={classes.digit}
+ value={value[index] ?? ""}
+ onChange={handleChange(index)}
+ onFocus={handleFocus}
+ onPaste={handlePaste(index)}
+ onKeyDown={handleKeyDown(index)}
+ inputMode="numeric"
+ // Lets mobile keyboards offer the code from an SMS or e-mail; it lands whole in the first box.
+ autoComplete={index === 0 ? "one-time-code" : "off"}
+ autoFocus={autoFocus && index === 0}
+ disabled={disabled}
+ aria-label={intl.formatMessage({ id: "signIn:verificationCodeDigit" }, { position: index + 1, length })}
+ data-test-id={`input_code_digit_${index}`}
+ />
+ ))}
+
+ );
+};
+
+export default VerificationCodeInput;
diff --git a/ngui/ui/src/themes/finops/components/VerificationCodeInput/constants.ts b/ngui/ui/src/themes/finops/components/VerificationCodeInput/constants.ts
new file mode 100644
index 000000000..3135da7d7
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/VerificationCodeInput/constants.ts
@@ -0,0 +1 @@
+export const VERIFICATION_CODE_LENGTH = 6;
diff --git a/ngui/ui/src/themes/finops/components/VerificationCodeInput/index.ts b/ngui/ui/src/themes/finops/components/VerificationCodeInput/index.ts
new file mode 100644
index 000000000..aaa8870fc
--- /dev/null
+++ b/ngui/ui/src/themes/finops/components/VerificationCodeInput/index.ts
@@ -0,0 +1,3 @@
+import VerificationCodeInput from "./VerificationCodeInput";
+
+export default VerificationCodeInput;
diff --git a/ngui/ui/src/themes/finops/containers/AuthorizationContainer/AuthorizationContainer.tsx b/ngui/ui/src/themes/finops/containers/AuthorizationContainer/AuthorizationContainer.tsx
index 2b3108a0e..efb0ad293 100644
--- a/ngui/ui/src/themes/finops/containers/AuthorizationContainer/AuthorizationContainer.tsx
+++ b/ngui/ui/src/themes/finops/containers/AuthorizationContainer/AuthorizationContainer.tsx
@@ -1,25 +1,19 @@
import { useState } from "react";
import { ApolloError } from "@apollo/client";
import { Stack } from "@mui/material";
-import { useDispatch } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
-import {
- AuthToken,
- CREATE_TOKEN_STATUS,
- isEmailNotVerifiedError,
- isInvalidCredentialsError,
- LOGIN_CHALLENGE_STATE
-} from "@theme/api/auth/signIn";
+import { CREATE_TOKEN_STATUS, isEmailNotVerifiedError, isInvalidCredentialsError } from "@theme/api/auth/signIn";
import TwoStepLoginForm from "@theme/components/forms/TwoStepLoginForm";
import { LoginFormError } from "@theme/components/forms/TwoStepLoginForm/types";
+import { useCompleteSignIn } from "@theme/hooks/useCompleteSignIn";
import { useCreateToken } from "@theme/hooks/useCreateToken";
import { useIsFeatureEnabled } from "@theme/hooks/useIsFeatureEnabled";
-import { MFA_CHALLENGE, MFA_ENROLLMENT } from "@theme/urls";
+import { useStartLoginChallenge } from "@theme/hooks/useLoginChallenge";
+import { getEmailVerificationUrl } from "@theme/urls";
import LoginForm from "components/forms/LoginForm";
import RegistrationForm from "components/forms/RegistrationForm";
import Greeter from "components/Greeter";
import Redirector from "components/Redirector";
-import { initialize } from "containers/InitializeContainer/redux";
import { useCreateTokenMutation, useCreateUserMutation } from "graphql/__generated__/hooks/auth";
import { useGetToken } from "hooks/useGetToken";
import { useOrganizationInfo } from "hooks/useOrganizationInfo";
@@ -28,7 +22,6 @@ import {
REGISTER,
LOGIN,
INITIALIZE,
- EMAIL_VERIFICATION,
SHOW_POLICY_QUERY_PARAM,
USER_EMAIL_QUERY_PARAMETER_NAME,
NEXT_QUERY_PARAMETER_NAME,
@@ -39,12 +32,10 @@ import { processGraphQLErrorData } from "utils/apollo";
import { OPTSCALE_CAPABILITY } from "utils/constants";
import { ERROR_CODES } from "utils/errorCodes";
import { SPACING_4 } from "utils/layouts";
-import macaroon from "utils/macaroons";
import { stringifySearchParams, getSearchParams } from "utils/network";
const AuthorizationContainer = () => {
const { pathname } = useLocation();
- const dispatch = useDispatch();
const { invited: queryInvited } = getSearchParams();
@@ -74,22 +65,23 @@ const AuthorizationContainer = () => {
const [createUser, { loading: registerLoading }] = useCreateUserMutation();
- const completeSignIn = (authToken: AuthToken) => {
- const caveats = macaroon.processCaveats(macaroon.deserialize(authToken.token).getCaveats());
+ const completeSignIn = useCompleteSignIn();
- dispatch(initialize({ ...authToken, caveats }));
- };
+ const startLoginChallenge = useStartLoginChallenge();
const handleLegacyLogin = async ({ email, password }: { email: string; password: string }) => {
try {
const { data } = await createToken({ variables: { email, password } });
- const caveats = macaroon.processCaveats(macaroon.deserialize(data.token.token).getCaveats());
- dispatch(initialize({ ...data.token, caveats }));
+ const issuedToken = data?.token;
+
+ if (issuedToken?.token) {
+ completeSignIn({ ...issuedToken, token: issuedToken.token });
+ }
} catch (error) {
if (error instanceof ApolloError) {
const errorsData = error.graphQLErrors.map(processGraphQLErrorData);
if (errorsData.some(({ errorCode }) => errorCode === ERROR_CODES.OA0073)) {
- navigate(`${EMAIL_VERIFICATION}?${stringifySearchParams({ email })}`);
+ navigate(getEmailVerificationUrl(email));
}
}
}
@@ -102,16 +94,14 @@ const AuthorizationContainer = () => {
const { status, data } = await onCreateToken({ email, password });
if (status === CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED) {
- const challengeUrl = data.state === LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED ? MFA_ENROLLMENT : MFA_CHALLENGE;
-
- navigate(challengeUrl, { state: { challenge: data } });
+ startLoginChallenge(data);
return;
}
completeSignIn(data);
} catch (error) {
if (isEmailNotVerifiedError(error)) {
- navigate(`${EMAIL_VERIFICATION}?${stringifySearchParams({ email })}`);
+ navigate(getEmailVerificationUrl(email));
return;
}
@@ -127,9 +117,10 @@ const AuthorizationContainer = () => {
trackEvent({ category: GA_EVENT_CATEGORIES.USER, action: "Registered", label: "optscale" });
- if (data.user.verified) {
- const caveats = macaroon.processCaveats(macaroon.deserialize(data.user.token).getCaveats());
- return dispatch(initialize({ ...data.user, caveats }));
+ const user = data?.user;
+
+ if (user?.verified && user.token) {
+ return completeSignIn({ ...user, token: user.token });
}
const { [OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME]: capability } = getSearchParams() as {
@@ -137,17 +128,12 @@ const AuthorizationContainer = () => {
};
await sendEmailVerificationCode(email, {
- [OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME]: Object.values(OPTSCALE_CAPABILITY).includes(capability)
+ [OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME]: Object.values(OPTSCALE_CAPABILITY).includes(capability)
? capability
: undefined
});
- return navigate(
- `${EMAIL_VERIFICATION}?${stringifySearchParams({
- email,
- [OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME]: capability
- })}`
- );
+ return navigate(getEmailVerificationUrl(email, capability));
};
const isInvited = queryInvited !== undefined;
diff --git a/ngui/ui/src/themes/finops/hooks/useCompleteLoginChallenge.ts b/ngui/ui/src/themes/finops/hooks/useCompleteLoginChallenge.ts
new file mode 100644
index 000000000..d0fa7f9fd
--- /dev/null
+++ b/ngui/ui/src/themes/finops/hooks/useCompleteLoginChallenge.ts
@@ -0,0 +1,9 @@
+import { completeLoginChallenge } from "@theme/api/auth/signIn";
+import { useAsyncCall } from "@theme/hooks/useAsyncCall";
+
+/** Drives the MFA step of sign-in. Rejects with an `AuthApiError`; sort it with `getMfaVerificationFailure`. */
+export const useCompleteLoginChallenge = () => {
+ const { onCall: onCompleteLoginChallenge, isLoading } = useAsyncCall(completeLoginChallenge);
+
+ return { onCompleteLoginChallenge, isLoading };
+};
diff --git a/ngui/ui/src/themes/finops/hooks/useCompleteSignIn.ts b/ngui/ui/src/themes/finops/hooks/useCompleteSignIn.ts
new file mode 100644
index 000000000..7a6dbb631
--- /dev/null
+++ b/ngui/ui/src/themes/finops/hooks/useCompleteSignIn.ts
@@ -0,0 +1,19 @@
+import { useCallback } from "react";
+import { useDispatch } from "react-redux";
+import { AuthToken } from "@theme/api/auth/signIn";
+import { initialize } from "containers/InitializeContainer/redux";
+import macaroon from "utils/macaroons";
+
+/** Stores an issued token the way the rest of the app expects — shared by every sign-in and registration path. */
+export const useCompleteSignIn = () => {
+ const dispatch = useDispatch();
+
+ return useCallback(
+ (authToken: Pick) => {
+ const caveats = macaroon.processCaveats(macaroon.deserialize(authToken.token).getCaveats());
+
+ dispatch(initialize({ ...authToken, caveats }));
+ },
+ [dispatch]
+ );
+};
diff --git a/ngui/ui/src/themes/finops/hooks/useLoginChallenge.ts b/ngui/ui/src/themes/finops/hooks/useLoginChallenge.ts
index 27f3a265c..70d25f225 100644
--- a/ngui/ui/src/themes/finops/hooks/useLoginChallenge.ts
+++ b/ngui/ui/src/themes/finops/hooks/useLoginChallenge.ts
@@ -1,12 +1,57 @@
-import { useLocation } from "react-router-dom";
-import { LoginChallenge } from "@theme/api/auth/signIn";
+import { useCallback } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { LOGIN_CHALLENGE_STATE, LoginChallenge, MfaTerminalFailureReason } from "@theme/api/auth/signIn";
+import { MFA_CHALLENGE, MFA_ENROLLMENT } from "@theme/urls";
+
+export type MfaTerminalFailure = {
+ reason: MfaTerminalFailureReason;
+ userEmail: string;
+};
+
+/** What the sign-in steps hand each other in router state — no token exists yet to fetch it with. */
+export type SignInRouterState = {
+ challenge?: LoginChallenge;
+ mfaTerminalFailure?: MfaTerminalFailure;
+};
+
+const getLoginChallengeUrl = ({ state }: Pick) =>
+ state === LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED ? MFA_ENROLLMENT : MFA_CHALLENGE;
+
+const useSignInRouterState = (): SignInRouterState => (useLocation().state as SignInRouterState | null) ?? {};
/**
* Reads the challenge handed over by the password step. It travels in router state because no
* token exists yet, so there is nothing to re-fetch it with.
*/
-export const useLoginChallenge = (): LoginChallenge | null => {
- const { state } = useLocation();
+export const useLoginChallenge = (): LoginChallenge | null => useSignInRouterState().challenge ?? null;
+
+export const useMfaTerminalFailure = (): MfaTerminalFailure | null => useSignInRouterState().mfaTerminalFailure ?? null;
+
+/** Opens the MFA or enrollment step for a `202` challenge. */
+export const useStartLoginChallenge = () => {
+ const navigate = useNavigate();
+
+ return useCallback(
+ (challenge: LoginChallenge, { replace = false }: { replace?: boolean } = {}) => {
+ const state: SignInRouterState = { challenge };
+
+ navigate(getLoginChallengeUrl(challenge), { replace, state });
+ },
+ [navigate]
+ );
+};
+
+/** Swaps the challenge for the failure in the current history entry, so Back never revives a dead challenge. */
+export const useEndLoginChallenge = () => {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+
+ return useCallback(
+ (mfaTerminalFailure: MfaTerminalFailure) => {
+ const state: SignInRouterState = { mfaTerminalFailure };
- return (state as { challenge?: LoginChallenge } | null)?.challenge ?? null;
+ navigate(pathname, { replace: true, state });
+ },
+ [navigate, pathname]
+ );
};
diff --git a/ngui/ui/src/themes/finops/hooks/useSecondsUntil.ts b/ngui/ui/src/themes/finops/hooks/useSecondsUntil.ts
new file mode 100644
index 000000000..3cfcf8e25
--- /dev/null
+++ b/ngui/ui/src/themes/finops/hooks/useSecondsUntil.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from "react";
+import { getCurrentUTCTimeInSec } from "utils/datetime";
+
+/** Seconds left until a unix timestamp, ticking once a second and stopping at 0. */
+export const useSecondsUntil = (unixSeconds: number) => {
+ const [now, setNow] = useState(getCurrentUTCTimeInSec);
+
+ useEffect(() => {
+ const interval = setInterval(() => setNow(getCurrentUTCTimeInSec()), 1000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return Math.max(0, unixSeconds - now);
+};
diff --git a/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/AuthenticatorAppIcon.tsx b/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/AuthenticatorAppIcon.tsx
new file mode 100644
index 000000000..cf66554f3
--- /dev/null
+++ b/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/AuthenticatorAppIcon.tsx
@@ -0,0 +1,24 @@
+import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
+
+const AuthenticatorAppIcon = (props: SvgIconProps) => (
+
+
+
+
+
+
+);
+
+export default AuthenticatorAppIcon;
diff --git a/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/index.ts b/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/index.ts
new file mode 100644
index 000000000..5785752fe
--- /dev/null
+++ b/ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/index.ts
@@ -0,0 +1,3 @@
+import AuthenticatorAppIcon from "./AuthenticatorAppIcon";
+
+export default AuthenticatorAppIcon;
diff --git a/ngui/ui/src/themes/finops/pages/MfaChallenge/MfaChallenge.tsx b/ngui/ui/src/themes/finops/pages/MfaChallenge/MfaChallenge.tsx
index 15f67072d..2e1ddbb59 100644
--- a/ngui/ui/src/themes/finops/pages/MfaChallenge/MfaChallenge.tsx
+++ b/ngui/ui/src/themes/finops/pages/MfaChallenge/MfaChallenge.tsx
@@ -1,7 +1,5 @@
-import LoginChallengeStep from "@theme/components/LoginChallengeStep";
+import MfaChallengeStep from "@theme/components/MfaChallengeStep";
-const MfaChallenge = () => (
-
-);
+const MfaChallenge = () => ;
export default MfaChallenge;
diff --git a/ngui/ui/src/themes/finops/tests/renderWithProviders.tsx b/ngui/ui/src/themes/finops/tests/renderWithProviders.tsx
new file mode 100644
index 000000000..e9e02408f
--- /dev/null
+++ b/ngui/ui/src/themes/finops/tests/renderWithProviders.tsx
@@ -0,0 +1,63 @@
+import { ReactNode } from "react";
+import { MockedProvider } from "@apollo/client/testing";
+import { createRoot } from "react-dom/client";
+import { act } from "react-dom/test-utils";
+import { IntlProvider } from "react-intl";
+import { Provider } from "react-redux";
+import { MemoryRouter, MemoryRouterProps, Routes } from "react-router-dom";
+import { legacy_createStore as createStore } from "redux";
+import ThemeProviderWrapper from "components/ThemeProviderWrapper";
+import { ALERTS } from "components/TopAlertWrapper/reducer";
+import intlConfig from "translations/react-intl-config";
+
+// Opts every spec rendering through here into React 18's act() environment, so updates are flushed, not warned about.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+// A plain store rather than redux-mock-store: nothing asserts on dispatched actions, and that package
+// ships no types. Greeter reaches TopAlertWrapper, which needs the alerts slice to exist.
+const store = createStore(() => ({ [ALERTS]: {} }));
+
+type RenderWithProvidersOptions = {
+ initialEntry: NonNullable[number];
+ /** `` elements; add stand-ins for the pages a redirect lands on, so it is observable in the DOM. */
+ routes: ReactNode;
+};
+
+/**
+ * Renders theme components that reach `Greeter` or `ThemeProviderWrapper`, which upstream's
+ * `TestProvider` cannot: it supplies no Apollo client, no alerts slice and no way to seed route state.
+ */
+export const renderWithProviders = ({ initialEntry, routes }: RenderWithProvidersOptions) => {
+ const container = document.createElement("div");
+ // Attached so a submit button click submits its form, as it does in a browser.
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(
+
+
+
+
+
+ {routes}
+
+
+
+
+
+ );
+ });
+
+ const find = (testId: string) => container.querySelector(`[data-test-id='${testId}']`);
+
+ return {
+ container,
+ find,
+ text: (testId: string) => find(testId)?.textContent,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ }
+ };
+};
diff --git a/ngui/ui/src/themes/finops/translations/de-DE/app.json b/ngui/ui/src/themes/finops/translations/de-DE/app.json
index c471d1e3a..d52157b40 100644
--- a/ngui/ui/src/themes/finops/translations/de-DE/app.json
+++ b/ngui/ui/src/themes/finops/translations/de-DE/app.json
@@ -2246,15 +2246,32 @@
"showWeekends": "Wochenenden anzeigen",
"shuffle": "Mischen",
"signIn": "Anmelden",
+ "signIn:approveSignInRequest": "Anmeldeanfrage bestätigen",
+ "signIn:attemptsLeft": "{count, plural, one {# Versuch übrig} other {# Versuche übrig}}",
+ "signIn:authenticatorApp": "Authenticator-App",
+ "signIn:challengeExpired": "Diese Anmeldeanfrage ist abgelaufen oder nicht mehr gültig. Melden Sie sich erneut an, um fortzufahren.",
+ "signIn:challengeExpiresIn": "Läuft ab in {time}",
+ "signIn:challengeLocked": "Zu viele falsche Codes – diese Anmeldeanfrage wurde zu Ihrer Sicherheit beendet. Melden Sie sich erneut an, um fortzufahren.",
"signIn:challengeNotImplemented": "Dieser Schritt ist noch nicht implementiert.",
"signIn:continue": "Weiter",
+ "signIn:defaultMethod": "Standardmethode",
+ "signIn:emailCodeSent": "Ein Bestätigungscode wurde an {email} gesendet.",
+ "signIn:emailVerificationCode": "Bestätigungscode per E-Mail",
"signIn:enrollmentRequiredDescription": "Ihre Organisation erfordert eine zweistufige Verifizierung. Fügen Sie eine Authentifizierungsmethode hinzu, um die Anmeldung abzuschließen.",
"signIn:enrollmentRequiredTitle": "Zweistufige Verifizierung einrichten",
+ "signIn:enterVerificationCode": "Geben Sie den {length}-stelligen Bestätigungscode ein:",
"signIn:invalidCredentials": "Die eingegebene E-Mail-Adresse oder das Passwort ist falsch.",
- "signIn:mfaRequiredDescription": "Geben Sie den Code Ihrer Authentifizierungsmethode ein, um die Anmeldung abzuschließen.",
- "signIn:mfaRequiredTitle": "Zweistufige Verifizierung",
+ "signIn:invalidVerificationCode": "Dieser Code ist falsch. Überprüfen Sie ihn und versuchen Sie es erneut.",
+ "signIn:noVerificationMethods": "Für dieses Konto ist keine Verifizierungsmethode verfügbar. Wenden Sie sich an Ihren Administrator, um die zweistufige Verifizierung zurückzusetzen.",
+ "signIn:signInAgain": "Erneut anmelden",
"signIn:signingInAs": "Anmeldung als {email}",
+ "signIn:totpInstructions": "Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten Code ein.",
"signIn:useDifferentEmail": "Andere E-Mail-Adresse verwenden",
+ "signIn:useDifferentMethod": "Andere Methode verwenden",
+ "signIn:useSelectedMethod": "Ausgewählte Methode verwenden",
+ "signIn:verificationCodeDigit": "Ziffer {position} von {length}",
+ "signIn:verify": "Bestätigen",
+ "signIn:verifyYourIdentity": "Bestätigen Sie Ihre Identität",
"signInWith": "Anmelden mit",
"signInWithGoogleIsNotConfigured": "Die Anmeldung mit Google ist nicht konfiguriert.",
"signInWithMicrosoftIsNotConfigured": "Die Anmeldung mit Microsoft ist nicht konfiguriert.",
diff --git a/ngui/ui/src/themes/finops/translations/en-US/app-override.json b/ngui/ui/src/themes/finops/translations/en-US/app-override.json
index f7de4cfae..7bf1f0dfb 100644
--- a/ngui/ui/src/themes/finops/translations/en-US/app-override.json
+++ b/ngui/ui/src/themes/finops/translations/en-US/app-override.json
@@ -2099,15 +2099,32 @@
"showWeekends": "Show weekends",
"shuffle": "Shuffle",
"signIn": "Sign in",
+ "signIn:approveSignInRequest": "Approve sign-in request",
+ "signIn:attemptsLeft": "{count, plural, one {# attempt left} other {# attempts left}}",
+ "signIn:authenticatorApp": "Authenticator app",
+ "signIn:challengeExpired": "This sign-in request has expired or is no longer valid. Sign in again to continue.",
+ "signIn:challengeExpiresIn": "Expires in {time}",
+ "signIn:challengeLocked": "Too many incorrect codes, so this sign-in request was stopped for your security. Sign in again to continue.",
"signIn:challengeNotImplemented": "This step is not implemented yet.",
"signIn:continue": "Continue",
+ "signIn:defaultMethod": "Default method",
+ "signIn:emailCodeSent": "A verification code has been sent to {email}.",
+ "signIn:emailVerificationCode": "E-mail verification code",
"signIn:enrollmentRequiredDescription": "Your organization requires two-step verification. Add an authentication method to finish signing in.",
"signIn:enrollmentRequiredTitle": "Set up two-step verification",
+ "signIn:enterVerificationCode": "Enter the {length}-digit verification code:",
"signIn:invalidCredentials": "The email or password you entered is incorrect.",
- "signIn:mfaRequiredDescription": "Enter the code from your authentication method to finish signing in.",
- "signIn:mfaRequiredTitle": "Two-step verification",
+ "signIn:invalidVerificationCode": "That code is incorrect. Check it and try again.",
+ "signIn:noVerificationMethods": "There is no verification method available for this account. Contact your administrator to reset two-step verification.",
+ "signIn:signInAgain": "Sign in again",
"signIn:signingInAs": "Signing in as {email}",
+ "signIn:totpInstructions": "Open your authenticator app and enter the code it shows.",
"signIn:useDifferentEmail": "Use a different email",
+ "signIn:useDifferentMethod": "Use a different method",
+ "signIn:useSelectedMethod": "Use selected method",
+ "signIn:verificationCodeDigit": "Digit {position} of {length}",
+ "signIn:verify": "Verify",
+ "signIn:verifyYourIdentity": "Verify your identity",
"signInWith": "Sign in with",
"signInWithGoogleIsNotConfigured": "Sign in with Google is not configured.",
"signInWithMicrosoftIsNotConfigured": "Sign in with Microsoft is not configured.",
diff --git a/ngui/ui/src/themes/finops/translations/es-ES/app.json b/ngui/ui/src/themes/finops/translations/es-ES/app.json
index 501e442d1..7d4a651d6 100644
--- a/ngui/ui/src/themes/finops/translations/es-ES/app.json
+++ b/ngui/ui/src/themes/finops/translations/es-ES/app.json
@@ -2246,15 +2246,32 @@
"showWeekends": "Mostrar fines de semana",
"shuffle": "Mezclar",
"signIn": "Iniciar sesión",
+ "signIn:approveSignInRequest": "Aprobar la solicitud de inicio de sesión",
+ "signIn:attemptsLeft": "{count, plural, one {Queda # intento} other {Quedan # intentos}}",
+ "signIn:authenticatorApp": "Aplicación de autenticación",
+ "signIn:challengeExpired": "Esta solicitud de inicio de sesión ha caducado o ya no es válida. Vuelva a iniciar sesión para continuar.",
+ "signIn:challengeExpiresIn": "Caduca en {time}",
+ "signIn:challengeLocked": "Demasiados códigos incorrectos: esta solicitud de inicio de sesión se ha detenido por su seguridad. Vuelva a iniciar sesión para continuar.",
"signIn:challengeNotImplemented": "Este paso aún no está implementado.",
"signIn:continue": "Continuar",
+ "signIn:defaultMethod": "Método predeterminado",
+ "signIn:emailCodeSent": "Se ha enviado un código de verificación a {email}.",
+ "signIn:emailVerificationCode": "Código de verificación por correo electrónico",
"signIn:enrollmentRequiredDescription": "Su organización requiere la verificación en dos pasos. Añada un método de autenticación para completar el inicio de sesión.",
"signIn:enrollmentRequiredTitle": "Configurar la verificación en dos pasos",
+ "signIn:enterVerificationCode": "Introduzca el código de verificación de {length} dígitos:",
"signIn:invalidCredentials": "El correo electrónico o la contraseña introducidos no son correctos.",
- "signIn:mfaRequiredDescription": "Introduzca el código de su método de autenticación para completar el inicio de sesión.",
- "signIn:mfaRequiredTitle": "Verificación en dos pasos",
+ "signIn:invalidVerificationCode": "Ese código es incorrecto. Compruébelo e inténtelo de nuevo.",
+ "signIn:noVerificationMethods": "No hay ningún método de verificación disponible para esta cuenta. Póngase en contacto con su administrador para restablecer la verificación en dos pasos.",
+ "signIn:signInAgain": "Volver a iniciar sesión",
"signIn:signingInAs": "Iniciando sesión como {email}",
+ "signIn:totpInstructions": "Abra su aplicación de autenticación e introduzca el código que muestra.",
"signIn:useDifferentEmail": "Usar otro correo electrónico",
+ "signIn:useDifferentMethod": "Usar otro método",
+ "signIn:useSelectedMethod": "Usar el método seleccionado",
+ "signIn:verificationCodeDigit": "Dígito {position} de {length}",
+ "signIn:verify": "Verificar",
+ "signIn:verifyYourIdentity": "Verifique su identidad",
"signInWith": "Iniciar sesión con",
"signInWithGoogleIsNotConfigured": "El inicio de sesión con Google no está configurado.",
"signInWithMicrosoftIsNotConfigured": "El inicio de sesión con Microsoft no está configurado.",
diff --git a/ngui/ui/src/themes/finops/translations/fr-FR/app.json b/ngui/ui/src/themes/finops/translations/fr-FR/app.json
index 1268c3409..5d3e512db 100644
--- a/ngui/ui/src/themes/finops/translations/fr-FR/app.json
+++ b/ngui/ui/src/themes/finops/translations/fr-FR/app.json
@@ -2246,15 +2246,32 @@
"showWeekends": "Afficher les week-ends",
"shuffle": "Mélanger",
"signIn": "Se connecter",
+ "signIn:approveSignInRequest": "Approuver la demande de connexion",
+ "signIn:attemptsLeft": "{count, plural, one {# tentative restante} other {# tentatives restantes}}",
+ "signIn:authenticatorApp": "Application d'authentification",
+ "signIn:challengeExpired": "Cette demande de connexion a expiré ou n'est plus valide. Reconnectez-vous pour continuer.",
+ "signIn:challengeExpiresIn": "Expire dans {time}",
+ "signIn:challengeLocked": "Trop de codes incorrects : cette demande de connexion a été arrêtée pour votre sécurité. Reconnectez-vous pour continuer.",
"signIn:challengeNotImplemented": "Cette étape n'est pas encore implémentée.",
"signIn:continue": "Continuer",
+ "signIn:defaultMethod": "Méthode par défaut",
+ "signIn:emailCodeSent": "Un code de vérification a été envoyé à {email}.",
+ "signIn:emailVerificationCode": "Code de vérification par e-mail",
"signIn:enrollmentRequiredDescription": "Votre organisation exige une vérification en deux étapes. Ajoutez une méthode d'authentification pour terminer la connexion.",
"signIn:enrollmentRequiredTitle": "Configurer la vérification en deux étapes",
+ "signIn:enterVerificationCode": "Saisissez le code de vérification à {length} chiffres :",
"signIn:invalidCredentials": "L'adresse e-mail ou le mot de passe saisi est incorrect.",
- "signIn:mfaRequiredDescription": "Saisissez le code de votre méthode d'authentification pour terminer la connexion.",
- "signIn:mfaRequiredTitle": "Vérification en deux étapes",
+ "signIn:invalidVerificationCode": "Ce code est incorrect. Vérifiez-le et réessayez.",
+ "signIn:noVerificationMethods": "Aucune méthode de vérification n'est disponible pour ce compte. Contactez votre administrateur pour réinitialiser la vérification en deux étapes.",
+ "signIn:signInAgain": "Se reconnecter",
"signIn:signingInAs": "Connexion en tant que {email}",
+ "signIn:totpInstructions": "Ouvrez votre application d'authentification et saisissez le code affiché.",
"signIn:useDifferentEmail": "Utiliser une autre adresse e-mail",
+ "signIn:useDifferentMethod": "Utiliser une autre méthode",
+ "signIn:useSelectedMethod": "Utiliser la méthode sélectionnée",
+ "signIn:verificationCodeDigit": "Chiffre {position} sur {length}",
+ "signIn:verify": "Vérifier",
+ "signIn:verifyYourIdentity": "Vérifiez votre identité",
"signInWith": "Se connecter avec",
"signInWithGoogleIsNotConfigured": "La connexion avec Google n'est pas configurée.",
"signInWithMicrosoftIsNotConfigured": "La connexion avec Microsoft n'est pas configurée.",
diff --git a/ngui/ui/src/themes/finops/translations/ja-JP/app.json b/ngui/ui/src/themes/finops/translations/ja-JP/app.json
index 8ddbd02f0..656c5d771 100644
--- a/ngui/ui/src/themes/finops/translations/ja-JP/app.json
+++ b/ngui/ui/src/themes/finops/translations/ja-JP/app.json
@@ -2255,15 +2255,32 @@
"showWeekends": "週末を表示",
"shuffle": "シャッフル",
"signIn": "サインイン",
+ "signIn:approveSignInRequest": "サインイン要求を承認",
+ "signIn:attemptsLeft": "{count, plural, other {残り # 回}}",
+ "signIn:authenticatorApp": "認証アプリ",
+ "signIn:challengeExpired": "このサインイン要求は期限切れか、無効になっています。続行するには再度サインインしてください。",
+ "signIn:challengeExpiresIn": "有効期限まで {time}",
+ "signIn:challengeLocked": "誤ったコードが多すぎるため、セキュリティ保護のためこのサインイン要求は停止されました。続行するには再度サインインしてください。",
"signIn:challengeNotImplemented": "このステップはまだ実装されていません。",
"signIn:continue": "続行",
+ "signIn:defaultMethod": "既定の方法",
+ "signIn:emailCodeSent": "確認コードを {email} に送信しました。",
+ "signIn:emailVerificationCode": "メール確認コード",
"signIn:enrollmentRequiredDescription": "組織で二段階認証が必要です。サインインを完了するには認証方法を追加してください。",
"signIn:enrollmentRequiredTitle": "二段階認証の設定",
+ "signIn:enterVerificationCode": "{length} 桁の確認コードを入力してください:",
"signIn:invalidCredentials": "入力されたメールアドレスまたはパスワードが正しくありません。",
- "signIn:mfaRequiredDescription": "サインインを完了するには、認証方法のコードを入力してください。",
- "signIn:mfaRequiredTitle": "二段階認証",
+ "signIn:invalidVerificationCode": "コードが正しくありません。確認してもう一度お試しください。",
+ "signIn:noVerificationMethods": "このアカウントで利用できる認証方法がありません。二段階認証をリセットするには管理者にお問い合わせください。",
+ "signIn:signInAgain": "再度サインイン",
"signIn:signingInAs": "{email} としてサインイン中",
+ "signIn:totpInstructions": "認証アプリを開き、表示されているコードを入力してください。",
"signIn:useDifferentEmail": "別のメールアドレスを使用",
+ "signIn:useDifferentMethod": "別の方法を使用",
+ "signIn:useSelectedMethod": "選択した方法を使用",
+ "signIn:verificationCodeDigit": "{length} 桁中 {position} 桁目",
+ "signIn:verify": "確認",
+ "signIn:verifyYourIdentity": "本人確認",
"signInWith": "次でサインイン:",
"signInWithGoogleIsNotConfigured": "Googleでのサインインが構成されていません。",
"signInWithMicrosoftIsNotConfigured": "Microsoftでのサインインが構成されていません。",
diff --git a/ngui/ui/src/themes/finops/translations/nl-NL/app.json b/ngui/ui/src/themes/finops/translations/nl-NL/app.json
index 3d27fdadc..adf7d5498 100644
--- a/ngui/ui/src/themes/finops/translations/nl-NL/app.json
+++ b/ngui/ui/src/themes/finops/translations/nl-NL/app.json
@@ -2255,15 +2255,32 @@
"showWeekends": "Weekenden tonen",
"shuffle": "Shuffle",
"signIn": "Inloggen",
+ "signIn:approveSignInRequest": "Aanmeldverzoek goedkeuren",
+ "signIn:attemptsLeft": "{count, plural, one {Nog # poging} other {Nog # pogingen}}",
+ "signIn:authenticatorApp": "Authenticator-app",
+ "signIn:challengeExpired": "Dit aanmeldverzoek is verlopen of niet meer geldig. Meld u opnieuw aan om verder te gaan.",
+ "signIn:challengeExpiresIn": "Verloopt over {time}",
+ "signIn:challengeLocked": "Te veel onjuiste codes: dit aanmeldverzoek is voor uw veiligheid gestopt. Meld u opnieuw aan om verder te gaan.",
"signIn:challengeNotImplemented": "Deze stap is nog niet geïmplementeerd.",
"signIn:continue": "Doorgaan",
+ "signIn:defaultMethod": "Standaardmethode",
+ "signIn:emailCodeSent": "Er is een verificatiecode verzonden naar {email}.",
+ "signIn:emailVerificationCode": "Verificatiecode per e-mail",
"signIn:enrollmentRequiredDescription": "Uw organisatie vereist verificatie in twee stappen. Voeg een authenticatiemethode toe om het inloggen te voltooien.",
"signIn:enrollmentRequiredTitle": "Verificatie in twee stappen instellen",
+ "signIn:enterVerificationCode": "Voer de verificatiecode van {length} cijfers in:",
"signIn:invalidCredentials": "Het ingevoerde e-mailadres of wachtwoord is onjuist.",
- "signIn:mfaRequiredDescription": "Voer de code van uw authenticatiemethode in om het inloggen te voltooien.",
- "signIn:mfaRequiredTitle": "Verificatie in twee stappen",
+ "signIn:invalidVerificationCode": "Deze code is onjuist. Controleer de code en probeer het opnieuw.",
+ "signIn:noVerificationMethods": "Er is geen verificatiemethode beschikbaar voor dit account. Neem contact op met uw beheerder om verificatie in twee stappen opnieuw in te stellen.",
+ "signIn:signInAgain": "Opnieuw aanmelden",
"signIn:signingInAs": "Inloggen als {email}",
+ "signIn:totpInstructions": "Open uw authenticator-app en voer de weergegeven code in.",
"signIn:useDifferentEmail": "Een ander e-mailadres gebruiken",
+ "signIn:useDifferentMethod": "Een andere methode gebruiken",
+ "signIn:useSelectedMethod": "Geselecteerde methode gebruiken",
+ "signIn:verificationCodeDigit": "Cijfer {position} van {length}",
+ "signIn:verify": "Verifiëren",
+ "signIn:verifyYourIdentity": "Verifieer uw identiteit",
"signInWith": "Inloggen met",
"signInWithGoogleIsNotConfigured": "Inloggen met Google is niet geconfigureerd.",
"signInWithMicrosoftIsNotConfigured": "Inloggen met Microsoft is niet geconfigureerd.",
diff --git a/ngui/ui/src/themes/finops/translations/pl-PL/app.json b/ngui/ui/src/themes/finops/translations/pl-PL/app.json
index a32210c34..fd7b1c219 100644
--- a/ngui/ui/src/themes/finops/translations/pl-PL/app.json
+++ b/ngui/ui/src/themes/finops/translations/pl-PL/app.json
@@ -2246,15 +2246,32 @@
"showWeekends": "Pokaż weekendy",
"shuffle": "Przetasuj",
"signIn": "Zaloguj się",
+ "signIn:approveSignInRequest": "Zatwierdź żądanie logowania",
+ "signIn:attemptsLeft": "{count, plural, one {Pozostała # próba} few {Pozostały # próby} many {Pozostało # prób} other {Pozostało # próby}}",
+ "signIn:authenticatorApp": "Aplikacja uwierzytelniająca",
+ "signIn:challengeExpired": "To żądanie logowania wygasło lub jest już nieważne. Zaloguj się ponownie, aby kontynuować.",
+ "signIn:challengeExpiresIn": "Wygasa za {time}",
+ "signIn:challengeLocked": "Zbyt wiele nieprawidłowych kodów — to żądanie logowania zostało zatrzymane ze względów bezpieczeństwa. Zaloguj się ponownie, aby kontynuować.",
"signIn:challengeNotImplemented": "Ten krok nie został jeszcze zaimplementowany.",
"signIn:continue": "Kontynuuj",
+ "signIn:defaultMethod": "Metoda domyślna",
+ "signIn:emailCodeSent": "Kod weryfikacyjny został wysłany na adres {email}.",
+ "signIn:emailVerificationCode": "Kod weryfikacyjny e-mail",
"signIn:enrollmentRequiredDescription": "Twoja organizacja wymaga weryfikacji dwuetapowej. Dodaj metodę uwierzytelniania, aby dokończyć logowanie.",
"signIn:enrollmentRequiredTitle": "Skonfiguruj weryfikację dwuetapową",
+ "signIn:enterVerificationCode": "Wprowadź {length}-cyfrowy kod weryfikacyjny:",
"signIn:invalidCredentials": "Podany adres e-mail lub hasło są nieprawidłowe.",
- "signIn:mfaRequiredDescription": "Wprowadź kod z metody uwierzytelniania, aby dokończyć logowanie.",
- "signIn:mfaRequiredTitle": "Weryfikacja dwuetapowa",
+ "signIn:invalidVerificationCode": "Ten kod jest nieprawidłowy. Sprawdź go i spróbuj ponownie.",
+ "signIn:noVerificationMethods": "Dla tego konta nie jest dostępna żadna metoda weryfikacji. Skontaktuj się z administratorem, aby zresetować weryfikację dwuetapową.",
+ "signIn:signInAgain": "Zaloguj się ponownie",
"signIn:signingInAs": "Logowanie jako {email}",
+ "signIn:totpInstructions": "Otwórz aplikację uwierzytelniającą i wprowadź wyświetlany kod.",
"signIn:useDifferentEmail": "Użyj innego adresu e-mail",
+ "signIn:useDifferentMethod": "Użyj innej metody",
+ "signIn:useSelectedMethod": "Użyj wybranej metody",
+ "signIn:verificationCodeDigit": "Cyfra {position} z {length}",
+ "signIn:verify": "Zweryfikuj",
+ "signIn:verifyYourIdentity": "Potwierdź swoją tożsamość",
"signInWith": "Zaloguj się za pomocą",
"signInWithGoogleIsNotConfigured": "Logowanie za pomocą Google nie jest skonfigurowane.",
"signInWithMicrosoftIsNotConfigured": "Logowanie za pomocą Microsoft nie jest skonfigurowane.",
diff --git a/ngui/ui/src/themes/finops/urls.test.ts b/ngui/ui/src/themes/finops/urls.test.ts
new file mode 100644
index 000000000..67e3bd8b8
--- /dev/null
+++ b/ngui/ui/src/themes/finops/urls.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { getEmailVerificationUrl, getLoginUrl } from "./urls";
+
+describe("getLoginUrl", () => {
+ it("opens the login page with the address filled in", () => {
+ expect(getLoginUrl("finops.ffc@example.com")).toBe("/login?email=finops.ffc%40example.com");
+ });
+});
+
+describe("getEmailVerificationUrl", () => {
+ it("carries only the address when there is no capability", () => {
+ expect(getEmailVerificationUrl("finops.ffc@example.com")).toBe("/email-verification?email=finops.ffc%40example.com");
+ });
+
+ it("carries the capability after the address", () => {
+ expect(getEmailVerificationUrl("finops.ffc@example.com", "finops")).toBe(
+ "/email-verification?email=finops.ffc%40example.com&capability=finops"
+ );
+ });
+});
diff --git a/ngui/ui/src/themes/finops/urls.ts b/ngui/ui/src/themes/finops/urls.ts
index 96aa9cd99..4ea8c3e9b 100644
--- a/ngui/ui/src/themes/finops/urls.ts
+++ b/ngui/ui/src/themes/finops/urls.ts
@@ -1,3 +1,6 @@
+import { EMAIL_VERIFICATION, LOGIN, OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME } from "@main/urls";
+import { stringifySearchParams } from "utils/network";
+
export * from "@main/urls";
export const PENDING_INVITATIONS = "/pending-invitations";
@@ -6,6 +9,12 @@ export const PENDING_INVITATIONS = "/pending-invitations";
export const MFA_CHALLENGE = "/mfa";
export const MFA_ENROLLMENT = "/mfa-enrollment";
+/** The login page with the address already filled in, for starting sign-in over. */
+export const getLoginUrl = (email: string) => `${LOGIN}?${stringifySearchParams({ email })}`;
+
+export const getEmailVerificationUrl = (email: string, capability?: string) =>
+ `${EMAIL_VERIFICATION}?${stringifySearchParams({ email, [OPTSCALE_CAPABILITY_QUERY_PARAMETER_NAME]: capability })}`;
+
const docUrl = (path: string) => `${DOCS_HYSTAX_OPTSCALE}${path}` as const;
const dataSourceConnectionDocUrl = (docPath: string) => docUrl(`e2e_guides/${docPath}`);
diff --git a/ngui/ui/vite.config.mts b/ngui/ui/vite.config.mts
index 864fa542e..fda8379f9 100644
--- a/ngui/ui/vite.config.mts
+++ b/ngui/ui/vite.config.mts
@@ -1,8 +1,10 @@
import react from "@vitejs/plugin-react-swc";
import { defineConfig, loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+import { isMockedApiEnabled } from "./src/themes/finops/api/mocked/config";
import { MockedApi } from "./src/themes/finops/api/mocked/http/mockedApiPlugin";
-import { ThemeResolver } from './src/utils/themeResolver';
+import { featureFlags } from "./src/themes/finops/theme.features";
+import { ThemeResolver } from "./src/utils/themeResolver";
const parseViteHost = (value?: string): string | boolean => {
const trimmed = value?.trim();
@@ -74,7 +76,7 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
rewrite: (p) => {
- return p.indexOf('/api/') === 0 ? p.replace(/^\/api/, "/") : p;
+ return p.indexOf("/api/") === 0 ? p.replace(/^\/api/, "/") : p;
}
}
}
@@ -95,10 +97,10 @@ export default defineConfig(({ mode }) => {
},
plugins: [
react(),
- ThemeResolver(VITE_APP_THEME || ''),
+ ThemeResolver(VITE_APP_THEME || ""),
tsconfigPaths(),
- // Answers the endpoints the backend has not shipped yet; VITE_MOCKED_API=false opts out.
- MockedApi(VITE_MOCKED_API !== "false")
+ // Answers the endpoints the backend has not shipped yet, while the mfa_login theme flag is on.
+ MockedApi(isMockedApiEnabled({ mfaLogin: featureFlags.mfa_login, mockedApiEnv: VITE_MOCKED_API }))
],
// TODO: Some of the tests are still failing
test: {