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", + }, + }} + /> + + + + + + + + ); +} \ 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("About backup codes")} + color="blue" + variant="light" + > + + {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.", + )} + + + + + {t( + "You can regenerate new backup codes at any time. This will invalidate all existing codes.", + )} + + + + + + +
+ ) : ( + <> + } + 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 }) => ( + + )} + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + )} +
+
+ ); +} 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("Two-factor authentication")} + + + {useBackupCode + ? t("Enter one of your backup codes") + : t("Enter the 6-digit code found in your authenticator app")} + + + + {!useBackupCode ? ( +
+ +
+ +
+ {form.errors.code && ( + + {form.errors.code} + + )} + + + + { + setUseBackupCode(true); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + > + {t("Use backup code")} + +
+
+ ) : ( + form.setFieldValue("code", value)} + error={form.errors.code?.toString()} + onSubmit={() => handleSubmit(form.values)} + onCancel={() => { + setUseBackupCode(false); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + isLoading={isLoading} + /> + )} +
+
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx new file mode 100644 index 00000000..4b58f074 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx @@ -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 ( + +
+ + } + title={t("Warning")} + color="red" + variant="light" + > + + {t( + "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.", + )} + + + + + {t( + "Please enter your password to disable two-factor authentication:", + )} + + + + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-settings.tsx b/apps/client/src/ee/mfa/components/mfa-settings.tsx new file mode 100644 index 00000000..beab11a0 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-settings.tsx @@ -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 ( + <> + +
+ {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 ( + + + } + > +
+ + {setupMutation.isPending ? ( +
+ +
+ ) : setupData ? ( + <> + + {t("1. Scan this QR code with your authenticator app")} + + +
+ + MFA QR Code + +
+ + setManualEntryOpen(!manualEntryOpen)} + > + + {manualEntryOpen ? ( + + ) : ( + + )} + + {t("Can't scan the code?")} + + + + + + } + color="gray" + variant="light" + > + + {t( + "Enter this code manually in your authenticator app:", + )} + + + {setupData.manualKey} + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + + + + + {t("2. Enter the 6-digit code from your authenticator")} + + + + + {form.errors.verificationCode && ( + + {form.errors.verificationCode} + + )} + + + + + ) : ( +
+ + {t("Failed to generate QR code. Please try again.")} + +
+ )} +
+
+
+ + } + > + + } + 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 }) => ( + + )} + + + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + +
+
+ ); +} 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("Two-factor authentication required")} + + + {t( + "Your workspace requires two-factor authentication for all users", + )} + + + + } + color="blue" + variant="light" + w="100%" + > + + {t( + "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.", + )} + + + + + + + + +
+
+ + setSetupModalOpen(false)} + onComplete={handleSetupComplete} + isRequired={true} + /> +
+ ); +} diff --git a/apps/client/src/ee/mfa/services/mfa-service.ts b/apps/client/src/ee/mfa/services/mfa-service.ts new file mode 100644 index 00000000..bf49d2eb --- /dev/null +++ b/apps/client/src/ee/mfa/services/mfa-service.ts @@ -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 { + const req = await api.post("/mfa/status"); + return req.data; +} + +export async function setupMfa( + data: MfaSetupRequest, +): Promise { + const req = await api.post("/mfa/setup", data); + return req.data; +} + +export async function enableMfa( + data: MfaEnableRequest, +): Promise { + const req = await api.post("/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 { + const req = await api.post( + "/mfa/generate-backup-codes", + data, + ); + return req.data; +} + +export async function verifyMfa(code: string): Promise { + const req = await api.post("/mfa/verify", { code }); + return req.data; +} + +export async function validateMfaAccess(): Promise { + try { + const res = await api.post("/mfa/validate-access"); + return res.data; + } catch { + return { valid: false }; + } +} diff --git a/apps/client/src/ee/mfa/types/mfa.types.ts b/apps/client/src/ee/mfa/types/mfa.types.ts new file mode 100644 index 00000000..ac032195 --- /dev/null +++ b/apps/client/src/ee/mfa/types/mfa.types.ts @@ -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; +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx new file mode 100644 index 00000000..37cf5152 --- /dev/null +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -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 ( + <> + + MFA + + +
+ {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 ( -
+
{t("Email")} {currentUser?.user.email} @@ -30,7 +30,7 @@ export default function ChangeEmail() {
{/* - */} diff --git a/apps/client/src/features/user/components/change-password.tsx b/apps/client/src/features/user/components/change-password.tsx index 1dddfe1e..63eb25b4 100644 --- a/apps/client/src/features/user/components/change-password.tsx +++ b/apps/client/src/features/user/components/change-password.tsx @@ -14,14 +14,14 @@ export default function ChangePassword() { return ( -
+
{t("Password")} {t("You can change your password here.")}
- diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 293629fe..dd404806 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -66,8 +66,9 @@ export async function createInvitation(data: ICreateInvite) { return req.data; } -export async function acceptInvitation(data: IAcceptInvite): Promise { - await api.post("/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: { diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index c9df7f19..600641c9 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -21,6 +21,7 @@ export interface IWorkspace { memberCount?: number; plan?: string; hasLicenseKey?: boolean; + enforceMfa?: boolean; } export interface ICreateInvite { diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 56dac67a..7b95d5c4 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -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: { diff --git a/apps/client/src/pages/settings/account/account-settings.tsx b/apps/client/src/pages/settings/account/account-settings.tsx index c1fd6fdc..f1d78f7d 100644 --- a/apps/client/src/pages/settings/account/account-settings.tsx +++ b/apps/client/src/pages/settings/account/account-settings.tsx @@ -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 ( <> - - {t("My Profile")} - {getAppName()} - + + + {t("My Profile")} - {getAppName()} + + @@ -29,6 +32,10 @@ export default function AccountSettings() { + + + + ); } diff --git a/apps/server/package.json b/apps/server/package.json index 4106ba89..1240e5cc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -71,6 +71,7 @@ "nestjs-kysely": "^1.2.0", "nodemailer": "^7.0.3", "openid-client": "^5.7.1", + "otpauth": "^9.4.0", "p-limit": "^6.2.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 87448bc7..edd9a903 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; import { sanitize } from 'sanitize-filename-ts'; +import { FastifyRequest } from 'fastify'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); @@ -74,3 +75,10 @@ export function sanitizeFileName(fileName: string): string { const sanitizedFilename = sanitize(fileName).replace(/ /g, '_'); return sanitizedFilename.slice(0, 255); } + +export function extractBearerTokenFromHeader( + request: FastifyRequest, +): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; +} diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index dc1235ec..a11e0360 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -6,6 +6,7 @@ import { Post, Res, UseGuards, + Logger, } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; @@ -22,12 +23,16 @@ import { PasswordResetDto } from './dto/password-reset.dto'; import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; import { FastifyReply } from 'fastify'; import { validateSsoEnforcement } from './auth.util'; +import { ModuleRef } from '@nestjs/core'; @Controller('auth') export class AuthController { + private readonly logger = new Logger(AuthController.name); + constructor( private authService: AuthService, private environmentService: EnvironmentService, + private moduleRef: ModuleRef, ) {} @HttpCode(HttpStatus.OK) @@ -39,6 +44,45 @@ export class AuthController { ) { validateSsoEnforcement(workspace); + let MfaModule: any; + let isMfaModuleReady = false; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + MfaModule = require('./../../ee/mfa/services/mfa.service'); + isMfaModuleReady = true; + } catch (err) { + this.logger.debug( + 'MFA module requested but EE module not bundled in this build', + ); + isMfaModuleReady = false; + } + if (isMfaModuleReady) { + const mfaService = this.moduleRef.get(MfaModule.MfaService, { + strict: false, + }); + + const mfaResult = await mfaService.checkMfaRequirements( + loginInput, + workspace, + res, + ); + + if (mfaResult) { + // If user has MFA enabled OR workspace enforces MFA, require MFA verification + if (mfaResult.userHasMfa || mfaResult.requiresMfaSetup) { + return { + userHasMfa: mfaResult.userHasMfa, + requiresMfaSetup: mfaResult.requiresMfaSetup, + isMfaEnforced: mfaResult.isMfaEnforced, + }; + } else if (mfaResult.authToken) { + // User doesn't have MFA and workspace doesn't require it + this.setAuthCookie(res, mfaResult.authToken); + return; + } + } + } + const authToken = await this.authService.login(loginInput, workspace.id); this.setAuthCookie(res, authToken); } @@ -85,11 +129,22 @@ export class AuthController { @Body() passwordResetDto: PasswordResetDto, @AuthWorkspace() workspace: Workspace, ) { - const authToken = await this.authService.passwordReset( + const result = await this.authService.passwordReset( passwordResetDto, - workspace.id, + workspace, ); - this.setAuthCookie(res, authToken); + + if (result.requiresLogin) { + return { + requiresLogin: true, + }; + } + + // Set auth cookie if no MFA is required + this.setAuthCookie(res, result.authToken); + return { + requiresLogin: false, + }; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index b9ce13c4..06322712 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -3,6 +3,7 @@ export enum JwtType { COLLAB = 'collab', EXCHANGE = 'exchange', ATTACHMENT = 'attachment', + MFA_TOKEN = 'mfa_token', } export type JwtPayload = { sub: string; @@ -30,3 +31,8 @@ export type JwtAttachmentPayload = { type: 'attachment'; }; +export interface JwtMfaTokenPayload { + sub: string; + workspaceId: string; + type: 'mfa_token'; +} diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index c71bc3bc..72ffc521 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -47,7 +47,7 @@ export class AuthService { includePassword: true, }); - const errorMessage = 'email or password does not match'; + const errorMessage = 'Email or password does not match'; if (!user || user?.deletedAt) { throw new UnauthorizedException(errorMessage); } @@ -156,10 +156,13 @@ export class AuthService { }); } - async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) { + async passwordReset( + passwordResetDto: PasswordResetDto, + workspace: Workspace, + ) { const userToken = await this.userTokenRepo.findById( passwordResetDto.token, - workspaceId, + workspace.id, ); if ( @@ -170,7 +173,9 @@ export class AuthService { throw new BadRequestException('Invalid or expired token'); } - const user = await this.userRepo.findById(userToken.userId, workspaceId); + const user = await this.userRepo.findById(userToken.userId, workspace.id, { + includeUserMfa: true, + }); if (!user || user.deletedAt) { throw new NotFoundException('User not found'); } @@ -183,7 +188,7 @@ export class AuthService { password: newPasswordHash, }, user.id, - workspaceId, + workspace.id, trx, ); @@ -201,7 +206,18 @@ export class AuthService { template: emailTemplate, }); - return this.tokenService.generateAccessToken(user); + // Check if user has MFA enabled or workspace enforces MFA + const userHasMfa = user?.['mfa']?.isEnabled || false; + const workspaceEnforcesMfa = workspace.enforceMfa || false; + + if (userHasMfa || workspaceEnforcesMfa) { + return { + requiresLogin: true, + }; + } + + const authToken = await this.tokenService.generateAccessToken(user); + return { authToken }; } async verifyUserToken( diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index c0e64e25..f1c4c5c0 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -9,6 +9,7 @@ import { JwtAttachmentPayload, JwtCollabPayload, JwtExchangePayload, + JwtMfaTokenPayload, JwtPayload, JwtType, } from '../dto/jwt-payload'; @@ -76,6 +77,22 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn: '1h' }); } + async generateMfaToken( + user: User, + workspaceId: string, + ): Promise { + if (user.deactivatedAt || user.deletedAt) { + throw new ForbiddenException(); + } + + const payload: JwtMfaTokenPayload = { + sub: user.id, + workspaceId, + type: JwtType.MFA_TOKEN, + }; + return this.jwtService.sign(payload, { expiresIn: '5m' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index fae56b7c..c31a597b 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { JwtPayload, JwtType } from '../dto/jwt-payload'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { FastifyRequest } from 'fastify'; +import { extractBearerTokenFromHeader } from '../../../common/helpers'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -18,7 +19,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ) { super({ jwtFromRequest: (req: FastifyRequest) => { - return req.cookies?.authToken || this.extractTokenFromHeader(req); + return req.cookies?.authToken || extractBearerTokenFromHeader(req); }, ignoreExpiration: false, secretOrKey: environmentService.getAppSecret(), @@ -48,9 +49,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { return { user, workspace }; } - - private extractTokenFromHeader(request: FastifyRequest): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 47a78480..f9062878 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -29,7 +29,8 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact import { WorkspaceCaslAction, WorkspaceCaslSubject, -} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify'; +} from '../../casl/interfaces/workspace-ability.type'; +import { FastifyReply } from 'fastify'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { CheckHostnameDto } from '../dto/check-hostname.dto'; import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto'; @@ -257,17 +258,27 @@ export class WorkspaceController { @AuthWorkspace() workspace: Workspace, @Res({ passthrough: true }) res: FastifyReply, ) { - const authToken = await this.workspaceInvitationService.acceptInvitation( + const result = await this.workspaceInvitationService.acceptInvitation( acceptInviteDto, workspace, ); - res.setCookie('authToken', authToken, { + if (result.requiresLogin) { + return { + requiresLogin: true, + }; + } + + res.setCookie('authToken', result.authToken, { httpOnly: true, path: '/', expires: this.environmentService.getCookieExpiresIn(), secure: this.environmentService.isHttps(), }); + + return { + requiresLogin: false, + }; } @Public() diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 412a3a8c..a0182a77 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -14,4 +14,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsBoolean() enforceSso: boolean; + + @IsOptional() + @IsBoolean() + enforceMfa: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 5ecc8427..90485f0a 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -177,7 +177,14 @@ export class WorkspaceInvitationService { } } - async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) { + async acceptInvitation( + dto: AcceptInviteDto, + workspace: Workspace, + ): Promise<{ + authToken?: string; + requiresLogin?: boolean; + message?: string; + }> { const invitation = await this.db .selectFrom('workspaceInvitations') .selectAll() @@ -289,7 +296,14 @@ export class WorkspaceInvitationService { }); } - return this.tokenService.generateAccessToken(newUser); + if (workspace.enforceMfa) { + return { + requiresLogin: true, + }; + } + + const authToken = await this.tokenService.generateAccessToken(newUser); + return { authToken }; } async resendInvitation( diff --git a/apps/server/src/database/migrations/20250715T070817-mfa.ts b/apps/server/src/database/migrations/20250715T070817-mfa.ts new file mode 100644 index 00000000..8aa6a92c --- /dev/null +++ b/apps/server/src/database/migrations/20250715T070817-mfa.ts @@ -0,0 +1,39 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('user_mfa') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('method', 'varchar', (col) => col.notNull().defaultTo('totp')) + .addColumn('secret', 'text', (col) => col) + .addColumn('is_enabled', 'boolean', (col) => col.defaultTo(false)) + .addColumn('backup_codes', sql`text[]`, (col) => col) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('user_mfa_user_id_unique', ['user_id']) + .execute(); + + // Add MFA policy columns to workspaces + await db.schema + .alterTable('workspaces') + .addColumn('enforce_mfa', 'boolean', (col) => col.defaultTo(false)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('workspaces').dropColumn('enforce_mfa').execute(); + + await db.schema.dropTable('user_mfa').execute(); +} diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index f87f4daa..190670e3 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; -import { Users } from '@docmost/db/types/db'; +import { DB, Users } from '@docmost/db/types/db'; import { hashPassword } from '../../../common/helpers'; import { dbOrTx } from '@docmost/db/utils'; import { @@ -11,7 +11,8 @@ import { } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; -import { sql } from 'kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; @Injectable() export class UserRepo { @@ -40,6 +41,7 @@ export class UserRepo { workspaceId: string, opts?: { includePassword?: boolean; + includeUserMfa?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -48,6 +50,7 @@ export class UserRepo { .selectFrom('users') .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) + .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .where('id', '=', userId) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -58,6 +61,7 @@ export class UserRepo { workspaceId: string, opts?: { includePassword?: boolean; + includeUserMfa?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -66,6 +70,7 @@ export class UserRepo { .selectFrom('users') .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) + .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .where(sql`LOWER(email)`, '=', sql`LOWER(${email})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -177,4 +182,18 @@ export class UserRepo { .returning(this.baseFields) .executeTakeFirst(); } + + withUserMfa(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('userMfa') + .select([ + 'userMfa.id', + 'userMfa.method', + 'userMfa.isEnabled', + 'userMfa.createdAt', + ]) + .whereRef('userMfa.userId', '=', 'users.id'), + ).as('mfa'); + } } diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 8b9765f4..6a15fcd9 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -32,6 +32,7 @@ export class WorkspaceRepo { 'trialEndAt', 'enforceSso', 'plan', + 'enforceMfa', ]; constructor(@InjectKysely() private readonly db: KyselyDB) {} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index b49f15b0..1d2051d4 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -247,6 +247,18 @@ export interface Spaces { workspaceId: string; } +export interface UserMfa { + backupCodes: string[] | null; + createdAt: Generated; + id: Generated; + isEnabled: Generated; + method: Generated; + secret: string | null; + updatedAt: Generated; + userId: string; + workspaceId: string; +} + export interface Users { avatarUrl: string | null; createdAt: Generated; @@ -300,6 +312,7 @@ export interface Workspaces { deletedAt: Timestamp | null; description: string | null; emailDomains: Generated; + enforceMfa: Generated; enforceSso: Generated; hostname: string | null; id: Generated; @@ -329,6 +342,7 @@ export interface DB { shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; + userMfa: UserMfa; users: Users; userTokens: UserTokens; workspaceInvitations: WorkspaceInvitations; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index db2c2823..b23fa775 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -18,6 +18,7 @@ import { AuthAccounts, Shares, FileTasks, + UserMfa as _UserMFA, } from './db'; // Workspace @@ -113,3 +114,8 @@ export type UpdatableShare = Updateable>; export type FileTask = Selectable; export type InsertableFileTask = Insertable; export type UpdatableFileTask = Updateable>; + +// UserMFA +export type UserMFA = Selectable<_UserMFA>; +export type InsertableUserMFA = Insertable<_UserMFA>; +export type UpdatableUserMFA = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 49a16ab3..0a2b9a0d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 49a16ab3e03971a375bcbfac60c3c1150d19059b +Subproject commit 0a2b9a0dbc54036009d74d39681f33902b307968 diff --git a/package.json b/package.json index f994f986..63042801 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", "@tiptap/suggestion": "^2.10.3", + "@types/qrcode": "^1.5.5", "bytes": "^3.1.2", "cross-env": "^7.0.3", "date-fns": "^4.1.0", @@ -70,6 +71,7 @@ "linkifyjs": "^4.2.0", "marked": "13.0.3", "ms": "3.0.0-canary.1", + "qrcode": "^1.5.4", "uuid": "^11.1.0", "y-indexeddb": "^9.0.12", "yjs": "^13.6.27" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c7a990b..ccaec09b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: '@tiptap/suggestion': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 bytes: specifier: ^3.1.2 version: 3.1.2 @@ -172,6 +175,9 @@ importers: ms: specifier: 3.0.0-canary.1 version: 3.0.0-canary.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -290,6 +296,9 @@ importers: lowlight: specifier: ^3.3.0 version: 3.3.0 + mantine-form-zod-resolver: + specifier: ^1.3.0 + version: 1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56) mermaid: specifier: ^11.6.0 version: 11.6.0 @@ -534,6 +543,9 @@ importers: openid-client: specifier: ^5.7.1 version: 5.7.1 + otpauth: + specifier: ^9.4.0 + version: 9.4.0 p-limit: specifier: ^6.2.0 version: 6.2.0 @@ -2846,6 +2858,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.7.1': + resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} + engines: {node: ^14.21.3 || >=16} + '@node-saml/node-saml@5.0.1': resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==} engines: {node: '>= 18'} @@ -4345,6 +4361,9 @@ packages: '@types/prop-types@15.7.11': resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/qs@6.9.14': resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==} @@ -5092,6 +5111,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -5518,6 +5540,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -5617,6 +5643,9 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dnd-core@14.0.1: resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} @@ -7161,6 +7190,13 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mantine-form-zod-resolver@1.3.0: + resolution: {integrity: sha512-XlXXkJCYuUuOllW0zedYW+m/lbdFQ/bso1Vz+pJOYkxgjhoGvzN2EXWCS2+0iTOT9Q7WnOwWvHmvpTJN3PxSXw==} + engines: {node: '>=16.6.0'} + peerDependencies: + '@mantine/form': '>=7.0.0' + zod: '>=3.25.0' + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -7632,6 +7668,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + otpauth@9.4.0: + resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -7878,6 +7917,10 @@ packages: png-chunks-extract@1.0.0: resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -8118,6 +8161,11 @@ packages: pwacompat@2.0.17: resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.12.0: resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==} engines: {node: '>=0.6'} @@ -8387,6 +8435,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -9392,6 +9443,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.16: resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==} engines: {node: '>= 0.4'} @@ -9531,6 +9585,9 @@ packages: peerDependencies: yjs: ^13.0.0 + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9550,10 +9607,18 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -12539,6 +12604,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.10': optional: true + '@noble/hashes@1.7.1': {} + '@node-saml/node-saml@5.0.1': dependencies: '@types/debug': 4.1.12 @@ -14169,6 +14236,10 @@ snapshots: '@types/prop-types@15.7.11': {} + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 22.13.4 + '@types/qs@6.9.14': {} '@types/range-parser@1.2.7': {} @@ -15124,6 +15195,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -15566,6 +15643,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.4.3: {} decode-named-character-reference@1.1.0: @@ -15644,6 +15723,8 @@ snapshots: diff@5.2.0: {} + dijkstrajs@1.0.3: {} + dnd-core@14.0.1: dependencies: '@react-dnd/asap': 4.0.1 @@ -17581,6 +17662,11 @@ snapshots: dependencies: tmpl: 1.0.5 + mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56): + dependencies: + '@mantine/form': 8.1.3(react@18.3.1) + zod: 3.25.56 + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -18196,6 +18282,10 @@ snapshots: os-tmpdir@1.0.2: {} + otpauth@9.4.0: + dependencies: + '@noble/hashes': 1.7.1 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -18443,6 +18533,8 @@ snapshots: dependencies: crc-32: 0.3.0 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-curve@1.0.1: {} @@ -18697,6 +18789,12 @@ snapshots: pwacompat@2.0.17: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.12.0: dependencies: side-channel: 1.0.6 @@ -18997,6 +19095,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -20058,6 +20158,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.3 + which-module@2.0.1: {} + which-typed-array@1.1.16: dependencies: available-typed-arrays: 1.0.7 @@ -20163,6 +20265,8 @@ snapshots: lib0: 0.2.108 yjs: 13.6.27 + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -20173,8 +20277,27 @@ snapshots: yaml@2.7.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1