mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 13:42:36 +10: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:
@ -39,6 +39,7 @@
|
|||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "0.16.22",
|
"katex": "0.16.22",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "^1.255.1",
|
||||||
|
|||||||
@ -358,7 +358,7 @@
|
|||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
"Default page edit mode": "Default page edit mode",
|
"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.",
|
"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",
|
"Delete member": "Delete member",
|
||||||
"Member deleted successfully": "Member deleted successfully",
|
"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.",
|
"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)",
|
"Close (Escape)": "Close (Escape)",
|
||||||
"Replace (Enter)": "Replace (Enter)",
|
"Replace (Enter)": "Replace (Enter)",
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,10 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec
|
|||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||||
import ShareLayout from "@/features/share/components/share-layout.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 { 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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -45,6 +47,11 @@ export default function App() {
|
|||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||||
|
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||||
|
<Route
|
||||||
|
path={"/login/mfa/setup"}
|
||||||
|
element={<MfaSetupRequiredPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
{!isCloud() && (
|
{!isCloud() && (
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||||
@ -58,7 +65,10 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
<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 path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal file
81
apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal file
193
apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal file
12
apps/client/src/ee/mfa/components/mfa-challenge.module.css
Normal 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);
|
||||||
|
}
|
||||||
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal file
160
apps/client/src/ee/mfa/components/mfa-challenge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal file
124
apps/client/src/ee/mfa/components/mfa-disable-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal file
112
apps/client/src/ee/mfa/components/mfa-settings.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal file
347
apps/client/src/ee/mfa/components/mfa-setup-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal file
48
apps/client/src/ee/mfa/components/mfa-setup-required.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal file
31
apps/client/src/ee/mfa/components/mfa.module.css
Normal 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;
|
||||||
|
}
|
||||||
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal file
51
apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts
Normal 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 };
|
||||||
|
}
|
||||||
19
apps/client/src/ee/mfa/index.ts
Normal file
19
apps/client/src/ee/mfa/index.ts
Normal 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";
|
||||||
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal file
13
apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal file
113
apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal file
61
apps/client/src/ee/mfa/services/mfa-service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal file
62
apps/client/src/ee/mfa/types/mfa.types.ts
Normal 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;
|
||||||
|
}
|
||||||
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal file
66
apps/client/src/ee/security/components/enforce-mfa.tsx
Normal 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")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||||
|
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||||
|
|
||||||
export default function Security() {
|
export default function Security() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -33,6 +34,10 @@ export default function Security() {
|
|||||||
|
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<EnforceMfa />
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
Single sign-on (SSO)
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { IRegister } from "@/features/auth/types/auth.types";
|
import { IRegister } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
|||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -39,9 +39,17 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(data);
|
const response = await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
// 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);
|
navigate(APP_ROUTE.HOME);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@ -56,9 +64,19 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acceptInvitation(data);
|
const response = await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
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);
|
navigate(APP_ROUTE.HOME);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@ -100,12 +118,22 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await passwordReset(data);
|
const response = await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
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);
|
navigate(APP_ROUTE.HOME);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Password reset was successful"),
|
message: t("Password reset was successful"),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@ -4,14 +4,16 @@ import {
|
|||||||
ICollabToken,
|
ICollabToken,
|
||||||
IForgotPassword,
|
IForgotPassword,
|
||||||
ILogin,
|
ILogin,
|
||||||
|
ILoginResponse,
|
||||||
IPasswordReset,
|
IPasswordReset,
|
||||||
ISetupWorkspace,
|
ISetupWorkspace,
|
||||||
IVerifyUserToken,
|
IVerifyUserToken,
|
||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||||
|
|
||||||
export async function login(data: ILogin): Promise<void> {
|
export async function login(data: ILogin): Promise<ILoginResponse> {
|
||||||
await api.post<void>("/auth/login", data);
|
const response = await api.post<ILoginResponse>("/auth/login", data);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
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);
|
await api.post<void>("/auth/forgot-password", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
||||||
await api.post<void>("/auth/password-reset", data);
|
const req = await api.post("/auth/password-reset", data);
|
||||||
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
|
|||||||
@ -38,3 +38,10 @@ export interface IVerifyUserToken {
|
|||||||
export interface ICollabToken {
|
export interface ICollabToken {
|
||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ILoginResponse {
|
||||||
|
userHasMfa?: boolean;
|
||||||
|
requiresMfaSetup?: boolean;
|
||||||
|
mfaToken?: string;
|
||||||
|
isMfaEnforced?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 />;
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export default function ChangeEmail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text size="md">{t("Email")}</Text>
|
<Text size="md">{t("Email")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{currentUser?.user.email}
|
{currentUser?.user.email}
|
||||||
@ -30,7 +30,7 @@ export default function ChangeEmail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
<Button onClick={open} variant="default">
|
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("Change email")}
|
{t("Change email")}
|
||||||
</Button>
|
</Button>
|
||||||
*/}
|
*/}
|
||||||
|
|||||||
@ -14,14 +14,14 @@ export default function ChangePassword() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
<div>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text size="md">{t("Password")}</Text>
|
<Text size="md">{t("Password")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("You can change your password here.")}
|
{t("You can change your password here.")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={open} variant="default">
|
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("Change password")}
|
{t("Change password")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@ -66,8 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
||||||
await api.post<void>("/workspace/invites/accept", data);
|
const req = await api.post("/workspace/invites/accept", data);
|
||||||
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInviteLink(data: {
|
export async function getInviteLink(data: {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface IWorkspace {
|
|||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
hasLicenseKey?: boolean;
|
hasLicenseKey?: boolean;
|
||||||
|
enforceMfa?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreateInvite {
|
export interface ICreateInvite {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ const APP_ROUTE = {
|
|||||||
PASSWORD_RESET: "/password-reset",
|
PASSWORD_RESET: "/password-reset",
|
||||||
CREATE_WORKSPACE: "/create",
|
CREATE_WORKSPACE: "/create",
|
||||||
SELECT_WORKSPACE: "/select",
|
SELECT_WORKSPACE: "/select",
|
||||||
|
MFA_CHALLENGE: "/login/mfa",
|
||||||
|
MFA_SETUP_REQUIRED: "/login/mfa/setup",
|
||||||
},
|
},
|
||||||
SETTINGS: {
|
SETTINGS: {
|
||||||
ACCOUNT: {
|
ACCOUNT: {
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import ChangePassword from "@/features/user/components/change-password";
|
|||||||
import { Divider } from "@mantine/core";
|
import { Divider } from "@mantine/core";
|
||||||
import AccountAvatar from "@/features/user/components/account-avatar";
|
import AccountAvatar from "@/features/user/components/account-avatar";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import {Helmet} from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
|
||||||
|
|
||||||
export default function AccountSettings() {
|
export default function AccountSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -14,7 +15,9 @@ export default function AccountSettings() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("My Profile")} - {getAppName()}</title>
|
<title>
|
||||||
|
{t("My Profile")} - {getAppName()}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<SettingsTitle title={t("My Profile")} />
|
<SettingsTitle title={t("My Profile")} />
|
||||||
|
|
||||||
@ -29,6 +32,10 @@ export default function AccountSettings() {
|
|||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<ChangePassword />
|
<ChangePassword />
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<AccountMfaSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,6 +71,7 @@
|
|||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
|
"otpauth": "^9.4.0",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||||
|
|
||||||
@ -74,3 +75,10 @@ export function sanitizeFileName(fileName: string): string {
|
|||||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
||||||
return sanitizedFilename.slice(0, 255);
|
return sanitizedFilename.slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractBearerTokenFromHeader(
|
||||||
|
request: FastifyRequest,
|
||||||
|
): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './services/auth.service';
|
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 { VerifyUserTokenDto } from './dto/verify-user-token.dto';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { validateSsoEnforcement } from './auth.util';
|
import { validateSsoEnforcement } from './auth.util';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
private readonly logger = new Logger(AuthController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -39,6 +44,45 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
validateSsoEnforcement(workspace);
|
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);
|
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||||
this.setAuthCookie(res, authToken);
|
this.setAuthCookie(res, authToken);
|
||||||
}
|
}
|
||||||
@ -85,11 +129,22 @@ export class AuthController {
|
|||||||
@Body() passwordResetDto: PasswordResetDto,
|
@Body() passwordResetDto: PasswordResetDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const authToken = await this.authService.passwordReset(
|
const result = await this.authService.passwordReset(
|
||||||
passwordResetDto,
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export enum JwtType {
|
|||||||
COLLAB = 'collab',
|
COLLAB = 'collab',
|
||||||
EXCHANGE = 'exchange',
|
EXCHANGE = 'exchange',
|
||||||
ATTACHMENT = 'attachment',
|
ATTACHMENT = 'attachment',
|
||||||
|
MFA_TOKEN = 'mfa_token',
|
||||||
}
|
}
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@ -30,3 +31,8 @@ export type JwtAttachmentPayload = {
|
|||||||
type: 'attachment';
|
type: 'attachment';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface JwtMfaTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
workspaceId: string;
|
||||||
|
type: 'mfa_token';
|
||||||
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export class AuthService {
|
|||||||
includePassword: true,
|
includePassword: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorMessage = 'email or password does not match';
|
const errorMessage = 'Email or password does not match';
|
||||||
if (!user || user?.deletedAt) {
|
if (!user || user?.deletedAt) {
|
||||||
throw new UnauthorizedException(errorMessage);
|
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(
|
const userToken = await this.userTokenRepo.findById(
|
||||||
passwordResetDto.token,
|
passwordResetDto.token,
|
||||||
workspaceId,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -170,7 +173,9 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Invalid or expired token');
|
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) {
|
if (!user || user.deletedAt) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@ -183,7 +188,7 @@ export class AuthService {
|
|||||||
password: newPasswordHash,
|
password: newPasswordHash,
|
||||||
},
|
},
|
||||||
user.id,
|
user.id,
|
||||||
workspaceId,
|
workspace.id,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -201,7 +206,18 @@ export class AuthService {
|
|||||||
template: emailTemplate,
|
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(
|
async verifyUserToken(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
JwtAttachmentPayload,
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
|
JwtMfaTokenPayload,
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
JwtType,
|
JwtType,
|
||||||
} from '../dto/jwt-payload';
|
} from '../dto/jwt-payload';
|
||||||
@ -76,6 +77,22 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateMfaToken(
|
||||||
|
user: User,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
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) {
|
async verifyJwt(token: string, tokenType: string) {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: this.environmentService.getAppSecret(),
|
secret: this.environmentService.getAppSecret(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { JwtPayload, JwtType } from '../dto/jwt-payload';
|
|||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
@ -18,7 +19,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: FastifyRequest) => {
|
jwtFromRequest: (req: FastifyRequest) => {
|
||||||
return req.cookies?.authToken || this.extractTokenFromHeader(req);
|
return req.cookies?.authToken || extractBearerTokenFromHeader(req);
|
||||||
},
|
},
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: environmentService.getAppSecret(),
|
secretOrKey: environmentService.getAppSecret(),
|
||||||
@ -48,9 +49,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return { user, workspace };
|
return { user, workspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromHeader(request: FastifyRequest): string | undefined {
|
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
|
||||||
return type === 'Bearer' ? token : undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,8 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
|
|||||||
import {
|
import {
|
||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
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 { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||||
@ -257,17 +258,27 @@ export class WorkspaceController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@Res({ passthrough: true }) res: FastifyReply,
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
const result = await this.workspaceInvitationService.acceptInvitation(
|
||||||
acceptInviteDto,
|
acceptInviteDto,
|
||||||
workspace,
|
workspace,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.setCookie('authToken', authToken, {
|
if (result.requiresLogin) {
|
||||||
|
return {
|
||||||
|
requiresLogin: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setCookie('authToken', result.authToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: this.environmentService.getCookieExpiresIn(),
|
expires: this.environmentService.getCookieExpiresIn(),
|
||||||
secure: this.environmentService.isHttps(),
|
secure: this.environmentService.isHttps(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requiresLogin: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
|||||||
@ -14,4 +14,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enforceSso: boolean;
|
enforceSso: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enforceMfa: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
const invitation = await this.db
|
||||||
.selectFrom('workspaceInvitations')
|
.selectFrom('workspaceInvitations')
|
||||||
.selectAll()
|
.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(
|
async resendInvitation(
|
||||||
|
|||||||
39
apps/server/src/database/migrations/20250715T070817-mfa.ts
Normal file
39
apps/server/src/database/migrations/20250715T070817-mfa.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
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<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('workspaces').dropColumn('enforce_mfa').execute();
|
||||||
|
|
||||||
|
await db.schema.dropTable('user_mfa').execute();
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
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 { hashPassword } from '../../../common/helpers';
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
import {
|
import {
|
||||||
@ -11,7 +11,8 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepo {
|
export class UserRepo {
|
||||||
@ -40,6 +41,7 @@ export class UserRepo {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
|
includeUserMfa?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@ -48,6 +50,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||||
|
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||||
.where('id', '=', userId)
|
.where('id', '=', userId)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@ -58,6 +61,7 @@ export class UserRepo {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
|
includeUserMfa?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@ -66,6 +70,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||||
|
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@ -177,4 +182,18 @@ export class UserRepo {
|
|||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('userMfa')
|
||||||
|
.select([
|
||||||
|
'userMfa.id',
|
||||||
|
'userMfa.method',
|
||||||
|
'userMfa.isEnabled',
|
||||||
|
'userMfa.createdAt',
|
||||||
|
])
|
||||||
|
.whereRef('userMfa.userId', '=', 'users.id'),
|
||||||
|
).as('mfa');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export class WorkspaceRepo {
|
|||||||
'trialEndAt',
|
'trialEndAt',
|
||||||
'enforceSso',
|
'enforceSso',
|
||||||
'plan',
|
'plan',
|
||||||
|
'enforceMfa',
|
||||||
];
|
];
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
|||||||
14
apps/server/src/database/types/db.d.ts
vendored
14
apps/server/src/database/types/db.d.ts
vendored
@ -247,6 +247,18 @@ export interface Spaces {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserMfa {
|
||||||
|
backupCodes: string[] | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
isEnabled: Generated<boolean | null>;
|
||||||
|
method: Generated<string>;
|
||||||
|
secret: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Users {
|
export interface Users {
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
@ -300,6 +312,7 @@ export interface Workspaces {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
emailDomains: Generated<string[] | null>;
|
emailDomains: Generated<string[] | null>;
|
||||||
|
enforceMfa: Generated<boolean | null>;
|
||||||
enforceSso: Generated<boolean>;
|
enforceSso: Generated<boolean>;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
@ -329,6 +342,7 @@ export interface DB {
|
|||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
|
userMfa: UserMfa;
|
||||||
users: Users;
|
users: Users;
|
||||||
userTokens: UserTokens;
|
userTokens: UserTokens;
|
||||||
workspaceInvitations: WorkspaceInvitations;
|
workspaceInvitations: WorkspaceInvitations;
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
AuthAccounts,
|
AuthAccounts,
|
||||||
Shares,
|
Shares,
|
||||||
FileTasks,
|
FileTasks,
|
||||||
|
UserMfa as _UserMFA,
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@ -113,3 +114,8 @@ export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
|||||||
export type FileTask = Selectable<FileTasks>;
|
export type FileTask = Selectable<FileTasks>;
|
||||||
export type InsertableFileTask = Insertable<FileTasks>;
|
export type InsertableFileTask = Insertable<FileTasks>;
|
||||||
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
||||||
|
|
||||||
|
// UserMFA
|
||||||
|
export type UserMFA = Selectable<_UserMFA>;
|
||||||
|
export type InsertableUserMFA = Insertable<_UserMFA>;
|
||||||
|
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: 49a16ab3e0...0a2b9a0dbc
@ -60,6 +60,7 @@
|
|||||||
"@tiptap/react": "^2.10.3",
|
"@tiptap/react": "^2.10.3",
|
||||||
"@tiptap/starter-kit": "^2.10.3",
|
"@tiptap/starter-kit": "^2.10.3",
|
||||||
"@tiptap/suggestion": "^2.10.3",
|
"@tiptap/suggestion": "^2.10.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -70,6 +71,7 @@
|
|||||||
"linkifyjs": "^4.2.0",
|
"linkifyjs": "^4.2.0",
|
||||||
"marked": "13.0.3",
|
"marked": "13.0.3",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"yjs": "^13.6.27"
|
"yjs": "^13.6.27"
|
||||||
|
|||||||
123
pnpm-lock.yaml
generated
123
pnpm-lock.yaml
generated
@ -142,6 +142,9 @@ importers:
|
|||||||
'@tiptap/suggestion':
|
'@tiptap/suggestion':
|
||||||
specifier: ^2.10.3
|
specifier: ^2.10.3
|
||||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
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:
|
bytes:
|
||||||
specifier: ^3.1.2
|
specifier: ^3.1.2
|
||||||
version: 3.1.2
|
version: 3.1.2
|
||||||
@ -172,6 +175,9 @@ importers:
|
|||||||
ms:
|
ms:
|
||||||
specifier: 3.0.0-canary.1
|
specifier: 3.0.0-canary.1
|
||||||
version: 3.0.0-canary.1
|
version: 3.0.0-canary.1
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@ -290,6 +296,9 @@ importers:
|
|||||||
lowlight:
|
lowlight:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 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:
|
mermaid:
|
||||||
specifier: ^11.6.0
|
specifier: ^11.6.0
|
||||||
version: 11.6.0
|
version: 11.6.0
|
||||||
@ -534,6 +543,9 @@ importers:
|
|||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^5.7.1
|
specifier: ^5.7.1
|
||||||
version: 5.7.1
|
version: 5.7.1
|
||||||
|
otpauth:
|
||||||
|
specifier: ^9.4.0
|
||||||
|
version: 9.4.0
|
||||||
p-limit:
|
p-limit:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.2.0
|
version: 6.2.0
|
||||||
@ -2846,6 +2858,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@node-saml/node-saml@5.0.1':
|
||||||
resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==}
|
resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -4345,6 +4361,9 @@ packages:
|
|||||||
'@types/prop-types@15.7.11':
|
'@types/prop-types@15.7.11':
|
||||||
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
|
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.5':
|
||||||
|
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
|
||||||
|
|
||||||
'@types/qs@6.9.14':
|
'@types/qs@6.9.14':
|
||||||
resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==}
|
resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==}
|
||||||
|
|
||||||
@ -5092,6 +5111,9 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -5518,6 +5540,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decimal.js@10.4.3:
|
decimal.js@10.4.3:
|
||||||
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
||||||
|
|
||||||
@ -5617,6 +5643,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dnd-core@14.0.1:
|
dnd-core@14.0.1:
|
||||||
resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==}
|
resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==}
|
||||||
|
|
||||||
@ -7161,6 +7190,13 @@ packages:
|
|||||||
makeerror@1.0.12:
|
makeerror@1.0.12:
|
||||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
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:
|
markdown-it@14.1.0:
|
||||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -7632,6 +7668,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
otpauth@9.4.0:
|
||||||
|
resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==}
|
||||||
|
|
||||||
p-limit@2.3.0:
|
p-limit@2.3.0:
|
||||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -7878,6 +7917,10 @@ packages:
|
|||||||
png-chunks-extract@1.0.0:
|
png-chunks-extract@1.0.0:
|
||||||
resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==}
|
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:
|
points-on-curve@0.2.0:
|
||||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||||
|
|
||||||
@ -8118,6 +8161,11 @@ packages:
|
|||||||
pwacompat@2.0.17:
|
pwacompat@2.0.17:
|
||||||
resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==}
|
resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.12.0:
|
qs@6.12.0:
|
||||||
resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==}
|
resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -8387,6 +8435,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-cwd@3.0.0:
|
resolve-cwd@3.0.0:
|
||||||
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -9392,6 +9443,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which-typed-array@1.1.16:
|
which-typed-array@1.1.16:
|
||||||
resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==}
|
resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -9531,6 +9585,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
yjs: ^13.0.0
|
yjs: ^13.0.0
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -9550,10 +9607,18 @@ packages:
|
|||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.2:
|
yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -12539,6 +12604,8 @@ snapshots:
|
|||||||
'@next/swc-win32-x64-msvc@14.2.10':
|
'@next/swc-win32-x64-msvc@14.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@noble/hashes@1.7.1': {}
|
||||||
|
|
||||||
'@node-saml/node-saml@5.0.1':
|
'@node-saml/node-saml@5.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 4.1.12
|
'@types/debug': 4.1.12
|
||||||
@ -14169,6 +14236,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/prop-types@15.7.11': {}
|
'@types/prop-types@15.7.11': {}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.13.4
|
||||||
|
|
||||||
'@types/qs@6.9.14': {}
|
'@types/qs@6.9.14': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
@ -15124,6 +15195,12 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
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:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@ -15566,6 +15643,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decimal.js@10.4.3: {}
|
decimal.js@10.4.3: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.1.0:
|
decode-named-character-reference@1.1.0:
|
||||||
@ -15644,6 +15723,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@5.2.0: {}
|
diff@5.2.0: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dnd-core@14.0.1:
|
dnd-core@14.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@react-dnd/asap': 4.0.1
|
'@react-dnd/asap': 4.0.1
|
||||||
@ -17581,6 +17662,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tmpl: 1.0.5
|
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:
|
markdown-it@14.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
@ -18196,6 +18282,10 @@ snapshots:
|
|||||||
|
|
||||||
os-tmpdir@1.0.2: {}
|
os-tmpdir@1.0.2: {}
|
||||||
|
|
||||||
|
otpauth@9.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.7.1
|
||||||
|
|
||||||
p-limit@2.3.0:
|
p-limit@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-try: 2.2.0
|
p-try: 2.2.0
|
||||||
@ -18443,6 +18533,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
crc-32: 0.3.0
|
crc-32: 0.3.0
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
points-on-curve@0.2.0: {}
|
points-on-curve@0.2.0: {}
|
||||||
|
|
||||||
points-on-curve@1.0.1: {}
|
points-on-curve@1.0.1: {}
|
||||||
@ -18697,6 +18789,12 @@ snapshots:
|
|||||||
|
|
||||||
pwacompat@2.0.17: {}
|
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:
|
qs@6.12.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.0.6
|
side-channel: 1.0.6
|
||||||
@ -18997,6 +19095,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-cwd@3.0.0:
|
resolve-cwd@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
@ -20058,6 +20158,8 @@ snapshots:
|
|||||||
is-weakmap: 2.0.2
|
is-weakmap: 2.0.2
|
||||||
is-weakset: 2.0.3
|
is-weakset: 2.0.3
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which-typed-array@1.1.16:
|
which-typed-array@1.1.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
available-typed-arrays: 1.0.7
|
available-typed-arrays: 1.0.7
|
||||||
@ -20163,6 +20265,8 @@ snapshots:
|
|||||||
lib0: 0.2.108
|
lib0: 0.2.108
|
||||||
yjs: 13.6.27
|
yjs: 13.6.27
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
@ -20173,8 +20277,27 @@ snapshots:
|
|||||||
|
|
||||||
yaml@2.7.0: {}
|
yaml@2.7.0: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
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:
|
yargs@17.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user