feat(EE): MFA implementation (#1381)

* feat(EE): MFA implementation for enterprise edition
- Add TOTP-based two-factor authentication
- Add backup codes support
- Add MFA enforcement at workspace level
- Add MFA setup and challenge UI pages
- Support MFA for login and password reset flows
- Add MFA validation for secure pages
* fix types
* remove unused object
* sync
* remove unused type
* sync
* refactor: rename MFA enabled field to is_enabled
* sync
This commit is contained in:
Philip Okugbe
2025-07-25 00:18:53 +01:00
committed by GitHub
parent 8522844673
commit 662460252f
49 changed files with 2026 additions and 54 deletions

View File

@ -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() {
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route
path={"/login/mfa/setup"}
element={<MfaSetupRequiredPage />}
/>
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@ -58,7 +65,10 @@ export default function App() {
)}
<Route element={<ShareLayout />}>
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
<Route
path={"/share/:shareId/p/:pageSlug"}
element={<SharedPage />}
/>
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>

View File

@ -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 (
<Stack>
<Alert icon={<IconAlertCircle size={16} />} color="blue" variant="light">
<Text size="sm">
{t(
"Enter one of your backup codes. Each backup code can only be used once.",
)}
</Text>
</Alert>
<TextInput
label={t("Backup code")}
placeholder="XXXXXXXX"
value={value}
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error}
autoFocus
maxLength={8}
styles={{
input: {
fontFamily: "monospace",
letterSpacing: "0.1em",
fontSize: "1rem",
},
}}
/>
<Stack>
<Button
fullWidth
size="md"
loading={isLoading}
onClick={onSubmit}
leftSection={<IconKey size={18} />}
>
{t("Verify backup code")}
</Button>
<Button
fullWidth
variant="subtle"
color="gray"
onClick={onCancel}
disabled={isLoading}
>
{t("Use authenticator app instead")}
</Button>
</Stack>
</Stack>
);
}

View File

@ -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<string[]>([]);
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 (
<Modal
opened={opened}
onClose={handleClose}
title={t("Backup codes")}
size="md"
>
<Stack gap="md">
{!showNewCodes ? (
<form onSubmit={form.onSubmit(handleRegenerate)}>
<Stack gap="md">
<Alert
icon={<IconAlertCircle size={20} />}
title={t("About backup codes")}
color="blue"
variant="light"
>
<Text size="sm">
{t(
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.",
)}
</Text>
</Alert>
<Text size="sm">
{t(
"You can regenerate new backup codes at any time. This will invalidate all existing codes.",
)}
</Text>
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
/>
<Button
type="submit"
fullWidth
loading={regenerateMutation.isPending}
leftSection={<IconRefresh size={18} />}
>
{t("Generate new backup codes")}
</Button>
</Stack>
</form>
) : (
<>
<Alert
icon={<IconAlertCircle size={20} />}
title={t("Save your new backup codes")}
color="yellow"
>
<Text size="sm">
{t(
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
)}
</Text>
</Alert>
<Paper p="md" withBorder>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t("Your new backup codes")}
</Text>
<CopyButton value={backupCodes.join("\n")}>
{({ copied, copy }) => (
<Button
size="xs"
variant="subtle"
onClick={copy}
leftSection={
copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)
}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
</Group>
<List size="sm" spacing="xs">
{backupCodes.map((code, index) => (
<List.Item key={index}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Button
fullWidth
onClick={handleClose}
leftSection={<IconCheck size={18} />}
>
{t("I've saved my backup codes")}
</Button>
</>
)}
</Stack>
</Modal>
);
}

View File

@ -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);
}

View File

