diff --git a/apps/client/package.json b/apps/client/package.json
index e8001c31..0c9fcc62 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -39,6 +39,7 @@
"jwt-decode": "^4.0.0",
"katex": "0.16.22",
"lowlight": "^3.3.0",
+ "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 6c2289d8..73a8c1fd 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -358,7 +358,7 @@
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
- "Reading": "Reading"
+ "Reading": "Reading",
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
@@ -402,5 +402,71 @@
"Close (Escape)": "Close (Escape)",
"Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
- "Replace all": "Replace all"
+ "Replace all": "Replace all",
+ "Error": "Error",
+ "Failed to disable MFA": "Failed to disable MFA",
+ "Disable two-factor authentication": "Disable two-factor authentication",
+ "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
+ "Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:",
+ "Two-factor authentication has been enabled": "Two-factor authentication has been enabled",
+ "Two-factor authentication has been disabled": "Two-factor authentication has been disabled",
+ "2-step verification": "2-step verification",
+ "Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.",
+ "Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.",
+ "Add 2FA method": "Add 2FA method",
+ "Backup codes": "Backup codes",
+ "Disable": "Disable",
+ "Invalid verification code": "Invalid verification code",
+ "New backup codes have been generated": "New backup codes have been generated",
+ "Failed to regenerate backup codes": "Failed to regenerate backup codes",
+ "About backup codes": "About backup codes",
+ "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
+ "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.",
+ "Confirm password": "Confirm password",
+ "Generate new backup codes": "Generate new backup codes",
+ "Save your new backup codes": "Save your new backup codes",
+ "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
+ "Your new backup codes": "Your new backup codes",
+ "I've saved my backup codes": "I've saved my backup codes",
+ "Failed to setup MFA": "Failed to setup MFA",
+ "Setup & Verify": "Setup & Verify",
+ "Add to authenticator": "Add to authenticator",
+ "1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
+ "Can't scan the code?": "Can't scan the code?",
+ "Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:",
+ "2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator",
+ "Verify and enable": "Verify and enable",
+ "Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.",
+ "Backup": "Backup",
+ "Save codes": "Save codes",
+ "Save your backup codes": "Save your backup codes",
+ "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
+ "Print": "Print",
+ "Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.",
+ "Two-Factor authentication required": "Two-factor authentication required",
+ "Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users",
+ "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
+ "Set up two-factor authentication": "Set up two-factor authentication",
+ "Cancel and logout": "Cancel and logout",
+ "Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.",
+ "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
+ "Password is required": "Password is required",
+ "Password must be at least 8 characters": "Password must be at least 8 characters",
+ "Please enter a 6-digit code": "Please enter a 6-digit code",
+ "Code must be exactly 6 digits": "Code must be exactly 6 digits",
+ "Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app",
+ "Need help authenticating?": "Need help authenticating?",
+ "MFA QR Code": "MFA QR Code",
+ "Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.",
+ "Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.",
+ "Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.",
+ "Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.",
+ "Two-factor authentication": "Two-factor authentication",
+ "Use authenticator app instead": "Use authenticator app instead",
+ "Verify backup code": "Verify backup code",
+ "Use backup code": "Use backup code",
+ "Enter one of your backup codes": "Enter one of your backup codes",
+ "Backup code": "Backup code",
+ "Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
+ "Verify": "Verify"
}
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 6fab378c..29b4bb0e 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -29,8 +29,10 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
import SharedPage from "@/pages/share/shared-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
-import ShareRedirect from '@/pages/share/share-redirect.tsx';
+import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
+import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
+import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
export default function App() {
const { t } = useTranslation();
@@ -45,6 +47,11 @@ export default function App() {
} />
} />
} />
+ } />
+ }
+ />
{!isCloud() && (
} />
@@ -58,7 +65,10 @@ export default function App() {
)}
}>
- } />
+ }
+ />
} />
diff --git a/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
new file mode 100644
index 00000000..fef4b8cb
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import {
+ TextInput,
+ Button,
+ Stack,
+ Text,
+ Alert,
+} from "@mantine/core";
+import { IconKey, IconAlertCircle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+
+interface MfaBackupCodeInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ error?: string;
+ onSubmit: () => void;
+ onCancel: () => void;
+ isLoading?: boolean;
+}
+
+export function MfaBackupCodeInput({
+ value,
+ onChange,
+ error,
+ onSubmit,
+ onCancel,
+ isLoading,
+}: MfaBackupCodeInputProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ } color="blue" variant="light">
+
+ {t(
+ "Enter one of your backup codes. Each backup code can only be used once.",
+ )}
+
+
+
+ onChange(e.currentTarget.value.toUpperCase())}
+ error={error}
+ autoFocus
+ maxLength={8}
+ styles={{
+ input: {
+ fontFamily: "monospace",
+ letterSpacing: "0.1em",
+ fontSize: "1rem",
+ },
+ }}
+ />
+
+
+ }
+ >
+ {t("Verify backup code")}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
new file mode 100644
index 00000000..fdad6811
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
@@ -0,0 +1,193 @@
+import React, { useState } from "react";
+import {
+ Modal,
+ Stack,
+ Text,
+ Button,
+ Paper,
+ Group,
+ List,
+ Code,
+ CopyButton,
+ Alert,
+ PasswordInput,
+} from "@mantine/core";
+import {
+ IconRefresh,
+ IconCopy,
+ IconCheck,
+ IconAlertCircle,
+} from "@tabler/icons-react";
+import { useMutation } from "@tanstack/react-query";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import { regenerateBackupCodes } from "@/ee/mfa";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+
+interface MfaBackupCodesModalProps {
+ opened: boolean;
+ onClose: () => void;
+}
+
+const formSchema = z.object({
+ confirmPassword: z.string().min(1, { message: "Password is required" }),
+});
+
+export function MfaBackupCodesModal({
+ opened,
+ onClose,
+}: MfaBackupCodesModalProps) {
+ const { t } = useTranslation();
+ const [backupCodes, setBackupCodes] = useState([]);
+ const [showNewCodes, setShowNewCodes] = useState(false);
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ confirmPassword: "",
+ },
+ });
+
+ const regenerateMutation = useMutation({
+ mutationFn: (data: { confirmPassword: string }) =>
+ regenerateBackupCodes(data),
+ onSuccess: (data) => {
+ setBackupCodes(data.backupCodes);
+ setShowNewCodes(true);
+ form.reset();
+ notifications.show({
+ title: t("Success"),
+ message: t("New backup codes have been generated"),
+ });
+ },
+ onError: (error: any) => {
+ notifications.show({
+ title: t("Error"),
+ message:
+ error.response?.data?.message ||
+ t("Failed to regenerate backup codes"),
+ color: "red",
+ });
+ },
+ });
+
+ const handleRegenerate = (values: { confirmPassword: string }) => {
+ regenerateMutation.mutate(values);
+ };
+
+ const handleClose = () => {
+ setShowNewCodes(false);
+ setBackupCodes([]);
+ form.reset();
+ onClose();
+ };
+
+ return (
+
+
+ {!showNewCodes ? (
+
+ ) : (
+ <>
+ }
+ title={t("Save your new backup codes")}
+ color="yellow"
+ >
+
+ {t(
+ "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
+ )}
+
+
+
+
+
+
+ {t("Your new backup codes")}
+
+
+ {({ copied, copy }) => (
+
+ ) : (
+
+ )
+ }
+ >
+ {copied ? t("Copied") : t("Copy")}
+
+ )}
+
+
+
+ {backupCodes.map((code, index) => (
+
+ {code}
+
+ ))}
+
+
+
+ }
+ >
+ {t("I've saved my backup codes")}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.module.css b/apps/client/src/ee/mfa/components/mfa-challenge.module.css
new file mode 100644
index 00000000..45eb5df9
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-challenge.module.css
@@ -0,0 +1,12 @@
+.container {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.paper {
+ width: 100%;
+ box-shadow: var(--mantine-shadow-lg);
+}
\ No newline at end of file
diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx
new file mode 100644
index 00000000..e067d730
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx
@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import {
+ Container,
+ Title,
+ Text,
+ PinInput,
+ Button,
+ Stack,
+ Anchor,
+ Paper,
+ Center,
+ ThemeIcon,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
+import { useNavigate } from "react-router-dom";
+import { notifications } from "@mantine/notifications";
+import classes from "./mfa-challenge.module.css";
+import { verifyMfa } from "@/ee/mfa";
+import APP_ROUTE from "@/lib/app-route";
+import { useTranslation } from "react-i18next";
+import * as z from "zod";
+import { MfaBackupCodeInput } from "./mfa-backup-code-input";
+
+const formSchema = z.object({
+ code: z
+ .string()
+ .refine(
+ (val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8,
+ {
+ message: "Enter a 6-digit code or 8-character backup code",
+ },
+ ),
+});
+
+type MfaChallengeFormValues = z.infer;
+
+export function MfaChallenge() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [isLoading, setIsLoading] = useState(false);
+ const [useBackupCode, setUseBackupCode] = useState(false);
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ code: "",
+ },
+ });
+
+ const handleSubmit = async (values: MfaChallengeFormValues) => {
+ setIsLoading(true);
+ try {
+ await verifyMfa(values.code);
+ navigate(APP_ROUTE.HOME);
+ } catch (error: any) {
+ setIsLoading(false);
+ notifications.show({
+ message:
+ error.response?.data?.message || t("Invalid verification code"),
+ color: "red",
+ });
+ form.setFieldValue("code", "");
+ }
+ };
+
+ return (
+
+
+
+
+ {t("2-step verification")}
+
+ {!isMfaEnabled
+ ? t(
+ "Protect your account with an additional verification layer when signing in.",
+ )
+ : t("Two-factor authentication is active on your account.")}
+
+
+
+ {!isMfaEnabled ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ setSetupModalOpen(false)}
+ onComplete={handleSetupComplete}
+ />
+
+ setDisableModalOpen(false)}
+ onComplete={handleDisableComplete}
+ />
+
+ setBackupCodesModalOpen(false)}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
new file mode 100644
index 00000000..124b622c
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
@@ -0,0 +1,347 @@
+import React, { useState } from "react";
+import {
+ Modal,
+ Stack,
+ Text,
+ Button,
+ Group,
+ Stepper,
+ Center,
+ Image,
+ PinInput,
+ Alert,
+ List,
+ CopyButton,
+ ActionIcon,
+ Tooltip,
+ Paper,
+ Code,
+ Loader,
+ Collapse,
+ UnstyledButton,
+} from "@mantine/core";
+import {
+ IconQrcode,
+ IconShieldCheck,
+ IconKey,
+ IconCopy,
+ IconCheck,
+ IconAlertCircle,
+ IconChevronDown,
+ IconChevronRight,
+ IconPrinter,
+} from "@tabler/icons-react";
+import { useForm } from "@mantine/form";
+import { useMutation } from "@tanstack/react-query";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import { setupMfa, enableMfa } from "@/ee/mfa";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+
+interface MfaSetupModalProps {
+ opened: boolean;
+ onClose?: () => void;
+ onComplete: () => void;
+ isRequired?: boolean;
+}
+
+interface SetupData {
+ secret: string;
+ qrCode: string;
+ manualKey: string;
+}
+
+const formSchema = z.object({
+ verificationCode: z
+ .string()
+ .length(6, { message: "Please enter a 6-digit code" }),
+});
+
+export function MfaSetupModal({
+ opened,
+ onClose,
+ onComplete,
+ isRequired = false,
+}: MfaSetupModalProps) {
+ const { t } = useTranslation();
+ const [active, setActive] = useState(0);
+ const [setupData, setSetupData] = useState(null);
+ const [backupCodes, setBackupCodes] = useState([]);
+ const [manualEntryOpen, setManualEntryOpen] = useState(false);
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ verificationCode: "",
+ },
+ });
+
+ const setupMutation = useMutation({
+ mutationFn: () => setupMfa({ method: "totp" }),
+ onSuccess: (data) => {
+ setSetupData(data);
+ },
+ onError: (error: any) => {
+ notifications.show({
+ title: t("Error"),
+ message: error.response?.data?.message || t("Failed to setup MFA"),
+ color: "red",
+ });
+ },
+ });
+
+ // Generate QR code when modal opens
+ React.useEffect(() => {
+ if (opened && !setupData && !setupMutation.isPending) {
+ setupMutation.mutate();
+ }
+ }, [opened]);
+
+ const enableMutation = useMutation({
+ mutationFn: (verificationCode: string) =>
+ enableMfa({
+ secret: setupData!.secret,
+ verificationCode,
+ }),
+ onSuccess: (data) => {
+ setBackupCodes(data.backupCodes);
+ setActive(1); // Move to backup codes step
+ },
+ onError: (error: any) => {
+ notifications.show({
+ title: t("Error"),
+ message:
+ error.response?.data?.message || t("Invalid verification code"),
+ color: "red",
+ });
+ form.setFieldValue("verificationCode", "");
+ },
+ });
+
+ const handleClose = () => {
+ if (active === 1 && backupCodes.length > 0) {
+ onComplete();
+ }
+ onClose();
+ // Reset state
+ setTimeout(() => {
+ setActive(0);
+ setSetupData(null);
+ setBackupCodes([]);
+ setManualEntryOpen(false);
+ form.reset();
+ }, 200);
+ };
+
+ const handleVerify = async (values: { verificationCode: string }) => {
+ await enableMutation.mutateAsync(values.verificationCode);
+ };
+
+ const handlePrintBackupCodes = () => {
+ window.print();
+ };
+
+ return (
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+ }
+ title={t("Save your backup codes")}
+ color="yellow"
+ >
+
+ {t(
+ "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
+ )}
+
+
+
+
+
+
+ {t("Backup codes")}
+
+
+
+ {({ copied, copy }) => (
+
+ ) : (
+
+ )
+ }
+ >
+ {copied ? t("Copied") : t("Copy")}
+
+ )}
+
+ }
+ >
+ {t("Print")}
+
+
+
+
+ {backupCodes.map((code, index) => (
+
+ {code}
+
+ ))}
+
+
+
+ }
+ >
+ {t("I've saved my backup codes")}
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx
new file mode 100644
index 00000000..c657abe9
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
+import { IconAlertCircle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { MfaSetupModal } from "@/ee/mfa";
+import APP_ROUTE from "@/lib/app-route.ts";
+import { useNavigate } from "react-router-dom";
+
+export default function MfaSetupRequired() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const handleSetupComplete = () => {
+ navigate(APP_ROUTE.HOME);
+ };
+
+ return (
+
+
+
+
+ {t("Two-factor authentication required")}
+
+
+ } color="yellow">
+
+ {t(
+ "Your workspace requires two-factor authentication. Please set it up to continue.",
+ )}
+
+
+
+
+ {t(
+ "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/mfa/components/mfa.module.css b/apps/client/src/ee/mfa/components/mfa.module.css
new file mode 100644
index 00000000..535704a5
--- /dev/null
+++ b/apps/client/src/ee/mfa/components/mfa.module.css
@@ -0,0 +1,31 @@
+.qrCodeContainer {
+ background-color: white;
+ padding: 1rem;
+ border-radius: var(--mantine-radius-md);
+ display: inline-block;
+}
+
+.backupCodesList {
+ font-family: var(--mantine-font-family-monospace);
+ background-color: var(--mantine-color-gray-0);
+ padding: 1rem;
+ border-radius: var(--mantine-radius-md);
+
+ @mixin dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+}
+
+.codeItem {
+ padding: 0.25rem 0;
+ font-size: 0.875rem;
+}
+
+.setupStep {
+ min-height: 400px;
+}
+
+.verificationInput {
+ max-width: 320px;
+ margin: 0 auto;
+}
\ No newline at end of file
diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
new file mode 100644
index 00000000..9200cac7
--- /dev/null
+++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useLocation } from "react-router-dom";
+import APP_ROUTE from "@/lib/app-route";
+import { validateMfaAccess } from "@/ee/mfa";
+
+export function useMfaPageProtection() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [isValidating, setIsValidating] = useState(true);
+ const [isValid, setIsValid] = useState(false);
+
+ useEffect(() => {
+ const checkAccess = async () => {
+ const result = await validateMfaAccess();
+
+ if (!result.valid) {
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ return;
+ }
+
+ // Check if user is on the correct page based on their MFA state
+ const isOnChallengePage =
+ location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE;
+ const isOnSetupPage =
+ location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED;
+
+ if (result.requiresMfaSetup && !isOnSetupPage) {
+ // User needs to set up MFA but is on challenge page
+ navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
+ } else if (
+ !result.requiresMfaSetup &&
+ result.userHasMfa &&
+ !isOnChallengePage
+ ) {
+ // User has MFA and should be on challenge page
+ navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
+ } else if (!result.isTransferToken) {
+ // User has a regular auth token, shouldn't be on MFA pages
+ navigate(APP_ROUTE.HOME);
+ } else {
+ setIsValid(true);
+ }
+
+ setIsValidating(false);
+ };
+
+ checkAccess();
+ }, [navigate, location.pathname]);
+
+ return { isValidating, isValid };
+}
diff --git a/apps/client/src/ee/mfa/index.ts b/apps/client/src/ee/mfa/index.ts
new file mode 100644
index 00000000..047b0a8d
--- /dev/null
+++ b/apps/client/src/ee/mfa/index.ts
@@ -0,0 +1,19 @@
+// Components
+export { MfaChallenge } from "./components/mfa-challenge";
+export { MfaSettings } from "./components/mfa-settings";
+export { MfaSetupModal } from "./components/mfa-setup-modal";
+export { MfaDisableModal } from "./components/mfa-disable-modal";
+export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal";
+
+// Pages
+export { MfaChallengePage } from "./pages/mfa-challenge-page";
+export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page";
+
+// Services
+export * from "./services/mfa-service";
+
+// Types
+export * from "./types/mfa.types";
+
+// Hooks
+export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts";
diff --git a/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
new file mode 100644
index 00000000..40949fc7
--- /dev/null
+++ b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import { MfaChallenge } from "@/ee/mfa";
+import { useMfaPageProtection } from "@/ee/mfa";
+
+export function MfaChallengePage() {
+ const { isValid } = useMfaPageProtection();
+
+ if (!isValid) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
new file mode 100644
index 00000000..0b5f756d
--- /dev/null
+++ b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
@@ -0,0 +1,113 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ Container,
+ Title,
+ Text,
+ Button,
+ Stack,
+ Paper,
+ Alert,
+ Center,
+ ThemeIcon,
+} from "@mantine/core";
+import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import APP_ROUTE from "@/lib/app-route";
+import { MfaSetupModal } from "@/ee/mfa";
+import classes from "@/features/auth/components/auth.module.css";
+import { notifications } from "@mantine/notifications";
+import { useMfaPageProtection } from "@/ee/mfa";
+
+export function MfaSetupRequiredPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [setupModalOpen, setSetupModalOpen] = useState(false);
+ const { isValid } = useMfaPageProtection();
+
+ const handleSetupComplete = async () => {
+ setSetupModalOpen(false);
+
+ notifications.show({
+ title: t("Success"),
+ message: t(
+ "Two-factor authentication has been set up. Please log in again.",
+ ),
+ });
+
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ };
+
+ const handleLogout = () => {
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ };
+
+ if (!isValid) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {t("Enforce two-factor authentication")}
+
+ {t(
+ "Once enforced, all members must enable two-factor authentication to access the workspace.",
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+interface EnforceMfaToggleProps {
+ size?: MantineSize;
+ label?: string;
+}
+export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
+ const { t } = useTranslation();
+ const [workspace, setWorkspace] = useAtom(workspaceAtom);
+ const [checked, setChecked] = useState(workspace?.enforceMfa);
+
+ const handleChange = async (event: React.ChangeEvent) => {
+ const value = event.currentTarget.checked;
+ try {
+ const updatedWorkspace = await updateWorkspace({ enforceMfa: value });
+ setChecked(value);
+ setWorkspace(updatedWorkspace);
+ } catch (err) {
+ notifications.show({
+ message: err?.response?.data?.message,
+ color: "red",
+ });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx
index de8efc06..82d8640f 100644
--- a/apps/client/src/ee/security/pages/security.tsx
+++ b/apps/client/src/ee/security/pages/security.tsx
@@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
+import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
export default function Security() {
const { t } = useTranslation();
@@ -33,6 +34,10 @@ export default function Security() {
+
+
+
+
Single sign-on (SSO)
diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx
index 2d7b3657..37397ef8 100644
--- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx
+++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import * as z from "zod";
-import { useForm, zodResolver } from "@mantine/form";
+import { useForm } from "@mantine/form";
import {
Container,
Title,
@@ -11,6 +11,7 @@ import {
Box,
Stack,
} from "@mantine/core";
+import { zodResolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts
index 2867f238..decb393f 100644
--- a/apps/client/src/features/auth/hooks/use-auth.ts
+++ b/apps/client/src/features/auth/hooks/use-auth.ts
@@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
-import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
+import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@@ -39,9 +39,17 @@ export default function useAuth() {
setIsLoading(true);
try {
- await login(data);
+ const response = await login(data);
setIsLoading(false);
- navigate(APP_ROUTE.HOME);
+
+ // Check if MFA is required
+ if (response?.userHasMfa) {
+ navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
+ } else if (response?.requiresMfaSetup) {
+ navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
+ } else {
+ navigate(APP_ROUTE.HOME);
+ }
} catch (err) {
setIsLoading(false);
console.log(err);
@@ -56,9 +64,19 @@ export default function useAuth() {
setIsLoading(true);
try {
- await acceptInvitation(data);
+ const response = await acceptInvitation(data);
setIsLoading(false);
- navigate(APP_ROUTE.HOME);
+
+ if (response?.requiresLogin) {
+ notifications.show({
+ message: t(
+ "Account created successfully. Please log in to set up two-factor authentication.",
+ ),
+ });
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ } else {
+ navigate(APP_ROUTE.HOME);
+ }
} catch (err) {
setIsLoading(false);
notifications.show({
@@ -100,12 +118,22 @@ export default function useAuth() {
setIsLoading(true);
try {
- await passwordReset(data);
+ const response = await passwordReset(data);
setIsLoading(false);
- navigate(APP_ROUTE.HOME);
- notifications.show({
- message: t("Password reset was successful"),
- });
+
+ if (response?.requiresLogin) {
+ notifications.show({
+ message: t(
+ "Password reset was successful. Please log in with your new password.",
+ ),
+ });
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ } else {
+ navigate(APP_ROUTE.HOME);
+ notifications.show({
+ message: t("Password reset was successful"),
+ });
+ }
} catch (err) {
setIsLoading(false);
notifications.show({
diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts
index 2008ecfc..20e437f3 100644
--- a/apps/client/src/features/auth/services/auth-service.ts
+++ b/apps/client/src/features/auth/services/auth-service.ts
@@ -4,14 +4,16 @@ import {
ICollabToken,
IForgotPassword,
ILogin,
+ ILoginResponse,
IPasswordReset,
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
-export async function login(data: ILogin): Promise {
- await api.post("/auth/login", data);
+export async function login(data: ILogin): Promise {
+ const response = await api.post("/auth/login", data);
+ return response.data;
}
export async function logout(): Promise {
@@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise {
await api.post("/auth/forgot-password", data);
}
-export async function passwordReset(data: IPasswordReset): Promise {
- await api.post("/auth/password-reset", data);
+export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
+ const req = await api.post("/auth/password-reset", data);
+ return req.data;
}
export async function verifyUserToken(data: IVerifyUserToken): Promise {
@@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise {
export async function getCollabToken(): Promise {
const req = await api.post("/auth/collab-token");
return req.data;
-}
+}
\ No newline at end of file
diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts
index 6a925a0a..71abc6b7 100644
--- a/apps/client/src/features/auth/types/auth.types.ts
+++ b/apps/client/src/features/auth/types/auth.types.ts
@@ -38,3 +38,10 @@ export interface IVerifyUserToken {
export interface ICollabToken {
token?: string;
}
+
+export interface ILoginResponse {
+ userHasMfa?: boolean;
+ requiresMfaSetup?: boolean;
+ mfaToken?: string;
+ isMfaEnforced?: boolean;
+}
diff --git a/apps/client/src/features/user/components/account-mfa-section.tsx b/apps/client/src/features/user/components/account-mfa-section.tsx
new file mode 100644
index 00000000..a2709afd
--- /dev/null
+++ b/apps/client/src/features/user/components/account-mfa-section.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+import { isCloud } from "@/lib/config";
+import { useLicense } from "@/ee/hooks/use-license";
+import { MfaSettings } from "@/ee/mfa";
+
+export function AccountMfaSection() {
+ const { hasLicenseKey } = useLicense();
+ const showMfa = isCloud() || hasLicenseKey;
+
+ if (!showMfa) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/apps/client/src/features/user/components/change-email.tsx b/apps/client/src/features/user/components/change-email.tsx
index 873d0744..5e00cbba 100644
--- a/apps/client/src/features/user/components/change-email.tsx
+++ b/apps/client/src/features/user/components/change-email.tsx
@@ -22,7 +22,7 @@ export default function ChangeEmail() {
return (
-