mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 07:43:06 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25fce8b049 | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af |
@@ -39,7 +39,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -222,7 +222,9 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
|
"Copy to space": "Copy to space",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
|
"Duplicate": "Duplicate",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
@@ -356,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.",
|
||||||
@@ -390,6 +392,7 @@
|
|||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully",
|
"Page copied successfully": "Page copied successfully",
|
||||||
|
"Page duplicated successfully": "Page duplicated successfully",
|
||||||
"Find": "Find",
|
"Find": "Find",
|
||||||
"Not found": "Not found",
|
"Not found": "Not found",
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||||
@@ -399,71 +402,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -29,10 +29,8 @@ 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();
|
||||||
@@ -47,11 +45,6 @@ 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 />} />
|
||||||
@@ -65,10 +58,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Route element={<ShareLayout />}>
|
<Route element={<ShareLayout />}>
|
||||||
<Route
|
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||||
path={"/share/:shareId/p/:pageSlug"}
|
|
||||||
element={<SharedPage />}
|
|
||||||
/>
|
|
||||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper {
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: var(--mantine-shadow-lg);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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 />;
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetUserMfa(
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post<{ success: boolean }>('/mfa/reset', { userId });
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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,7 +11,6 @@ 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();
|
||||||
@@ -34,10 +33,6 @@ 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 } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -11,7 +11,6 @@ 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 } from "@/ee/utils.ts";
|
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -39,17 +39,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
// Check if MFA is required
|
|
||||||
if (response?.userHasMfa) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
|
||||||
} else if (response?.requiresMfaSetup) {
|
|
||||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@@ -64,19 +56,9 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await acceptInvitation(data);
|
await acceptInvitation(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
|
||||||
notifications.show({
|
|
||||||
message: t(
|
|
||||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -118,22 +100,12 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await passwordReset(data);
|
await passwordReset(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
navigate(APP_ROUTE.HOME);
|
||||||
if (response?.requiresLogin) {
|
notifications.show({
|
||||||
notifications.show({
|
message: t("Password reset was successful"),
|
||||||
message: t(
|
});
|
||||||
"Password reset was successful. Please log in with your new password.",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
|
||||||
} else {
|
|
||||||
navigate(APP_ROUTE.HOME);
|
|
||||||
notifications.show({
|
|
||||||
message: t("Password reset was successful"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ 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<ILoginResponse> {
|
export async function login(data: ILogin): Promise<void> {
|
||||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
await api.post<void>("/auth/login", data);
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
@@ -38,9 +36,8 @@ 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<{ requiresLogin?: boolean; }> {
|
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||||
const req = await api.post("/auth/password-reset", data);
|
await api.post<void>("/auth/password-reset", data);
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||||
@@ -50,4 +47,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
|||||||
export async function getCollabToken(): Promise<ICollabToken> {
|
export async function getCollabToken(): Promise<ICollabToken> {
|
||||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,3 @@ 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,96 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 24px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .resizeHandleBottom,
|
||||||
|
.resizing .resizeHandleBottom {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeBar {
|
||||||
|
width: 50px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-gray-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom:hover .resizeBar,
|
||||||
|
.resizing .resizeBar {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-gray-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./resizable-wrapper.module.css";
|
||||||
|
|
||||||
|
interface ResizableWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialHeight?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
onResize?: (height: number) => void;
|
||||||
|
isEditable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
showHandles?: "always" | "hover";
|
||||||
|
direction?: "vertical" | "horizontal" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||||
|
children,
|
||||||
|
initialHeight = 480,
|
||||||
|
minHeight = 200,
|
||||||
|
maxHeight = 1200,
|
||||||
|
onResize,
|
||||||
|
isEditable = true,
|
||||||
|
className,
|
||||||
|
showHandles = "hover",
|
||||||
|
direction = "vertical",
|
||||||
|
}) => {
|
||||||
|
const [resizeParams, setResizeParams] = useState<{
|
||||||
|
initialSize: number;
|
||||||
|
initialClientY: number;
|
||||||
|
initialClientX: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizeParams) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current) return;
|
||||||
|
|
||||||
|
if (direction === "vertical" || direction === "both") {
|
||||||
|
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||||
|
const newHeight = Math.min(
|
||||||
|
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||||
|
maxHeight
|
||||||
|
);
|
||||||
|
setCurrentHeight(newHeight);
|
||||||
|
wrapperRef.current.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setResizeParams(null);
|
||||||
|
if (onResize && currentHeight !== initialHeight) {
|
||||||
|
onResize(currentHeight);
|
||||||
|
}
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setResizeParams({
|
||||||
|
initialSize: currentHeight,
|
||||||
|
initialClientY: e.clientY,
|
||||||
|
initialClientX: e.clientX,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.style.cursor = "ns-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
}, [currentHeight]);
|
||||||
|
|
||||||
|
const shouldShowHandles =
|
||||||
|
isEditable &&
|
||||||
|
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={clsx(classes.wrapper, className, {
|
||||||
|
[classes.resizing]: !!resizeParams,
|
||||||
|
})}
|
||||||
|
style={{ height: currentHeight }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!!resizeParams && <div className={classes.overlay} />}
|
||||||
|
{shouldShowHandles && direction === "vertical" && (
|
||||||
|
<div
|
||||||
|
className={classes.resizeHandleBottom}
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
>
|
||||||
|
<div className={classes.resizeBar} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.embedWrapper {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedIframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import React, { useMemo, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AspectRatio,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@@ -14,7 +13,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
|
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||||
|
import classes from "./embed-view.module.css";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@@ -33,7 +35,7 @@ const schema = z.object({
|
|||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes, editor } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider, height: nodeHeight } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleResize = useCallback((newHeight: number) => {
|
||||||
|
updateAttributes({ height: newHeight });
|
||||||
|
}, [updateAttributes]);
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
@@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<>
|
<ResizableWrapper
|
||||||
<AspectRatio ratio={16 / 9}>
|
initialHeight={nodeHeight || 480}
|
||||||
<iframe
|
minHeight={200}
|
||||||
src={embedUrl}
|
maxHeight={1200}
|
||||||
allow="encrypted-media"
|
onResize={handleResize}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
isEditable={editor.isEditable}
|
||||||
allowFullScreen
|
className={clsx(classes.embedWrapper, {
|
||||||
frameBorder="0"
|
"ProseMirror-selectednode": selected,
|
||||||
></iframe>
|
})}
|
||||||
</AspectRatio>
|
>
|
||||||
</>
|
<iframe
|
||||||
|
className={classes.embedIframe}
|
||||||
|
src={embedUrl}
|
||||||
|
allow="encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
allowFullScreen
|
||||||
|
frameBorder="0"
|
||||||
|
/>
|
||||||
|
</ResizableWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -30,7 +30,7 @@ export default function CopyPageModal({
|
|||||||
if (!targetSpace) return;
|
if (!targetSpace) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const copiedPage = await copyPageToSpace({
|
const copiedPage = await duplicatePage({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
|||||||
await api.post<void>("/pages/move-to-space", data);
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
import {
|
||||||
|
NodeApi,
|
||||||
|
NodeRendererProps,
|
||||||
|
Tree,
|
||||||
|
TreeApi,
|
||||||
|
SimpleTree,
|
||||||
|
} from "react-arborist";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||||
|
import { duplicatePage } from "../../services/page-service.ts";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -90,8 +97,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
|
const [isRootReady, setIsRootReady] = useState(false);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef((element) => {
|
||||||
|
rootElement.current = element;
|
||||||
|
if (element && !isRootReady) {
|
||||||
|
setIsRootReady(true);
|
||||||
|
}
|
||||||
|
}, sizeRef);
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
@@ -199,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [currentPage?.id]);
|
}, [currentPage?.id]);
|
||||||
|
|
||||||
|
// Clean up tree API on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (treeApiRef.current) {
|
return () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTreeApi(treeApiRef.current);
|
setTreeApi(null);
|
||||||
}
|
};
|
||||||
}, [treeApiRef.current]);
|
}, [setTreeApi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{rootElement.current && (
|
{isRootReady && rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
@@ -217,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={rootElement.current.clientHeight}
|
height={rootElement.current.clientHeight}
|
||||||
ref={treeApiRef}
|
ref={(ref) => {
|
||||||
|
treeApiRef.current = ref;
|
||||||
|
if (ref) {
|
||||||
|
//@ts-ignore
|
||||||
|
setTreeApi(ref);
|
||||||
|
}
|
||||||
|
}}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
@@ -383,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} />
|
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||||
|
|
||||||
{!tree.props.disableEdit && (
|
{!tree.props.disableEdit && (
|
||||||
<CreateNode
|
<CreateNode
|
||||||
@@ -436,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
|||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
node: NodeApi<SpaceTreeNode>;
|
node: NodeApi<SpaceTreeNode>;
|
||||||
treeApi: TreeApi<SpaceTreeNode>;
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
|
const [data, setData] = useAtom(treeDataAtom);
|
||||||
|
const emit = useQueryEmit();
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [
|
const [
|
||||||
@@ -461,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicatePage = async () => {
|
||||||
|
try {
|
||||||
|
const duplicatedPage = await duplicatePage({
|
||||||
|
pageId: node.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the index of the current node
|
||||||
|
const parentId =
|
||||||
|
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
||||||
|
? null
|
||||||
|
: node.parent?.id;
|
||||||
|
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
||||||
|
const currentIndex =
|
||||||
|
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
||||||
|
const newIndex = currentIndex + 1;
|
||||||
|
|
||||||
|
// Add the duplicated page to the tree
|
||||||
|
const treeNodeData: SpaceTreeNode = {
|
||||||
|
id: duplicatedPage.id,
|
||||||
|
slugId: duplicatedPage.slugId,
|
||||||
|
name: duplicatedPage.title,
|
||||||
|
position: duplicatedPage.position,
|
||||||
|
spaceId: duplicatedPage.spaceId,
|
||||||
|
parentPageId: duplicatedPage.parentPageId,
|
||||||
|
icon: duplicatedPage.icon,
|
||||||
|
hasChildren: duplicatedPage.hasChildren,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update local tree
|
||||||
|
const simpleTree = new SimpleTree(data);
|
||||||
|
simpleTree.create({
|
||||||
|
parentId,
|
||||||
|
index: newIndex,
|
||||||
|
data: treeNodeData,
|
||||||
|
});
|
||||||
|
setData(simpleTree.data);
|
||||||
|
|
||||||
|
// Emit socket event
|
||||||
|
setTimeout(() => {
|
||||||
|
emit({
|
||||||
|
operation: "addTreeNode",
|
||||||
|
spaceId: spaceId,
|
||||||
|
payload: {
|
||||||
|
parentId,
|
||||||
|
index: newIndex,
|
||||||
|
data: treeNodeData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: t("Page duplicated successfully"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data.message || "An error occurred",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
@@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<>
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconCopy size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDuplicatePage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Duplicate")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconArrowRight size={16} />}
|
leftSection={<IconArrowRight size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -524,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
openCopyPageModal();
|
openCopyPageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Copy")}
|
{t("Copy to space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface IMovePageToSpace {
|
|||||||
|
|
||||||
export interface ICopyPageToSpace {
|
export interface ICopyPageToSpace {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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 style={{ minWidth: 0, flex: 1 }}>
|
<div>
|
||||||
<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" style={{ whiteSpace: "nowrap" }}>
|
<Button onClick={open} variant="default">
|
||||||
{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 style={{ minWidth: 0, flex: 1 }}>
|
<div>
|
||||||
<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" style={{ whiteSpace: "nowrap" }}>
|
<Button onClick={open} variant="default">
|
||||||
{t("Change password")}
|
{t("Change password")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
+4
-46
@@ -1,41 +1,23 @@
|
|||||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IconDots, IconTrash, IconShieldOff } from "@tabler/icons-react";
|
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import {
|
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
useDeleteWorkspaceMemberMutation,
|
|
||||||
useResetUserMfaMutation
|
|
||||||
} from "@/features/workspace/queries/workspace-query.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
|
||||||
import { isCloud } from "@/lib/config.ts";
|
|
||||||
import { UserRole } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
userRole: string;
|
|
||||||
}
|
}
|
||||||
export default function MemberActionMenu({ userId, userRole }: Props) {
|
export default function MemberActionMenu({ userId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||||
const resetUserMfaMutation = useResetUserMfaMutation();
|
const { isAdmin } = useUserRole();
|
||||||
const { isAdmin, isOwner } = useUserRole();
|
|
||||||
const { hasLicenseKey } = useLicense();
|
|
||||||
|
|
||||||
// Show MFA reset only for self-hosted enterprise edition
|
|
||||||
// Admins cannot reset MFA for owners
|
|
||||||
const canResetMfa = isOwner || (isAdmin && userRole !== UserRole.OWNER);
|
|
||||||
const showMfaReset = !isCloud() && hasLicenseKey && canResetMfa;
|
|
||||||
|
|
||||||
const onRevoke = async () => {
|
const onRevoke = async () => {
|
||||||
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResetMfa = async () => {
|
|
||||||
await resetUserMfaMutation.mutateAsync({ userId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRevokeModal = () =>
|
const openRevokeModal = () =>
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
title: t("Delete member"),
|
title: t("Delete member"),
|
||||||
@@ -52,22 +34,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
|||||||
onConfirm: onRevoke,
|
onConfirm: onRevoke,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openResetMfaModal = () =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t("Reset MFA"),
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
{t(
|
|
||||||
"Are you sure you want to reset MFA for this user? They will need to set up MFA again.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
centered: true,
|
|
||||||
labels: { confirm: t("Reset"), cancel: t("Cancel") },
|
|
||||||
confirmProps: { color: "red" },
|
|
||||||
onConfirm: onResetMfa,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -85,14 +51,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{showMfaReset && (
|
|
||||||
<Menu.Item
|
|
||||||
onClick={openResetMfaModal}
|
|
||||||
leftSection={<IconShieldOff size={16} />}
|
|
||||||
>
|
|
||||||
{t("Reset MFA")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
onClick={openRevokeModal}
|
onClick={openRevokeModal}
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{isAdmin && <MemberActionMenu userId={user.id} userRole={user.role} />}
|
{isAdmin && <MemberActionMenu userId={user.id} />}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
getAppVersion,
|
getAppVersion,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
} from "@/features/workspace/services/workspace-service";
|
} from "@/features/workspace/services/workspace-service";
|
||||||
import { resetUserMfa } from "@/ee/mfa";
|
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
@@ -193,29 +192,3 @@ export function useAppVersion(
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResetUserMfaMutation() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
{ success: boolean },
|
|
||||||
Error,
|
|
||||||
{ userId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ userId }) => resetUserMfa(userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("MFA has been reset successfully"),
|
|
||||||
color: "green"
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["workspaceMembers"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message || t("Failed to reset MFA");
|
|
||||||
notifications.show({ message: errorMessage, color: "red" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,9 +66,8 @@ export async function createInvitation(data: ICreateInvite) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
||||||
const req = await api.post("/workspace/invites/accept", data);
|
await api.post<void>("/workspace/invites/accept", data);
|
||||||
return req.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInviteLink(data: {
|
export async function getInviteLink(data: {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export interface IWorkspace {
|
|||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
hasLicenseKey?: boolean;
|
hasLicenseKey?: boolean;
|
||||||
enforceMfa?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreateInvite {
|
export interface ICreateInvite {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ 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,21 +4,18 @@ 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();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>
|
<title>{t("My Profile")} - {getAppName()}</title>
|
||||||
{t("My Profile")} - {getAppName()}
|
</Helmet>
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
<SettingsTitle title={t("My Profile")} />
|
<SettingsTitle title={t("My Profile")} />
|
||||||
|
|
||||||
<AccountAvatar />
|
<AccountAvatar />
|
||||||
@@ -32,10 +29,6 @@ export default function AccountSettings() {
|
|||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<ChangePassword />
|
<ChangePassword />
|
||||||
|
|
||||||
<Divider my="lg" />
|
|
||||||
|
|
||||||
<AccountMfaSection />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +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",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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');
|
||||||
|
|
||||||
@@ -75,10 +74,3 @@ 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,7 +6,6 @@ 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';
|
||||||
@@ -23,16 +22,12 @@ 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)
|
||||||
@@ -44,45 +39,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -129,22 +85,11 @@ export class AuthController {
|
|||||||
@Body() passwordResetDto: PasswordResetDto,
|
@Body() passwordResetDto: PasswordResetDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.passwordReset(
|
const authToken = await this.authService.passwordReset(
|
||||||
passwordResetDto,
|
passwordResetDto,
|
||||||
workspace,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
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,7 +3,6 @@ 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;
|
||||||
@@ -31,8 +30,3 @@ 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,13 +156,10 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async passwordReset(
|
async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) {
|
||||||
passwordResetDto: PasswordResetDto,
|
|
||||||
workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const userToken = await this.userTokenRepo.findById(
|
const userToken = await this.userTokenRepo.findById(
|
||||||
passwordResetDto.token,
|
passwordResetDto.token,
|
||||||
workspace.id,
|
workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -173,9 +170,7 @@ 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, workspace.id, {
|
const user = await this.userRepo.findById(userToken.userId, workspaceId);
|
||||||
includeUserMfa: true,
|
|
||||||
});
|
|
||||||
if (!user || user.deletedAt) {
|
if (!user || user.deletedAt) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
@@ -188,7 +183,7 @@ export class AuthService {
|
|||||||
password: newPasswordHash,
|
password: newPasswordHash,
|
||||||
},
|
},
|
||||||
user.id,
|
user.id,
|
||||||
workspace.id,
|
workspaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,18 +201,7 @@ export class AuthService {
|
|||||||
template: emailTemplate,
|
template: emailTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if user has MFA enabled or workspace enforces MFA
|
return this.tokenService.generateAccessToken(user);
|
||||||
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,7 +9,6 @@ import {
|
|||||||
JwtAttachmentPayload,
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
JwtMfaTokenPayload,
|
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
JwtType,
|
JwtType,
|
||||||
} from '../dto/jwt-payload';
|
} from '../dto/jwt-payload';
|
||||||
@@ -77,22 +76,6 @@ 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,7 +6,6 @@ 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') {
|
||||||
@@ -19,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: FastifyRequest) => {
|
jwtFromRequest: (req: FastifyRequest) => {
|
||||||
return req.cookies?.authToken || extractBearerTokenFromHeader(req);
|
return req.cookies?.authToken || this.extractTokenFromHeader(req);
|
||||||
},
|
},
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: environmentService.getAppSecret(),
|
secretOrKey: environmentService.getAppSecret(),
|
||||||
@@ -49,4 +48,9 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
import { IsString, IsNotEmpty } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class CopyPageToSpaceDto {
|
export class DuplicatePageDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CopyPageMapEntry = {
|
export type CopyPageMapEntry = {
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -242,33 +242,41 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('copy-to-space')
|
@Post('duplicate')
|
||||||
async copyPageToSpace(
|
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||||
@Body() dto: CopyPageToSpaceDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
) {
|
|
||||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||||
if (!copiedPage) {
|
if (!copiedPage) {
|
||||||
throw new NotFoundException('Page to copy not found');
|
throw new NotFoundException('Page to copy not found');
|
||||||
}
|
}
|
||||||
if (copiedPage.spaceId === dto.spaceId) {
|
|
||||||
throw new BadRequestException('Page is already in this space');
|
// If spaceId is provided, it's a copy to different space
|
||||||
|
if (dto.spaceId) {
|
||||||
|
const abilities = await Promise.all([
|
||||||
|
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||||
|
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
abilities.some((ability) =>
|
||||||
|
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
|
||||||
|
} else {
|
||||||
|
// If no spaceId, it's a duplicate in same space
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
copiedPage.spaceId,
|
||||||
|
);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const abilities = await Promise.all([
|
|
||||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
|
||||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
abilities.some((ability) =>
|
|
||||||
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import {
|
|||||||
removeMarkTypeFromDoc,
|
removeMarkTypeFromDoc,
|
||||||
} from '../../../common/helpers/prosemirror/utils';
|
} from '../../../common/helpers/prosemirror/utils';
|
||||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
import {
|
||||||
|
CopyPageMapEntry,
|
||||||
|
ICopyPageAttachment,
|
||||||
|
} from '../dto/duplicate-page.dto';
|
||||||
import { Node as PMNode } from '@tiptap/pm/model';
|
import { Node as PMNode } from '@tiptap/pm/model';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
|
|
||||||
@@ -258,11 +261,52 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
async duplicatePage(
|
||||||
//TODO:
|
rootPage: Page,
|
||||||
// i. maintain internal links within copied pages
|
targetSpaceId: string | undefined,
|
||||||
|
authUser: User,
|
||||||
|
) {
|
||||||
|
const spaceId = targetSpaceId || rootPage.spaceId;
|
||||||
|
const isDuplicateInSameSpace =
|
||||||
|
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
||||||
|
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
let nextPosition: string;
|
||||||
|
|
||||||
|
if (isDuplicateInSameSpace) {
|
||||||
|
// For duplicate in same space, position right after the original page
|
||||||
|
let siblingQuery = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['position'])
|
||||||
|
.where('spaceId', '=', rootPage.spaceId)
|
||||||
|
.where('position', '>', rootPage.position);
|
||||||
|
|
||||||
|
if (rootPage.parentPageId) {
|
||||||
|
siblingQuery = siblingQuery.where(
|
||||||
|
'parentPageId',
|
||||||
|
'=',
|
||||||
|
rootPage.parentPageId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
siblingQuery = siblingQuery.where('parentPageId', 'is', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSibling = await siblingQuery
|
||||||
|
.orderBy('position', 'asc')
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (nextSibling) {
|
||||||
|
nextPosition = generateJitteredKeyBetween(
|
||||||
|
rootPage.position,
|
||||||
|
nextSibling.position,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For copy to different space, position at the end
|
||||||
|
nextPosition = await this.nextPagePosition(spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
@@ -326,12 +370,38 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update internal page links in mention nodes
|
||||||
|
prosemirrorDoc.descendants((node: PMNode) => {
|
||||||
|
if (
|
||||||
|
node.type.name === 'mention' &&
|
||||||
|
node.attrs.entityType === 'page'
|
||||||
|
) {
|
||||||
|
const referencedPageId = node.attrs.entityId;
|
||||||
|
|
||||||
|
// Check if the referenced page is within the pages being copied
|
||||||
|
if (referencedPageId && pageMap.has(referencedPageId)) {
|
||||||
|
const mappedPage = pageMap.get(referencedPageId);
|
||||||
|
//@ts-ignore
|
||||||
|
node.attrs.entityId = mappedPage.newPageId;
|
||||||
|
//@ts-ignore
|
||||||
|
node.attrs.slugId = mappedPage.newSlugId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||||
|
|
||||||
|
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||||
|
let title = page.title;
|
||||||
|
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
||||||
|
const originalTitle = page.title || 'Untitled';
|
||||||
|
title = `Copy of ${originalTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pageFromMap.newPageId,
|
id: pageFromMap.newPageId,
|
||||||
slugId: pageFromMap.newSlugId,
|
slugId: pageFromMap.newSlugId,
|
||||||
title: page.title,
|
title: title,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
textContent: jsonToText(prosemirrorJson),
|
textContent: jsonToText(prosemirrorJson),
|
||||||
@@ -401,9 +471,16 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||||
return await this.pageRepo.findById(newPageId, {
|
const duplicatedPage = await this.pageRepo.findById(newPageId, {
|
||||||
includeSpace: true,
|
includeSpace: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasChildren = pages.length > 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...duplicatedPage,
|
||||||
|
hasChildren,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
|
|||||||
import {
|
import {
|
||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
WorkspaceCaslSubject,
|
||||||
} from '../../casl/interfaces/workspace-ability.type';
|
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
|
||||||
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';
|
||||||
@@ -258,27 +257,17 @@ export class WorkspaceController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@Res({ passthrough: true }) res: FastifyReply,
|
@Res({ passthrough: true }) res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const result = await this.workspaceInvitationService.acceptInvitation(
|
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||||
acceptInviteDto,
|
acceptInviteDto,
|
||||||
workspace,
|
workspace,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.requiresLogin) {
|
res.setCookie('authToken', authToken, {
|
||||||
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,8 +14,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enforceSso: boolean;
|
enforceSso: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enforceMfa: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,14 +177,7 @@ export class WorkspaceInvitationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptInvitation(
|
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
|
||||||
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()
|
||||||
@@ -296,14 +289,7 @@ export class WorkspaceInvitationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.enforceMfa) {
|
return this.tokenService.generateAccessToken(newUser);
|
||||||
return {
|
|
||||||
requiresLogin: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authToken = await this.tokenService.generateAccessToken(newUser);
|
|
||||||
return { authToken };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resendInvitation(
|
async resendInvitation(
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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 { DB, Users } from '@docmost/db/types/db';
|
import { 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,8 +11,7 @@ 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 { ExpressionBuilder, sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepo {
|
export class UserRepo {
|
||||||
@@ -41,7 +40,6 @@ export class UserRepo {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
includeUserMfa?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@@ -50,7 +48,6 @@ 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();
|
||||||
@@ -61,7 +58,6 @@ export class UserRepo {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
includeUserMfa?: boolean;
|
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@@ -70,7 +66,6 @@ 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();
|
||||||
@@ -182,18 +177,4 @@ 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,7 +32,6 @@ export class WorkspaceRepo {
|
|||||||
'trialEndAt',
|
'trialEndAt',
|
||||||
'enforceSso',
|
'enforceSso',
|
||||||
'plan',
|
'plan',
|
||||||
'enforceMfa',
|
|
||||||
];
|
];
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
|||||||
-14
@@ -247,18 +247,6 @@ 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>;
|
||||||
@@ -312,7 +300,6 @@ 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>;
|
||||||
@@ -342,7 +329,6 @@ 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,7 +18,6 @@ import {
|
|||||||
AuthAccounts,
|
AuthAccounts,
|
||||||
Shares,
|
Shares,
|
||||||
FileTasks,
|
FileTasks,
|
||||||
UserMfa as _UserMFA,
|
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@@ -114,8 +113,3 @@ 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'>>;
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 2f4ed49609...49a16ab3e0
@@ -14,10 +14,14 @@ import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
|||||||
import { unwrapFromParagraph } from '../utils/import-formatter';
|
import { unwrapFromParagraph } from '../utils/import-formatter';
|
||||||
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportAttachmentService {
|
export class ImportAttachmentService {
|
||||||
private readonly logger = new Logger(ImportAttachmentService.name);
|
private readonly logger = new Logger(ImportAttachmentService.name);
|
||||||
|
private readonly CONCURRENT_UPLOADS = 3;
|
||||||
|
private readonly MAX_RETRIES = 2;
|
||||||
|
private readonly RETRY_DELAY = 2000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@@ -41,7 +45,14 @@ export class ImportAttachmentService {
|
|||||||
attachmentCandidates,
|
attachmentCandidates,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const attachmentTasks: Promise<void>[] = [];
|
const attachmentTasks: (() => Promise<void>)[] = [];
|
||||||
|
const limit = pLimit(this.CONCURRENT_UPLOADS);
|
||||||
|
const uploadStats = {
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
failedFiles: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache keyed by the *relative* path that appears in the HTML.
|
* Cache keyed by the *relative* path that appears in the HTML.
|
||||||
@@ -74,30 +85,16 @@ export class ImportAttachmentService {
|
|||||||
|
|
||||||
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
||||||
|
|
||||||
attachmentTasks.push(
|
attachmentTasks.push(() => this.uploadWithRetry({
|
||||||
(async () => {
|
abs,
|
||||||
const fileStream = createReadStream(abs);
|
storageFilePath,
|
||||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
attachmentId,
|
||||||
const stat = await fs.stat(abs);
|
fileNameWithExt,
|
||||||
|
ext,
|
||||||
await this.db
|
pageId,
|
||||||
.insertInto('attachments')
|
fileTask,
|
||||||
.values({
|
uploadStats,
|
||||||
id: attachmentId,
|
}));
|
||||||
filePath: storageFilePath,
|
|
||||||
fileName: fileNameWithExt,
|
|
||||||
fileSize: stat.size,
|
|
||||||
mimeType: getMimeType(fileNameWithExt),
|
|
||||||
type: 'file',
|
|
||||||
fileExt: ext,
|
|
||||||
creatorId: fileTask.creatorId,
|
|
||||||
workspaceId: fileTask.workspaceId,
|
|
||||||
pageId,
|
|
||||||
spaceId: fileTask.spaceId,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachmentId,
|
attachmentId,
|
||||||
@@ -292,12 +289,113 @@ export class ImportAttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wait for all uploads & DB inserts
|
// wait for all uploads & DB inserts
|
||||||
try {
|
uploadStats.total = attachmentTasks.length;
|
||||||
await Promise.all(attachmentTasks);
|
|
||||||
} catch (err) {
|
if (uploadStats.total > 0) {
|
||||||
this.logger.log('Import attachment upload error', err);
|
this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
attachmentTasks.map(task => limit(task))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Import attachment upload error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadStats.failed > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to upload ${uploadStats.failed} files:`,
|
||||||
|
uploadStats.failedFiles
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $.root().html() || '';
|
return $.root().html() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async uploadWithRetry(opts: {
|
||||||
|
abs: string;
|
||||||
|
storageFilePath: string;
|
||||||
|
attachmentId: string;
|
||||||
|
fileNameWithExt: string;
|
||||||
|
ext: string;
|
||||||
|
pageId: string;
|
||||||
|
fileTask: FileTask;
|
||||||
|
uploadStats: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
failedFiles: string[];
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
abs,
|
||||||
|
storageFilePath,
|
||||||
|
attachmentId,
|
||||||
|
fileNameWithExt,
|
||||||
|
ext,
|
||||||
|
pageId,
|
||||||
|
fileTask,
|
||||||
|
uploadStats,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const fileStream = createReadStream(abs);
|
||||||
|
await this.storageService.uploadStream(storageFilePath, fileStream);
|
||||||
|
const stat = await fs.stat(abs);
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.insertInto('attachments')
|
||||||
|
.values({
|
||||||
|
id: attachmentId,
|
||||||
|
filePath: storageFilePath,
|
||||||
|
fileName: fileNameWithExt,
|
||||||
|
fileSize: stat.size,
|
||||||
|
mimeType: getMimeType(fileNameWithExt),
|
||||||
|
type: 'file',
|
||||||
|
fileExt: ext,
|
||||||
|
creatorId: fileTask.creatorId,
|
||||||
|
workspaceId: fileTask.workspaceId,
|
||||||
|
pageId,
|
||||||
|
spaceId: fileTask.spaceId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
uploadStats.completed++;
|
||||||
|
|
||||||
|
if (uploadStats.completed % 10 === 0) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Upload progress: ${uploadStats.completed}/${uploadStats.total}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
this.logger.warn(
|
||||||
|
`Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < this.MAX_RETRIES) {
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, this.RETRY_DELAY * attempt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStats.failed++;
|
||||||
|
uploadStats.failedFiles.push(fileNameWithExt);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`,
|
||||||
|
lastError
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
"@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",
|
||||||
@@ -71,7 +70,6 @@
|
|||||||
"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"
|
||||||
|
|||||||
Generated
+17
-123
@@ -142,9 +142,6 @@ 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
|
||||||
@@ -175,9 +172,6 @@ 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
|
||||||
@@ -296,9 +290,6 @@ 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
|
||||||
@@ -543,9 +534,9 @@ importers:
|
|||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^5.7.1
|
specifier: ^5.7.1
|
||||||
version: 5.7.1
|
version: 5.7.1
|
||||||
otpauth:
|
p-limit:
|
||||||
specifier: ^9.4.0
|
specifier: ^6.2.0
|
||||||
version: 9.4.0
|
version: 6.2.0
|
||||||
passport-google-oauth20:
|
passport-google-oauth20:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -2855,10 +2846,6 @@ 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'}
|
||||||
@@ -4358,9 +4345,6 @@ 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==}
|
||||||
|
|
||||||
@@ -5108,9 +5092,6 @@ 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'}
|
||||||
@@ -5537,10 +5518,6 @@ 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==}
|
||||||
|
|
||||||
@@ -5640,9 +5617,6 @@ 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==}
|
||||||
|
|
||||||
@@ -7187,13 +7161,6 @@ 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
|
||||||
@@ -7665,9 +7632,6 @@ 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'}
|
||||||
@@ -7676,6 +7640,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
p-locate@4.1.0:
|
p-locate@4.1.0:
|
||||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -7910,10 +7878,6 @@ 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==}
|
||||||
|
|
||||||
@@ -8154,11 +8118,6 @@ 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'}
|
||||||
@@ -8428,9 +8387,6 @@ 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'}
|
||||||
@@ -9436,9 +9392,6 @@ 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'}
|
||||||
@@ -9578,9 +9531,6 @@ 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'}
|
||||||
@@ -9600,18 +9550,10 @@ 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'}
|
||||||
@@ -9632,6 +9574,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yocto-queue@1.2.1:
|
||||||
|
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
|
||||||
|
engines: {node: '>=12.20'}
|
||||||
|
|
||||||
yoctocolors-cjs@2.1.2:
|
yoctocolors-cjs@2.1.2:
|
||||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -12593,8 +12539,6 @@ 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
|
||||||
@@ -14225,10 +14169,6 @@ 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': {}
|
||||||
@@ -15184,12 +15124,6 @@ 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
|
||||||
@@ -15632,8 +15566,6 @@ 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:
|
||||||
@@ -15712,8 +15644,6 @@ 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
|
||||||
@@ -17651,11 +17581,6 @@ 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
|
||||||
@@ -18271,10 +18196,6 @@ 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
|
||||||
@@ -18283,6 +18204,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
yocto-queue: 1.2.1
|
||||||
|
|
||||||
p-locate@4.1.0:
|
p-locate@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 2.3.0
|
p-limit: 2.3.0
|
||||||
@@ -18518,8 +18443,6 @@ 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: {}
|
||||||
@@ -18774,12 +18697,6 @@ 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
|
||||||
@@ -19080,8 +18997,6 @@ 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
|
||||||
@@ -20143,8 +20058,6 @@ 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
|
||||||
@@ -20250,8 +20163,6 @@ 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: {}
|
||||||
@@ -20262,27 +20173,8 @@ 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
|
||||||
@@ -20306,6 +20198,8 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
yocto-queue@1.2.1: {}
|
||||||
|
|
||||||
yoctocolors-cjs@2.1.2: {}
|
yoctocolors-cjs@2.1.2: {}
|
||||||
|
|
||||||
zeed-dom@0.15.1:
|
zeed-dom@0.15.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user