@ -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<typeof formSchema>;
export function MfaChallenge() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [useBackupCode, setUseBackupCode] = useState(false);
const form = useForm<MfaChallengeFormValues>({
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 (
<Container size={420} className={classes.container}>
<Paper radius="lg" p={40} className={classes.paper}>
<Stack align="center" gap="xl">
<Center>
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
<IconDeviceMobile size={40} stroke={1.5} />
</ThemeIcon>
</Center>
<Stack align="center" gap="xs">
<Title order={2} ta="center" fw={600}>
{t("Two-factor authentication")}
</Title>
<Text size="sm" c="dimmed" ta="center">
{useBackupCode
? t("Enter one of your backup codes")
: t("Enter the 6-digit code found in your authenticator app")}
</Text>
</Stack>
{!useBackupCode ? (
<form
onSubmit={form.onSubmit(handleSubmit)}
style={{ width: "100%" }}
>
<Stack gap="lg">
<Center>
<PinInput
length={6}
type="number"
autoFocus
oneTimeCode
{...form.getInputProps("code")}
error={!!form.errors.code}
styles={{
input: {
fontSize: "1.2rem",
textAlign: "center",
},
}}
/>
</Center>
{form.errors.code && (
<Text c="red" size="sm" ta="center">
{form.errors.code}
</Text>
)}
<Button
type="submit"
fullWidth
size="md"
loading={isLoading}
leftSection={<IconLock size={18} />}
>
{t("Verify")}
</Button>
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => {
setUseBackupCode(true);
form.setFieldValue("code", "");
form.clearErrors();
}}
>
{t("Use backup code")}
</Anchor>
</Stack>
</form>
) : (
<MfaBackupCodeInput
value={form.values.code}
onChange={(value) => form.setFieldValue("code", value)}
error={form.errors.code?.toString()}
onSubmit={() => handleSubmit(form.values)}
onCancel={() => {
setUseBackupCode(false);
form.setFieldValue("code", "");
form.clearErrors();
}}
isLoading={isLoading}
/>
)}
</Stack>
</Paper>
</Container>
);
}

View File

