+ {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..b7a7d39e 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,22 @@ 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:
+ response.message ||
+ t(
+ "Account created successfully. Please log in to set up two-factor authentication.",
+ ),
+ color: "green",
+ });
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ } else {
+ navigate(APP_ROUTE.HOME);
+ }
} catch (err) {
setIsLoading(false);
notifications.show({
@@ -100,12 +121,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..1a396c8e 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; message?: string }> {
+ 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 (
-