mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 07:54:05 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user