@ -0,0 +1,124 @@
import React from "react";
import {
Modal,
Stack,
Text,
Button,
PasswordInput,
Alert,
} from "@mantine/core";
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { disableMfa } from "@/ee/mfa";
interface MfaDisableModalProps {
opened: boolean;
onClose: () => void;
onComplete: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({
opened,
onClose,
onComplete,
}: MfaDisableModalProps) {
const { t } = useTranslation();
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
confirmPassword: "",
},
});
const disableMutation = useMutation({
mutationFn: disableMfa,
onSuccess: () => {
onComplete();
},
onError: (error: any) => {
notifications.show({
title: t("Error"),
message: error.response?.data?.message || t("Failed to disable MFA"),
color: "red",
});
},
});
const handleSubmit = async (values: { confirmPassword: string }) => {
await disableMutation.mutateAsync(values);
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Disable two-factor authentication")}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={20} />}
title={t("Warning")}
color="red"
variant="light"
>
<Text size="sm">
{t(
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.",
)}
</Text>
</Alert>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
/>
<Stack gap="sm">
<Button
type="submit"
fullWidth
color="red"
loading={disableMutation.isPending}
leftSection={<IconShieldOff size={18} />}
>
{t("Disable two-factor authentication")}
</Button>
<Button
fullWidth
variant="default"
onClick={handleClose}
disabled={disableMutation.isPending}
>
{t("Cancel")}
</Button>
</Stack>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,112 @@
import React, { useState } from "react";
import { Group, Text, Button } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
export function MfaSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
queryFn: getMfaStatus,
});
if (isLoading) {
return null;
}
// Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true;
const handleSetupComplete = () => {
setSetupModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
notifications.show({
title: t("Success"),
message: t("Two-factor authentication has been enabled"),
});
};
const handleDisableComplete = () => {
setDisableModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["mfa-status"] });
notifications.show({
title: t("Success"),
message: t("Two-factor authentication has been disabled"),
color: "blue",
});
};
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div style={{ minWidth: 0, flex: 1 }}>
<Text size="md">{t("2-step verification")}</Text>
<Text size="sm" c="dimmed">
{!isMfaEnabled
? t(
"Protect your account with an additional verification layer when signing in.",
)
: t("Two-factor authentication is active on your account.")}
</Text>
</div>
{!isMfaEnabled ? (
<Button
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</Group>
<MfaSetupModal
opened={setupModalOpen}
onClose={() => setSetupModalOpen(false)}
onComplete={handleSetupComplete}
/>
<MfaDisableModal
opened={disableModalOpen}
onClose={() => setDisableModalOpen(false)}
onComplete={handleDisableComplete}
/>
<MfaBackupCodesModal
opened={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
/>
</>
);
}

View File

@ -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<SetupData | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
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 (
<Modal
opened={opened}
onClose={handleClose}
title={t("Set up two-factor authentication")}
size="md"
>
<Stepper active={active} size="sm">
<Stepper.Step
label={t("Setup & Verify")}
description={t("Add to authenticator")}
icon={<IconQrcode size={18} />}
>
<form onSubmit={form.onSubmit(handleVerify)}>
<Stack gap="md" mt="xl">
{setupMutation.isPending ? (
<Center py="xl">
<Loader size="lg" />
</Center>
) : setupData ? (
<>
<Text size="sm">
{t("1. Scan this QR code with your authenticator app")}
</Text>
<Center>
<Paper p="md" withBorder>
<Image
src={setupData.qrCode}
alt="MFA QR Code"
width={200}
height={200}
/>
</Paper>
</Center>
<UnstyledButton
onClick={() => setManualEntryOpen(!manualEntryOpen)}
>
<Group gap="xs">
{manualEntryOpen ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
<Text size="sm" c="dimmed">
{t("Can't scan the code?")}
</Text>
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
variant="light"
>
<Text size="sm" mb="sm">
{t(
"Enter this code manually in your authenticator app:",
)}
</Text>
<Group gap="xs">
<Code block>{setupData.manualKey}</Code>
<CopyButton value={setupData.manualKey}>
{({ copied, copy }) => (
<Tooltip label={copied ? t("Copied") : t("Copy")}>
<ActionIcon
color={copied ? "green" : "gray"}
onClick={copy}
>
{copied ? (
<IconCheck size={16} />
) : (
<IconCopy size={16} />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Alert>
</Collapse>
<Text size="sm" mt="md">
{t("2. Enter the 6-digit code from your authenticator")}
</Text>
<Stack align="center">
<PinInput
length={6}
type="number"
autoFocus
oneTimeCode
{...form.getInputProps("verificationCode")}
styles={{
input: {
fontSize: "1.2rem",
textAlign: "center",
},
}}
/>
{form.errors.verificationCode && (
<Text c="red" size="sm">
{form.errors.verificationCode}
</Text>
)}
</Stack>
<Button
type="submit"
fullWidth
loading={enableMutation.isPending}
leftSection={<IconShieldCheck size={18} />}
>
{t("Verify and enable")}
</Button>
</>
) : (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Failed to generate QR code. Please try again.")}
</Text>
</Center>
)}
</Stack>
</form>
</Stepper.Step>
<Stepper.Step
label={t("Backup")}
description={t("Save codes")}
icon={<IconKey size={18} />}
>
<Stack gap="md" mt="xl">
<Alert
icon={<IconAlertCircle size={20} />}
title={t("Save your backup codes")}
color="yellow"
>
<Text size="sm">
{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.",
)}
</Text>
</Alert>
<Paper p="md" withBorder>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t("Backup codes")}
</Text>
<Group gap="xs" wrap="nowrap">
<CopyButton value={backupCodes.join("\n")}>
{({ copied, copy }) => (
<Button
size="xs"
variant="subtle"
onClick={copy}
leftSection={
copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)
}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
<Button
size="xs"
variant="subtle"
onClick={handlePrintBackupCodes}
leftSection={<IconPrinter size={14} />}
>
{t("Print")}
</Button>
</Group>
</Group>
<List size="sm" spacing="xs">
{backupCodes.map((code, index) => (
<List.Item key={index}>
<Code>{code}</Code>
</List.Item>
))}
</List>
</Paper>
<Button
fullWidth
onClick={handleClose}
leftSection={<IconCheck size={18} />}
>
{t("I've saved my backup codes")}
</Button>
</Stack>
</Stepper.Step>
</Stepper>
</Modal>
);
}

View File

@ -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 (
<Container size="sm" py="xl">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Stack>
<Title order={2} ta="center">
{t("Two-factor authentication required")}
</Title>
<Alert icon={<IconAlertCircle size="1rem" />} color="yellow">
<Text size="sm">
{t(
"Your workspace requires two-factor authentication. Please set it up to continue.",
)}
</Text>
</Alert>
<Text c="dimmed" size="sm" ta="center">
{t(
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.",
)}
</Text>
<MfaSetupModal
opened={true}
onComplete={handleSetupComplete}
isRequired={true}
/>
</Stack>
</Paper>
</Container>
);
}

View File

@ -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;
}

View File

@ -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 };
}

View File

@ -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";

View File

@ -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 <MfaChallenge />;
}

View File

@ -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 (
<Container size={480} className={classes.container}>
<Paper radius="lg" p={40}>
<Stack align="center" gap="xl">
<Center>
<ThemeIcon size={80} radius="xl" variant="light" color="blue">
<IconShieldCheck size={40} stroke={1.5} />
</ThemeIcon>
</Center>
<Stack align="center" gap="xs">
<Title order={2} ta="center" fw={600}>
{t("Two-factor authentication required")}
</Title>
<Text size="md" c="dimmed" ta="center">
{t(
"Your workspace requires two-factor authentication for all users",
)}
</Text>
</Stack>
<Alert
icon={<IconAlertCircle size={20} />}
color="blue"
variant="light"
w="100%"
>
<Text size="sm">
{t(
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.",
)}
</Text>
</Alert>
<Stack w="100%" gap="sm">
<Button
fullWidth
size="md"
onClick={() => setSetupModalOpen(true)}
leftSection={<IconShieldCheck size={18} />}
>
{t("Set up two-factor authentication")}
</Button>
<Button
fullWidth
variant="subtle"
color="gray"
onClick={handleLogout}
>
{t("Cancel and logout")}
</Button>
</Stack>
</Stack>
</Paper>
<MfaSetupModal
opened={setupModalOpen}
onClose={() => setSetupModalOpen(false)}
onComplete={handleSetupComplete}
isRequired={true}
/>
</Container>
);
}

View File

@ -0,0 +1,61 @@
import api from "@/lib/api-client";
import {
MfaBackupCodesResponse,
MfaDisableRequest,
MfaEnableRequest,
MfaEnableResponse,
MfaSetupRequest,
MfaSetupResponse,
MfaStatusResponse,
MfaAccessValidationResponse,
} from "@/ee/mfa";
export async function getMfaStatus(): Promise<MfaStatusResponse> {
const req = await api.post("/mfa/status");
return req.data;
}
export async function setupMfa(
data: MfaSetupRequest,
): Promise<MfaSetupResponse> {
const req = await api.post<MfaSetupResponse>("/mfa/setup", data);
return req.data;
}
export async function enableMfa(
data: MfaEnableRequest,
): Promise<MfaEnableResponse> {
const req = await api.post<MfaEnableResponse>("/mfa/enable", data);
return req.data;
}
export async function disableMfa(
data: MfaDisableRequest,
): Promise<{ success: boolean }> {
const req = await api.post<{ success: boolean }>("/mfa/disable", data);
return req.data;
}
export async function regenerateBackupCodes(data: {
confirmPassword: string;
}): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes",
data,
);
return req.data;
}
export async function verifyMfa(code: string): Promise<any> {
const req = await api.post("/mfa/verify", { code });
return req.data;
}
export async function validateMfaAccess(): Promise<MfaAccessValidationResponse> {
try {
const res = await api.post("/mfa/validate-access");
return res.data;
} catch {
return { valid: false };
}
}

View File

@ -0,0 +1,62 @@
export interface MfaMethod {
type: 'totp' | 'email';
isEnabled: boolean;
}
export interface MfaSettings {
isEnabled: boolean;
methods: MfaMethod[];
backupCodesCount: number;
lastUpdated?: string;
}
export interface MfaSetupState {
method: 'totp' | 'email';
secret?: string;
qrCode?: string;
manualEntry?: string;
backupCodes?: string[];
}
export interface MfaStatusResponse {
isEnabled?: boolean;
method?: string | null;
backupCodesCount?: number;
}
export interface MfaSetupRequest {
method: 'totp';
}
export interface MfaSetupResponse {
method: string;
qrCode: string;
secret: string;
manualKey: string;
}
export interface MfaEnableRequest {
secret: string;
verificationCode: string;
}
export interface MfaEnableResponse {
success: boolean;
backupCodes: string[];
}
export interface MfaDisableRequest {
confirmPassword: string;
}
export interface MfaBackupCodesResponse {
backupCodes: string[];
}
export interface MfaAccessValidationResponse {
valid: boolean;
isTransferToken?: boolean;
requiresMfaSetup?: boolean;
userHasMfa?: boolean;
isMfaEnforced?: boolean;
}

View File

@ -0,0 +1,66 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
export default function EnforceMfa() {
const { t } = useTranslation();
return (
<>
<Title order={4} my="sm">
MFA
</Title>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce two-factor authentication")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle />
</Group>
</>
);
}
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<HTMLInputElement>) => {
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 (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle MFA enforcement")}
/>
);
}

View File

@ -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() {
<Divider my="lg" />
<EnforceMfa />
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>

View File

@ -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";

View File

@ -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({

View File

@ -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<void> {
await api.post<void>("/auth/login", data);
export async function login(data: ILogin): Promise<ILoginResponse> {
const response = await api.post<ILoginResponse>("/auth/login", data);
return response.data;
}
export async function logout(): Promise<void> {
@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise<void> {
await api.post<void>("/auth/forgot-password", data);
}
export async function passwordReset(data: IPasswordReset): Promise<void> {
await api.post<void>("/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<any> {
@ -47,4 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data;
}
}

View File

@ -38,3 +38,10 @@ export interface IVerifyUserToken {
export interface ICollabToken {
token?: string;
}
export interface ILoginResponse {
userHasMfa?: boolean;
requiresMfaSetup?: boolean;
mfaToken?: string;
isMfaEnforced?: boolean;
}

View File

@ -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 <MfaSettings />;
}

View File

@ -22,7 +22,7 @@ export default function ChangeEmail() {
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<div style={{ minWidth: 0, flex: 1 }}>
<Text size="md">{t("Email")}</Text>
<Text size="sm" c="dimmed">
{currentUser?.user.email}
@ -30,7 +30,7 @@ export default function ChangeEmail() {
</div>
{/*
<Button onClick={open} variant="default">
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
{t("Change email")}
</Button>
*/}

View File

@ -14,14 +14,14 @@ export default function ChangePassword() {
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<div style={{ minWidth: 0, flex: 1 }}>
<Text size="md">{t("Password")}</Text>
<Text size="sm" c="dimmed">
{t("You can change your password here.")}
</Text>
</div>
<Button onClick={open} variant="default">
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
{t("Change password")}
</Button>

View File

@ -66,8 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
return req.data;
}
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
await api.post<void>("/workspace/invites/accept", data);
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
const req = await api.post("/workspace/invites/accept", data);
return req.data;
}
export async function getInviteLink(data: {

View File

@ -21,6 +21,7 @@ export interface IWorkspace {
memberCount?: number;
plan?: string;
hasLicenseKey?: boolean;
enforceMfa?: boolean;
}
export interface ICreateInvite {

View File

@ -8,6 +8,8 @@ const APP_ROUTE = {
PASSWORD_RESET: "/password-reset",
CREATE_WORKSPACE: "/create",
SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup",
},
SETTINGS: {
ACCOUNT: {

View File

@ -4,18 +4,21 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
export default function AccountSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("My Profile")} - {getAppName()}</title>
</Helmet>
<Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />
@ -29,6 +32,10 @@ export default function AccountSettings() {
<Divider my="lg" />
<ChangePassword />
<Divider my="lg" />
<AccountMfaSection />
</>
);
}