mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 07:54:05 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf44914ad |
@@ -16,12 +16,12 @@
|
|||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^8.1.3",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^8.1.3",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@tiptap/extension-character-count": "^2.10.3",
|
"@tiptap/extension-character-count": "^2.10.3",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -356,7 +356,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.",
|
||||||
@@ -389,81 +389,5 @@
|
|||||||
"Failed to share page": "Failed to share page",
|
"Failed to share page": "Failed to share page",
|
||||||
"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"
|
||||||
"Find": "Find",
|
|
||||||
"Not found": "Not found",
|
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
|
||||||
"Next match (Enter)": "Next match (Enter)",
|
|
||||||
"Match case (Alt+C)": "Match case (Alt+C)",
|
|
||||||
"Replace": "Replace",
|
|
||||||
"Close (Escape)": "Close (Escape)",
|
|
||||||
"Replace (Enter)": "Replace (Enter)",
|
|
||||||
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
|
|
||||||
"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,21 +1,9 @@
|
|||||||
|
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Group,
|
|
||||||
Menu,
|
|
||||||
UnstyledButton,
|
|
||||||
Text,
|
|
||||||
useMantineColorScheme,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrightnessFilled,
|
|
||||||
IconBrush,
|
IconBrush,
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
|
||||||
IconDeviceDesktop,
|
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSun,
|
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -31,7 +19,6 @@ export default function TopMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
|
||||||
|
|
||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
@@ -88,7 +75,7 @@ export default function TopMenu() {
|
|||||||
name={user.name}
|
name={user.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ width: 190 }}>
|
<div style={{width: 190}}>
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
{user.name}
|
{user.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -114,44 +101,6 @@ export default function TopMenu() {
|
|||||||
{t("My preferences")}
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Sub>
|
|
||||||
<Menu.Sub.Target>
|
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
|
||||||
{t("Theme")}
|
|
||||||
</Menu.Sub.Item>
|
|
||||||
</Menu.Sub.Target>
|
|
||||||
|
|
||||||
<Menu.Sub.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("light")}
|
|
||||||
leftSection={<IconSun size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Light")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("dark")}
|
|
||||||
leftSection={<IconMoon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Dark")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("auto")}
|
|
||||||
leftSection={<IconDeviceDesktop size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("System settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Sub.Dropdown>
|
|
||||||
</Menu.Sub>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ export default function BillingDetails() {
|
|||||||
{billing.billingScheme === "tiered" && (
|
{billing.billingScheme === "tiered" && (
|
||||||
<>
|
<>
|
||||||
<Text fw={700} fz="lg">
|
<Text fw={700} fz="lg">
|
||||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
${billing.amount / 100} {billing.currency.toUpperCase()}
|
||||||
{billing.interval}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" fz="sm">
|
<Text c="dimmed" fz="sm">
|
||||||
per {billing.interval}
|
per {billing.interval}
|
||||||
@@ -130,7 +129,7 @@ export default function BillingDetails() {
|
|||||||
<>
|
<>
|
||||||
<Text fw={700} fz="lg">
|
<Text fw={700} fz="lg">
|
||||||
{(billing.amount / 100) * billing.quantity}{" "}
|
{(billing.amount / 100) * billing.quantity}{" "}
|
||||||
{billing.currency.toUpperCase()} / {billing.interval}
|
{billing.currency.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" fz="sm">
|
<Text c="dimmed" fz="sm">
|
||||||
${billing.amount / 100} /user/{billing.interval}
|
${billing.amount / 100} /user/{billing.interval}
|
||||||
|
|||||||
@@ -12,18 +12,14 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Flex,
|
Flex,
|
||||||
Switch,
|
Switch,
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
|
|
||||||
export default function BillingPlans() {
|
export default function BillingPlans() {
|
||||||
const { data: plans } = useBillingPlans();
|
const { data: plans } = useBillingPlans();
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const [isAnnual, setIsAnnual] = useState(true);
|
const [isAnnual, setIsAnnual] = useState(true);
|
||||||
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -40,76 +36,49 @@ export default function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: remove by July 30.
|
|
||||||
// Check if workspace was created between June 28 and July 14, 2025
|
|
||||||
const showTieredPricingNotice = (() => {
|
|
||||||
if (!workspace?.createdAt) return false;
|
|
||||||
const createdDate = new Date(workspace.createdAt);
|
|
||||||
const startDate = new Date('2025-06-20');
|
|
||||||
const endDate = new Date('2025-07-14');
|
|
||||||
return createdDate >= startDate && createdDate <= endDate;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
if (!plans || plans.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any plan is tiered
|
const firstPlan = plans[0];
|
||||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
|
||||||
|
|
||||||
// Set initial tier value if not set and we have tiered plans
|
// Set initial tier value if not set
|
||||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
||||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tiered plans, ensure we have a selected tier
|
if (!selectedTierValue) {
|
||||||
if (hasTieredPlans && !selectedTierValue) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectData = firstTieredPlan?.pricingTiers
|
const selectData = firstPlan.pricingTiers
|
||||||
?.filter((tier) => !tier.custom)
|
.filter((tier) => !tier.custom)
|
||||||
.map((tier, index) => {
|
.map((tier, index) => {
|
||||||
const prevMaxUsers =
|
const prevMaxUsers =
|
||||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
||||||
return {
|
return {
|
||||||
value: tier.upTo.toString(),
|
value: tier.upTo.toString(),
|
||||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||||
};
|
};
|
||||||
}) || [];
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Container size="xl" py="xl">
|
||||||
{/* Tiered pricing notice for eligible workspaces */}
|
|
||||||
{showTieredPricingNotice && !hasTieredPlans && (
|
|
||||||
<Alert
|
|
||||||
icon={<IconInfoCircle size={16} />}
|
|
||||||
title="Want the old tiered pricing?"
|
|
||||||
color="blue"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
Contact support to switch back to our tiered pricing model.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
{/* Controls Section */}
|
||||||
<Stack gap="xl" mb="md">
|
<Stack gap="xl" mb="md">
|
||||||
{/* Team Size and Billing Controls */}
|
{/* Team Size and Billing Controls */}
|
||||||
<Group justify="center" align="center" gap="sm">
|
<Group justify="center" align="center" gap="sm">
|
||||||
{hasTieredPlans && (
|
<Select
|
||||||
<Select
|
label="Team size"
|
||||||
label="Team size"
|
description="Select the number of users"
|
||||||
description="Select the number of users"
|
value={selectedTierValue}
|
||||||
value={selectedTierValue}
|
onChange={setSelectedTierValue}
|
||||||
onChange={setSelectedTierValue}
|
data={selectData}
|
||||||
data={selectData}
|
w={250}
|
||||||
w={250}
|
size="md"
|
||||||
size="md"
|
allowDeselect={false}
|
||||||
allowDeselect={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="center" align="start">
|
<Group justify="center" align="start">
|
||||||
<Flex justify="center" gap="md" align="center">
|
<Flex justify="center" gap="md" align="center">
|
||||||
@@ -133,29 +102,17 @@ export default function BillingPlans() {
|
|||||||
{/* Plans Grid */}
|
{/* Plans Grid */}
|
||||||
<Group justify="center" gap="lg" align="stretch">
|
<Group justify="center" gap="lg" align="stretch">
|
||||||
{plans.map((plan, index) => {
|
{plans.map((plan, index) => {
|
||||||
let price;
|
const tieredPlan = plan;
|
||||||
let displayPrice;
|
const planSelectedTier =
|
||||||
|
tieredPlan.pricingTiers.find(
|
||||||
|
(tier) => tier.upTo.toString() === selectedTierValue,
|
||||||
|
) || tieredPlan.pricingTiers[0];
|
||||||
|
|
||||||
|
const price = isAnnual
|
||||||
|
? planSelectedTier.yearly
|
||||||
|
: planSelectedTier.monthly;
|
||||||
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
|
||||||
|
|
||||||
if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) {
|
|
||||||
// Tiered billing logic
|
|
||||||
const planSelectedTier =
|
|
||||||
plan.pricingTiers.find(
|
|
||||||
(tier) => tier.upTo.toString() === selectedTierValue,
|
|
||||||
) || plan.pricingTiers[0];
|
|
||||||
|
|
||||||
price = isAnnual
|
|
||||||
? planSelectedTier.yearly
|
|
||||||
: planSelectedTier.monthly;
|
|
||||||
displayPrice = isAnnual ? (price / 12).toFixed(0) : price;
|
|
||||||
} else {
|
|
||||||
// Per-unit billing logic
|
|
||||||
const monthlyPrice = parseFloat(plan.price?.monthly || '0');
|
|
||||||
const yearlyPrice = parseFloat(plan.price?.yearly || '0');
|
|
||||||
price = isAnnual ? yearlyPrice : monthlyPrice;
|
|
||||||
displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={plan.name}
|
key={plan.name}
|
||||||
@@ -186,27 +143,25 @@ export default function BillingPlans() {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group align="baseline" gap="xs">
|
<Group align="baseline" gap="xs">
|
||||||
<Title order={1} size="h1">
|
<Title order={1} size="h1">
|
||||||
${displayPrice}
|
${isAnnual ? (price / 12).toFixed(0) : price}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="lg" c="dimmed">
|
<Text size="lg" c="dimmed">
|
||||||
{plan.billingScheme === 'per_unit'
|
per {isAnnual ? "month" : "month"}
|
||||||
? `per user/month`
|
|
||||||
: `per month`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" c="dimmed">
|
{isAnnual && (
|
||||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
Billed annually
|
||||||
{plan.billingScheme === 'tiered' && plan.pricingTiers && (
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<Text size="md" fw={500}>
|
||||||
|
For {planSelectedTier.upTo} users
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||||
Subscribe
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface IBillingPlan {
|
|||||||
};
|
};
|
||||||
features: string[];
|
features: string[];
|
||||||
billingScheme: string | null;
|
billingScheme: string | null;
|
||||||
pricingTiers?: PricingTier[];
|
pricingTiers: PricingTier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PricingTier {
|
interface PricingTier {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,12 +12,6 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
-ms-word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type SearchAndReplaceAtomType = {
|
|
||||||
isOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
|
|
||||||
isOpen: false,
|
|
||||||
});
|
|
||||||
-312
@@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
Flex,
|
|
||||||
Input,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconArrowNarrowDown,
|
|
||||||
IconArrowNarrowUp,
|
|
||||||
IconLetterCase,
|
|
||||||
IconReplace,
|
|
||||||
IconSearch,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import classes from "./search-replace.module.css";
|
|
||||||
|
|
||||||
interface PageFindDialogDialogProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [replaceText, setReplaceText] = useState("");
|
|
||||||
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const [replaceButton, replaceButtonToggle] = useToggle([
|
|
||||||
{ isReplaceShow: false, color: "gray" },
|
|
||||||
{ isReplaceShow: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [caseSensitive, caseSensitiveToggle] = useToggle([
|
|
||||||
{ isCaseSensitive: false, color: "gray" },
|
|
||||||
{ isCaseSensitive: true, color: "blue" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setReplaceText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setSearchText("");
|
|
||||||
setReplaceText("");
|
|
||||||
setPageFindState({ isOpen: false });
|
|
||||||
// Reset replace button state when closing
|
|
||||||
if (replaceButton.isReplaceShow) {
|
|
||||||
replaceButtonToggle();
|
|
||||||
}
|
|
||||||
// Clear search term in editor
|
|
||||||
if (editor) {
|
|
||||||
editor.commands.setSearchTerm("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToSelection = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
const position: Range = results[resultIndex];
|
|
||||||
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
editor.commands.setTextSelection(position);
|
|
||||||
|
|
||||||
const element = document.querySelector(".search-result-current");
|
|
||||||
if (element)
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
|
|
||||||
editor.commands.setTextSelection(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
editor.commands.nextSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
editor.commands.previousSearchResult();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replace = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replace();
|
|
||||||
goToSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceAll = () => {
|
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
|
||||||
editor.commands.replaceAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setSearchTerm(searchText);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
editor.commands.selectCurrentItem();
|
|
||||||
}, [searchText]);
|
|
||||||
|
|
||||||
const handleOpenEvent = (e) => {
|
|
||||||
setPageFindState({ isOpen: true });
|
|
||||||
const selectedText = editor.state.doc.textBetween(
|
|
||||||
editor.state.selection.from,
|
|
||||||
editor.state.selection.to,
|
|
||||||
);
|
|
||||||
if (selectedText !== "") {
|
|
||||||
setSearchText(selectedText);
|
|
||||||
}
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEvent = (e) => {
|
|
||||||
closeDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
!pageFindState.isOpen && closeDialog();
|
|
||||||
|
|
||||||
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
|
|
||||||
document.removeEventListener(
|
|
||||||
"closeFindDialogFromEditor",
|
|
||||||
handleCloseEvent,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [pageFindState.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
|
||||||
editor.commands.resetIndex();
|
|
||||||
goToSelection();
|
|
||||||
}, [caseSensitive]);
|
|
||||||
|
|
||||||
const resultsCount = useMemo(
|
|
||||||
() =>
|
|
||||||
searchText.trim() === ""
|
|
||||||
? ""
|
|
||||||
: editor?.storage?.searchAndReplace?.results.length > 0
|
|
||||||
? editor?.storage?.searchAndReplace?.resultIndex +
|
|
||||||
1 +
|
|
||||||
"/" +
|
|
||||||
editor?.storage?.searchAndReplace?.results.length
|
|
||||||
: t("Not found"),
|
|
||||||
[
|
|
||||||
searchText,
|
|
||||||
editor?.storage?.searchAndReplace?.resultIndex,
|
|
||||||
editor?.storage?.searchAndReplace?.results.length,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
closeDialog();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
className={classes.findDialog}
|
|
||||||
opened={pageFindState.isOpen}
|
|
||||||
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={"auto"}
|
|
||||||
position={{ top: 90, right: 50 }}
|
|
||||||
withBorder
|
|
||||||
transitionProps={{ transition: "slide-down" }}
|
|
||||||
>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={t("Find")}
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" ta="right">
|
|
||||||
{resultsCount}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
rightSectionWidth="70"
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={220}
|
|
||||||
onChange={searchInputEvent}
|
|
||||||
value={searchText}
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", next],
|
|
||||||
["shift+Enter", previous],
|
|
||||||
["alt+C", caseSensitiveToggle],
|
|
||||||
//@ts-ignore
|
|
||||||
...(editable ? [["alt+R", replaceButtonToggle]] : []),
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Previous match (Shift+Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={previous}>
|
|
||||||
<IconArrowNarrowUp
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Next match (Enter)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={next}>
|
|
||||||
<IconArrowNarrowDown
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Match case (Alt+C)")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={caseSensitive.color}
|
|
||||||
onClick={() => caseSensitiveToggle()}
|
|
||||||
>
|
|
||||||
<IconLetterCase
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
{editable && (
|
|
||||||
<Tooltip label={t("Replace")}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color={replaceButton.color}
|
|
||||||
onClick={() => replaceButtonToggle()}
|
|
||||||
>
|
|
||||||
<IconReplace
|
|
||||||
style={{ width: "70%", height: "70%" }}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label={t("Close (Escape)")}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
|
|
||||||
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
{replaceButton.isReplaceShow && editable && (
|
|
||||||
<Flex align="center" gap="xs">
|
|
||||||
<Input
|
|
||||||
placeholder={t("Replace")}
|
|
||||||
leftSection={<IconReplace size={16} />}
|
|
||||||
rightSection={<div></div>}
|
|
||||||
rightSectionPointerEvents="all"
|
|
||||||
size="xs"
|
|
||||||
w={180}
|
|
||||||
autoFocus
|
|
||||||
onChange={replaceInputEvent}
|
|
||||||
value={replaceText}
|
|
||||||
onKeyDown={getHotkeyHandler([
|
|
||||||
["Enter", replace],
|
|
||||||
["ctrl+alt+Enter", replaceAll],
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<ActionIcon.Group>
|
|
||||||
<Tooltip label={t("Replace (Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replace}
|
|
||||||
>
|
|
||||||
{t("Replace")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={replaceAll}
|
|
||||||
>
|
|
||||||
{t("Replace all")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SearchAndReplaceDialog;
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
.findDialog{
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.findDialog div[data-position="right"].mantine-Input-section {
|
|
||||||
justify-content: right;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
ColorSwatch,
|
|
||||||
Popover,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export interface TableColorItem {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableBackgroundColorProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABLE_COLORS: TableColorItem[] = [
|
|
||||||
{ name: "Default", color: "" },
|
|
||||||
{ name: "Blue", color: "#b4d5ff" },
|
|
||||||
{ name: "Green", color: "#acf5d2" },
|
|
||||||
{ name: "Yellow", color: "#fef1b4" },
|
|
||||||
{ name: "Red", color: "#ffbead" },
|
|
||||||
{ name: "Pink", color: "#ffc7fe" },
|
|
||||||
{ name: "Gray", color: "#eaecef" },
|
|
||||||
{ name: "Purple", color: "#c1b7f2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|
||||||
editor,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const setTableCellBackground = (color: string, colorName: string) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.updateAttributes("tableCell", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.updateAttributes("tableHeader", {
|
|
||||||
backgroundColor: color || null,
|
|
||||||
backgroundColorName: color ? colorName : null
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
setOpened(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get current cell's background color
|
|
||||||
const getCurrentColor = () => {
|
|
||||||
if (editor.isActive("tableCell")) {
|
|
||||||
const attrs = editor.getAttributes("tableCell");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
if (editor.isActive("tableHeader")) {
|
|
||||||
const attrs = editor.getAttributes("tableHeader");
|
|
||||||
return attrs.backgroundColor || "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentColor = getCurrentColor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
width={200}
|
|
||||||
position="bottom"
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: "pop" }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Background color")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<IconPalette size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Background color")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(4, 1fr)",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TABLE_COLORS.map((item, index) => (
|
|
||||||
<UnstyledButton
|
|
||||||
key={index}
|
|
||||||
onClick={() => setTableCellBackground(item.color, item.name)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
}}
|
|
||||||
title={t(item.name)}
|
|
||||||
>
|
|
||||||
<ColorSwatch
|
|
||||||
color={item.color || "#ffffff"}
|
|
||||||
size={24}
|
|
||||||
style={{
|
|
||||||
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentColor === item.color && (
|
|
||||||
<IconCheck
|
|
||||||
size={18}
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
item.color === "" || item.color.startsWith("#F")
|
|
||||||
? "#000000"
|
|
||||||
: "#ffffff",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ColorSwatch>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,11 +12,8 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowRemove,
|
IconRowRemove,
|
||||||
IconSquareToggle,
|
IconSquareToggle,
|
||||||
IconTableRow,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -48,10 +45,6 @@ export const TableCellMenu = React.memo(
|
|||||||
editor.chain().focus().deleteRow().run();
|
editor.chain().focus().deleteRow().run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderCell = useCallback(() => {
|
|
||||||
editor.chain().focus().toggleHeaderCell().run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -67,9 +60,6 @@ export const TableCellMenu = React.memo(
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
|
||||||
<TableTextAlignment editor={editor} />
|
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@@ -113,17 +103,6 @@ export const TableCellMenu = React.memo(
|
|||||||
<IconRowRemove size={18} />
|
<IconRowRemove size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={toggleHeaderCell}
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Toggle header cell")}
|
|
||||||
>
|
|
||||||
<IconTableRow size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import {
|
|
||||||
IconAlignCenter,
|
|
||||||
IconAlignLeft,
|
|
||||||
IconAlignRight,
|
|
||||||
IconCheck,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
rem,
|
|
||||||
ScrollArea,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface TableTextAlignmentProps {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlignmentItem {
|
|
||||||
name: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
command: () => void;
|
|
||||||
isActive: () => boolean;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, setOpened] = React.useState(false);
|
|
||||||
|
|
||||||
const items: AlignmentItem[] = [
|
|
||||||
{
|
|
||||||
name: "Align left",
|
|
||||||
value: "left",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "left" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("left").run(),
|
|
||||||
icon: IconAlignLeft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align center",
|
|
||||||
value: "center",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "center" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("center").run(),
|
|
||||||
icon: IconAlignCenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Align right",
|
|
||||||
value: "right",
|
|
||||||
isActive: () => editor.isActive({ textAlign: "right" }),
|
|
||||||
command: () => editor.chain().focus().setTextAlign("right").run(),
|
|
||||||
icon: IconAlignRight,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeItem = items.find((item) => item.isActive()) || items[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
opened={opened}
|
|
||||||
onChange={setOpened}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
transitionProps={{ transition: 'pop' }}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
aria-label={t("Text alignment")}
|
|
||||||
onClick={() => setOpened(!opened)}
|
|
||||||
>
|
|
||||||
<activeItem.icon size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover.Target>
|
|
||||||
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<ScrollArea.Autosize type="scroll" mah={300}>
|
|
||||||
<Button.Group orientation="vertical">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<item.icon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
item.isActive() && <IconCheck size={16} />
|
|
||||||
}
|
|
||||||
justify="left"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setOpened(false);
|
|
||||||
}}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
>
|
|
||||||
{t(item.name)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Button.Group>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -11,6 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
|
|||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import Table from "@tiptap/extension-table";
|
import Table from "@tiptap/extension-table";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
MathInline,
|
MathInline,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
|
||||||
Mention,
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@@ -218,22 +217,6 @@ export const mainExtensions = [
|
|||||||
CharacterCount.configure({
|
CharacterCount.configure({
|
||||||
wordCounter: (text) => countWords(text),
|
wordCounter: (text) => countWords(text),
|
||||||
}),
|
}),
|
||||||
SearchAndReplace.extend({
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
'Mod-f': () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
'Escape': () => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).configure(),
|
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +44,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
|||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
@@ -126,15 +130,7 @@ export default function PageEditor({
|
|||||||
const now = Date.now().valueOf() / 1000;
|
const now = Date.now().valueOf() / 1000;
|
||||||
const isTokenExpired = now >= payload.exp;
|
const isTokenExpired = now >= payload.exp;
|
||||||
if (isTokenExpired) {
|
if (isTokenExpired) {
|
||||||
refetchCollabToken().then((result) => {
|
refetchCollabToken();
|
||||||
if (result.data?.token) {
|
|
||||||
remote.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
remote.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
@@ -160,21 +156,6 @@ export default function PageEditor({
|
|||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle token updates by reconnecting with new token
|
|
||||||
if (providersRef.current?.remote && collabQuery?.token) {
|
|
||||||
const currentToken = providersRef.current.remote.configuration.token;
|
|
||||||
if (currentToken !== collabQuery.token) {
|
|
||||||
// Token has changed, need to reconnect with new token
|
|
||||||
providersRef.current.remote.disconnect();
|
|
||||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
|
||||||
providersRef.current.remote.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersReady || !providersRef.current) return;
|
if (!providersReady || !providersRef.current) return;
|
||||||
@@ -217,10 +198,6 @@ export default function PageEditor({
|
|||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
|
||||||
event.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
@@ -373,11 +350,6 @@ export default function PageEditor({
|
|||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
{editor && (
|
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editor && editor.isEditable && (
|
{editor && editor.isEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
|
|||||||
@@ -71,12 +71,4 @@
|
|||||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||||
transform: rotateZ(90deg);
|
transform: rotateZ(90deg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
|
|
||||||
transform: rotateZ(90deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.search-result{
|
|
||||||
background: #ffff65;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-current{
|
|
||||||
background: #ffc266 !important;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,5 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
@import "./find.css";
|
|
||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
/* Ordered list type cycling based on nesting depth */
|
|
||||||
ol,
|
|
||||||
ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol ol ol ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol ol,
|
|
||||||
ol ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol ol ol ol ol {
|
|
||||||
list-style-type: lower-alpha;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol ol ol,
|
|
||||||
ol ol ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol ol ol,
|
|
||||||
ol ol ol ol ol ol ol ol ol ol ol ol {
|
|
||||||
list-style-type: lower-roman;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
list-style-position: outside;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nested list spacing */
|
|
||||||
ol ol,
|
|
||||||
ol ul,
|
|
||||||
ul ol {
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
margin-bottom: 0.1rem;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
& table {
|
& table {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-width: 700px !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +38,8 @@
|
|||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: light-dark(
|
background-color: light-dark(
|
||||||
var(--mantine-color-gray-1),
|
var(--mantine-color-gray-1),
|
||||||
var(--mantine-color-dark-5)
|
var(--mantine-color-dark-5)
|
||||||
);
|
);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -67,54 +66,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table cell background colors with dark mode support */
|
|
||||||
.ProseMirror {
|
|
||||||
table {
|
|
||||||
@mixin dark {
|
|
||||||
/* Blue */
|
|
||||||
td[data-background-color="#b4d5ff"],
|
|
||||||
th[data-background-color="#b4d5ff"] {
|
|
||||||
background-color: #1a3a5c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Green */
|
|
||||||
td[data-background-color="#acf5d2"],
|
|
||||||
th[data-background-color="#acf5d2"] {
|
|
||||||
background-color: #1a4d3a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Yellow */
|
|
||||||
td[data-background-color="#fef1b4"],
|
|
||||||
th[data-background-color="#fef1b4"] {
|
|
||||||
background-color: #7c5014 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Red */
|
|
||||||
td[data-background-color="#ffbead"],
|
|
||||||
th[data-background-color="#ffbead"] {
|
|
||||||
background-color: #5c2a23 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pink */
|
|
||||||
td[data-background-color="#ffc7fe"],
|
|
||||||
th[data-background-color="#ffc7fe"] {
|
|
||||||
background-color: #4d2a4d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gray */
|
|
||||||
td[data-background-color="#eaecef"],
|
|
||||||
th[data-background-color="#eaecef"] {
|
|
||||||
background-color: #2a2e33 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Purple */
|
|
||||||
td[data-background-color="#c1b7f2"],
|
|
||||||
th[data-background-color="#c1b7f2"] {
|
|
||||||
background-color: #3a2f5c !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
useUpdateTitlePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { History } from "@tiptap/extension-history";
|
||||||
@@ -43,8 +40,7 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||||
useUpdateTitlePageMutation();
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
@@ -112,12 +108,7 @@ export function TitleEditor({
|
|||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: page.id,
|
id: page.id,
|
||||||
payload: {
|
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||||
title: page.title,
|
|
||||||
slugId: page.slugId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
icon: page.icon,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
if (page.title !== titleEditor.getText()) return;
|
||||||
@@ -161,19 +152,13 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}, [userPageEditMode, titleEditor, editable]);
|
}, [userPageEditMode, titleEditor, editable]);
|
||||||
|
|
||||||
const openSearchDialog = () => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleTitleKeyDown(event: any) {
|
function handleTitleKeyDown(event: any) {
|
||||||
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
||||||
|
|
||||||
// Prevent focus shift when IME composition is active
|
// Prevent focus shift when IME composition is active
|
||||||
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
||||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||||
return;
|
|
||||||
|
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
const { $head } = titleEditor.state.selection;
|
const { $head } = titleEditor.state.selection;
|
||||||
|
|
||||||
@@ -187,16 +172,5 @@ export function TitleEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||||
<EditorContent
|
|
||||||
editor={titleEditor}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
// First handle the search hotkey
|
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
||||||
|
|
||||||
// Then handle other key events
|
|
||||||
handleTitleKeyDown(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -17,12 +16,7 @@ import React from "react";
|
|||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import {
|
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||||
getHotkeyHandler,
|
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
|
||||||
useHotkeys,
|
|
||||||
} from "@mantine/hooks";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -38,7 +32,6 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
@@ -53,26 +46,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
useHotkeys(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
"mod+F",
|
|
||||||
() => {
|
|
||||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Escape",
|
|
||||||
() => {
|
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
|||||||
{option["type"] === "group" && <IconGroupCircle />}
|
{option["type"] === "group" && <IconGroupCircle />}
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||||
{option["type"] === "user" && option["email"] && (
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -50,7 +47,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
const userItems = suggestion?.users.map((user: IUser) => ({
|
const userItems = suggestion?.users.map((user: IUser) => ({
|
||||||
value: `user-${user.id}`,
|
value: `user-${user.id}`,
|
||||||
label: user.name,
|
label: user.name,
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
type: "user",
|
type: "user",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
api_host: getPostHogHost(),
|
api_host: getPostHogHost(),
|
||||||
defaults: "2025-05-24",
|
defaults: "2025-05-24",
|
||||||
disable_session_recording: true,
|
disable_session_recording: true,
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,15 @@
|
|||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
|
"openai": "^5.8.2",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.4.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",
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"redis": "^5.5.6",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sanitize-filename-ts": "^1.0.2",
|
"sanitize-filename-ts": "^1.0.2",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TextStyle } from '@tiptap/extension-text-style';
|
|||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
import Table from '@tiptap/extension-table';
|
import Table from '@tiptap/extension-table';
|
||||||
|
import TableHeader from '@tiptap/extension-table-header';
|
||||||
import {
|
import {
|
||||||
Callout,
|
Callout,
|
||||||
Comment,
|
Comment,
|
||||||
@@ -21,7 +22,6 @@ import {
|
|||||||
LinkExtension,
|
LinkExtension,
|
||||||
MathBlock,
|
MathBlock,
|
||||||
MathInline,
|
MathInline,
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
@@ -46,11 +46,9 @@ export const tiptapExtensions = [
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
}),
|
}),
|
||||||
Comment,
|
Comment,
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({
|
TaskItem,
|
||||||
nested: true,
|
|
||||||
}),
|
|
||||||
Underline,
|
Underline,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Superscript,
|
Superscript,
|
||||||
@@ -66,9 +64,9 @@ export const tiptapExtensions = [
|
|||||||
DetailsContent,
|
DetailsContent,
|
||||||
DetailsSummary,
|
DetailsSummary,
|
||||||
Table,
|
Table,
|
||||||
TableCell,
|
|
||||||
TableRow,
|
|
||||||
TableHeader,
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
Youtube,
|
Youtube,
|
||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
@@ -78,7 +76,7 @@ export const tiptapExtensions = [
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ export class AuthenticationExtension implements Extension {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.deactivatedAt || user.deletedAt) {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
this.logger.warn(`Page not found: ${pageId}`);
|
this.logger.warn(`Page not found: ${pageId}`);
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
|
textContent: textContent,
|
||||||
lastUpdatedById: context.user.id,
|
lastUpdatedById: context.user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
# AI Search Integration Guide
|
||||||
|
|
||||||
|
This guide shows how to integrate the AI Search module with your existing page operations for automatic indexing.
|
||||||
|
|
||||||
|
## Event-Based Auto-Indexing
|
||||||
|
|
||||||
|
The AI Search module uses event listeners to automatically index pages when they are created, updated, or deleted.
|
||||||
|
|
||||||
|
### Emitting Events in Page Service
|
||||||
|
|
||||||
|
Update your existing `PageService` to emit events for AI search indexing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your page.service.ts
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageService {
|
||||||
|
constructor(
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
// ... other dependencies
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createPage(createPageDto: CreatePageDto): Promise<Page> {
|
||||||
|
// Your existing page creation logic
|
||||||
|
const page = await this.pageRepo.create(createPageDto);
|
||||||
|
|
||||||
|
// Emit event for AI search indexing
|
||||||
|
this.eventEmitter.emit('page.created', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
title: page.title,
|
||||||
|
textContent: page.textContent,
|
||||||
|
operation: 'create'
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePage(pageId: string, updatePageDto: UpdatePageDto): Promise<Page> {
|
||||||
|
// Your existing page update logic
|
||||||
|
const page = await this.pageRepo.update(pageId, updatePageDto);
|
||||||
|
|
||||||
|
// Emit event for AI search reindexing
|
||||||
|
this.eventEmitter.emit('page.updated', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
title: page.title,
|
||||||
|
textContent: page.textContent,
|
||||||
|
operation: 'update'
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePage(pageId: string): Promise<void> {
|
||||||
|
// Get page info before deletion
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
|
||||||
|
// Your existing page deletion logic
|
||||||
|
await this.pageRepo.delete(pageId);
|
||||||
|
|
||||||
|
// Emit event for AI search cleanup
|
||||||
|
if (page) {
|
||||||
|
this.eventEmitter.emit('page.deleted', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
operation: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding EventEmitter to Page Module
|
||||||
|
|
||||||
|
Make sure your `PageModule` imports the `EventEmitterModule`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your page.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { PageService } from './services/page.service';
|
||||||
|
import { PageController } from './page.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
EventEmitterModule, // Add this if not already present
|
||||||
|
],
|
||||||
|
controllers: [PageController],
|
||||||
|
providers: [PageService],
|
||||||
|
exports: [PageService],
|
||||||
|
})
|
||||||
|
export class PageModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Operations
|
||||||
|
|
||||||
|
For bulk operations, you can emit multiple events or use a bulk reindex:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async bulkUpdatePages(updates: BulkUpdateDto[]): Promise<Page[]> {
|
||||||
|
const updatedPages = await this.pageRepo.bulkUpdate(updates);
|
||||||
|
|
||||||
|
// Option 1: Emit individual events
|
||||||
|
for (const page of updatedPages) {
|
||||||
|
this.eventEmitter.emit('page.updated', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
title: page.title,
|
||||||
|
textContent: page.textContent,
|
||||||
|
operation: 'update'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 2: Use bulk reindex (more efficient for large batches)
|
||||||
|
// const pageIds = updatedPages.map(p => p.id);
|
||||||
|
// this.eventEmitter.emit('ai-search.bulk-reindex', {
|
||||||
|
// pageIds,
|
||||||
|
// workspaceId: updatedPages[0]?.workspaceId
|
||||||
|
// });
|
||||||
|
|
||||||
|
return updatedPages;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Integration
|
||||||
|
|
||||||
|
If you prefer manual control over indexing, you can directly use the AI search services:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your page.service.ts
|
||||||
|
import { AiSearchService } from '../ai-search/services/ai-search.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageService {
|
||||||
|
constructor(
|
||||||
|
private readonly aiSearchService: AiSearchService,
|
||||||
|
// ... other dependencies
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createPageWithSearch(createPageDto: CreatePageDto): Promise<Page> {
|
||||||
|
const page = await this.pageRepo.create(createPageDto);
|
||||||
|
|
||||||
|
// Manually trigger indexing
|
||||||
|
try {
|
||||||
|
await this.aiSearchService.reindexPages({
|
||||||
|
pageIds: [page.id],
|
||||||
|
workspaceId: page.workspaceId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the page creation
|
||||||
|
console.error('Failed to index page for AI search:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Adding AI Search to Client
|
||||||
|
|
||||||
|
Create AI search service on the client side:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/client/src/features/ai-search/services/ai-search-service.ts
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface AiSearchParams {
|
||||||
|
query: string;
|
||||||
|
spaceId?: string;
|
||||||
|
limit?: number;
|
||||||
|
similarity_threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
similarity_score: number;
|
||||||
|
highlight: string;
|
||||||
|
space?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function semanticSearch(params: AiSearchParams): Promise<AiSearchResult[]> {
|
||||||
|
const response = await api.post<AiSearchResult[]>("/ai-search/semantic", params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hybridSearch(params: AiSearchParams): Promise<AiSearchResult[]> {
|
||||||
|
const response = await api.post<AiSearchResult[]>("/ai-search/hybrid", params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Query Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/client/src/features/ai-search/queries/ai-search-query.ts
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { semanticSearch, hybridSearch, AiSearchParams } from "../services/ai-search-service";
|
||||||
|
|
||||||
|
export function useAiSemanticSearchQuery(params: AiSearchParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ai-search", "semantic", params],
|
||||||
|
queryFn: () => semanticSearch(params),
|
||||||
|
enabled: !!params.query && params.query.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiHybridSearchQuery(params: AiSearchParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ai-search", "hybrid", params],
|
||||||
|
queryFn: () => hybridSearch(params),
|
||||||
|
enabled: !!params.query && params.query.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Search Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/client/src/features/ai-search/components/ai-search-spotlight.tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
|
import { IconSearch, IconBrain } from "@tabler/icons-react";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useAiSemanticSearchQuery } from "../queries/ai-search-query";
|
||||||
|
|
||||||
|
export function AiSearchSpotlight() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
|
||||||
|
const { data: results, isLoading } = useAiSemanticSearchQuery({
|
||||||
|
query: debouncedQuery,
|
||||||
|
limit: 10,
|
||||||
|
similarity_threshold: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spotlight.Root query={query} onQueryChange={setQuery}>
|
||||||
|
<Spotlight.Search
|
||||||
|
placeholder="AI-powered semantic search..."
|
||||||
|
leftSection={<IconBrain size={20} />}
|
||||||
|
/>
|
||||||
|
<Spotlight.ActionsList>
|
||||||
|
{isLoading && <Spotlight.Empty>Searching...</Spotlight.Empty>}
|
||||||
|
|
||||||
|
{!isLoading && (!results || results.length === 0) && (
|
||||||
|
<Spotlight.Empty>No results found</Spotlight.Empty>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results?.map((result) => (
|
||||||
|
<Spotlight.Action key={result.id}>
|
||||||
|
<div>
|
||||||
|
<div>{result.title}</div>
|
||||||
|
<div style={{ fontSize: '0.8em', opacity: 0.7 }}>
|
||||||
|
Similarity: {(result.similarity_score * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
{result.highlight && (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '0.8em', opacity: 0.6 }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.highlight }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Spotlight.Action>
|
||||||
|
))}
|
||||||
|
</Spotlight.ActionsList>
|
||||||
|
</Spotlight.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Mode Toggle
|
||||||
|
|
||||||
|
Create a component that allows users to choose between traditional and AI search:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/client/src/features/search/components/search-mode-toggle.tsx
|
||||||
|
import { SegmentedControl } from "@mantine/core";
|
||||||
|
import { IconSearch, IconBrain } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface SearchModeToggleProps {
|
||||||
|
value: 'traditional' | 'ai' | 'hybrid';
|
||||||
|
onChange: (value: 'traditional' | 'ai' | 'hybrid') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModeToggle({ value, onChange }: SearchModeToggleProps) {
|
||||||
|
return (
|
||||||
|
<SegmentedControl
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: 'Traditional',
|
||||||
|
value: 'traditional',
|
||||||
|
icon: IconSearch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI Semantic',
|
||||||
|
value: 'ai',
|
||||||
|
icon: IconBrain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hybrid',
|
||||||
|
value: 'hybrid',
|
||||||
|
icon: IconBrain,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Async Indexing
|
||||||
|
|
||||||
|
For better performance, consider making indexing asynchronous:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use a queue for heavy indexing operations
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageService {
|
||||||
|
constructor(
|
||||||
|
@InjectQueue('ai-search') private aiSearchQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createPage(createPageDto: CreatePageDto): Promise<Page> {
|
||||||
|
const page = await this.pageRepo.create(createPageDto);
|
||||||
|
|
||||||
|
// Queue indexing job instead of doing it synchronously
|
||||||
|
await this.aiSearchQueue.add('index-page', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
title: page.title,
|
||||||
|
textContent: page.textContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Indexing
|
||||||
|
|
||||||
|
Only index pages when AI search is configured:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createPage(createPageDto: CreatePageDto): Promise<Page> {
|
||||||
|
const page = await this.pageRepo.create(createPageDto);
|
||||||
|
|
||||||
|
// Check if AI search is enabled before emitting events
|
||||||
|
if (this.embeddingService.isConfigured()) {
|
||||||
|
this.eventEmitter.emit('page.created', {
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
title: page.title,
|
||||||
|
textContent: page.textContent,
|
||||||
|
operation: 'create'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Integration
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// page.service.spec.ts
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
describe('PageService', () => {
|
||||||
|
let service: PageService;
|
||||||
|
let eventEmitter: EventEmitter2;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
PageService,
|
||||||
|
{
|
||||||
|
provide: EventEmitter2,
|
||||||
|
useValue: {
|
||||||
|
emit: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PageService>(PageService);
|
||||||
|
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit page.created event when creating page', async () => {
|
||||||
|
const createPageDto = { title: 'Test Page', content: 'Test content' };
|
||||||
|
await service.createPage(createPageDto);
|
||||||
|
|
||||||
|
expect(eventEmitter.emit).toHaveBeenCalledWith('page.created',
|
||||||
|
expect.objectContaining({
|
||||||
|
operation: 'create',
|
||||||
|
title: 'Test Page',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Analytics
|
||||||
|
|
||||||
|
### Track Search Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add search analytics
|
||||||
|
this.eventEmitter.emit('ai-search.query', {
|
||||||
|
query: searchParams.query,
|
||||||
|
userId: opts.userId,
|
||||||
|
workspaceId: opts.workspaceId,
|
||||||
|
searchType: 'semantic',
|
||||||
|
resultCount: results.length,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This integration approach ensures that your AI search stays in sync with your content while maintaining good performance and error handling.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# AI Search Module
|
||||||
|
|
||||||
|
A comprehensive AI-powered semantic search module for Docmost that integrates with Redis vector database using the official **node-redis** client to provide intelligent search capabilities following Redis vector search specifications.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Semantic Search**: Find content based on meaning rather than exact keywords using vector embeddings
|
||||||
|
- **Hybrid Search**: Combines both semantic and traditional full-text search with configurable weights
|
||||||
|
- **Redis Vector Database**: Uses Redis with RediSearch module for efficient vector operations via node-redis client
|
||||||
|
- **HNSW Indexing**: Hierarchical Navigable Small World algorithm for fast approximate nearest neighbor search
|
||||||
|
- **Auto-indexing**: Automatically indexes pages when they are created or updated
|
||||||
|
- **OpenAI-Compatible**: Supports OpenAI and OpenAI-compatible embedding providers
|
||||||
|
- **Batch Operations**: Efficient batch processing for large-scale indexing
|
||||||
|
- **Permission-aware**: Respects user permissions and workspace access
|
||||||
|
- **COSINE Distance**: Uses cosine distance metric for semantic similarity
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-search/
|
||||||
|
├── ai-search.controller.ts # REST API endpoints
|
||||||
|
├── ai-search.module.ts # Module configuration
|
||||||
|
├── dto/
|
||||||
|
│ └── semantic-search.dto.ts # Request/response DTOs
|
||||||
|
├── services/
|
||||||
|
│ ├── ai-search.service.ts # Main search logic
|
||||||
|
│ ├── embedding.service.ts # Text embedding generation
|
||||||
|
│ ├── redis-vector.service.ts # Redis vector operations (node-redis)
|
||||||
|
│ └── vector.service.ts # Vector math utilities
|
||||||
|
├── listeners/
|
||||||
|
│ └── page-update.listener.ts # Auto-indexing on page changes
|
||||||
|
├── constants.ts # Configuration constants
|
||||||
|
├── README.md # This file
|
||||||
|
├── SETUP.md # Setup guide
|
||||||
|
└── INTEGRATION.md # Integration examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add these environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Redis Vector Database (using node-redis client)
|
||||||
|
REDIS_VECTOR_HOST=localhost
|
||||||
|
REDIS_VECTOR_PORT=6379
|
||||||
|
REDIS_VECTOR_PASSWORD=your_redis_password
|
||||||
|
REDIS_VECTOR_DB=0
|
||||||
|
REDIS_VECTOR_INDEX=docmost_pages
|
||||||
|
|
||||||
|
# AI Embedding Configuration (OpenAI-compatible)
|
||||||
|
AI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
AI_EMBEDDING_DIMENSIONS=1536
|
||||||
|
AI_EMBEDDING_BASE_URL=https://api.openai.com/v1/embeddings # Optional: for custom providers
|
||||||
|
|
||||||
|
# OpenAI API Key (or compatible provider key)
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Redis Vector Search Implementation
|
||||||
|
|
||||||
|
This implementation follows the official [Redis Vector Search specifications](https://redis.io/docs/latest/develop/interact/search-and-query/query/vector-search/) and uses the [node-redis client](https://redis.io/docs/latest/develop/clients/nodejs/vecsearch/) for proper integration.
|
||||||
|
|
||||||
|
### Key Features:
|
||||||
|
- **HNSW Algorithm**: Uses Hierarchical Navigable Small World for fast vector indexing
|
||||||
|
- **COSINE Distance**: Semantic similarity using cosine distance metric
|
||||||
|
- **KNN Queries**: K-nearest neighbors search with `*=>[KNN k @embedding $vector AS distance]`
|
||||||
|
- **Hash Storage**: Vectors stored as Redis hash documents with binary embedding data
|
||||||
|
- **node-redis Client**: Official Redis client with full vector search support
|
||||||
|
|
||||||
|
### Vector Index Schema:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
page_id: SchemaFieldTypes.TEXT, // Sortable page identifier
|
||||||
|
workspace_id: SchemaFieldTypes.TEXT, // Sortable workspace filter
|
||||||
|
space_id: SchemaFieldTypes.TEXT, // Space filter
|
||||||
|
title: SchemaFieldTypes.TEXT, // Page title
|
||||||
|
embedding: { // Vector field
|
||||||
|
type: SchemaFieldTypes.VECTOR,
|
||||||
|
ALGORITHM: VectorAlgorithms.HNSW, // HNSW indexing
|
||||||
|
TYPE: 'FLOAT32', // 32-bit floats
|
||||||
|
DIM: 1536, // Embedding dimensions
|
||||||
|
DISTANCE_METRIC: 'COSINE', // Cosine similarity
|
||||||
|
},
|
||||||
|
indexed_at: SchemaFieldTypes.NUMERIC // Indexing timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Semantic Search
|
||||||
|
```http
|
||||||
|
POST /ai-search/semantic
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"query": "machine learning algorithms",
|
||||||
|
"spaceId": "optional-space-id",
|
||||||
|
"limit": 20,
|
||||||
|
"similarity_threshold": 0.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hybrid Search
|
||||||
|
```http
|
||||||
|
POST /ai-search/hybrid
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"query": "neural networks",
|
||||||
|
"spaceId": "optional-space-id",
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reindex Pages
|
||||||
|
```http
|
||||||
|
POST /ai-search/reindex
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"spaceId": "optional-space-id",
|
||||||
|
"pageIds": ["page-id-1", "page-id-2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Semantic Search
|
||||||
|
```typescript
|
||||||
|
import { AiSearchService } from './ai-search.service';
|
||||||
|
|
||||||
|
// Search for pages semantically using vector similarity
|
||||||
|
const results = await aiSearchService.semanticSearch(
|
||||||
|
'artificial intelligence concepts',
|
||||||
|
{ limit: 10, similarity_threshold: 0.8 },
|
||||||
|
{ userId: 'user-id', workspaceId: 'workspace-id' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hybrid Search with Weighted Scoring
|
||||||
|
```typescript
|
||||||
|
// Combine semantic (70%) and text search (30%)
|
||||||
|
const results = await aiSearchService.hybridSearch(
|
||||||
|
'machine learning tutorial',
|
||||||
|
{ spaceId: 'space-id', limit: 15 },
|
||||||
|
{ userId: 'user-id', workspaceId: 'workspace-id' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
The module uses the official **node-redis** package for Redis integration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"redis": "^4.7.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Install with pnpm:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
### Vector Search Performance
|
||||||
|
- **HNSW Algorithm**: Provides O(log n) search complexity
|
||||||
|
- **COSINE Distance**: Efficient for normalized embeddings
|
||||||
|
- **Batch Operations**: Multi-command execution for bulk indexing
|
||||||
|
- **Connection Pooling**: Persistent Redis connections
|
||||||
|
|
||||||
|
### Memory Efficiency
|
||||||
|
- **Float32 Vectors**: Reduced memory usage vs Float64
|
||||||
|
- **TTL Expiration**: Automatic cleanup of old vectors (30 days)
|
||||||
|
- **Prefix-based Storage**: Organized key structure
|
||||||
|
|
||||||
|
## Vector Storage Format
|
||||||
|
|
||||||
|
Vectors are stored as Redis hash documents:
|
||||||
|
```
|
||||||
|
Key: vector:{workspaceId}:{pageId}
|
||||||
|
Fields:
|
||||||
|
page_id: "page-uuid"
|
||||||
|
workspace_id: "workspace-uuid"
|
||||||
|
space_id: "space-uuid"
|
||||||
|
title: "Page Title"
|
||||||
|
embedding: Buffer<Float32Array> // Binary vector data
|
||||||
|
indexed_at: "1234567890"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The module includes comprehensive error handling:
|
||||||
|
|
||||||
|
- **Connection Resilience**: Automatic reconnection on Redis failures
|
||||||
|
- **Embedding Retries**: Exponential backoff for API failures
|
||||||
|
- **Vector Validation**: Dimension and format checking
|
||||||
|
- **Graceful Degradation**: Fallback to text search on vector errors
|
||||||
|
|
||||||
|
This implementation provides production-ready vector search capabilities that scale with your content while maintaining excellent search quality and performance.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# AI Search Setup Guide
|
||||||
|
|
||||||
|
This guide will help you set up the AI Search module with Redis vector database for Docmost.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Redis with RediSearch**: You need Redis with the RediSearch module for vector operations
|
||||||
|
2. **OpenAI API Key**: For embedding generation (or alternative provider)
|
||||||
|
3. **Node.js Dependencies**: The required packages are already added to package.json
|
||||||
|
|
||||||
|
## Step 1: Install Redis with RediSearch
|
||||||
|
|
||||||
|
### Option A: Using Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Redis Stack (includes RediSearch and vector capabilities)
|
||||||
|
docker run -d --name redis-stack \
|
||||||
|
-p 6379:6379 \
|
||||||
|
-v redis-data:/data \
|
||||||
|
redis/redis-stack-server:latest
|
||||||
|
|
||||||
|
# Or using Redis Enterprise with RediSearch
|
||||||
|
docker run -d --name redis-vector \
|
||||||
|
-p 6379:6379 \
|
||||||
|
-v redis-data:/data \
|
||||||
|
redislabs/redisearch:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Manual Installation
|
||||||
|
|
||||||
|
1. Install Redis from source with RediSearch module
|
||||||
|
2. Or use Redis Cloud with RediSearch enabled
|
||||||
|
|
||||||
|
## Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
Add these variables to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ===== Redis Vector Database Configuration =====
|
||||||
|
REDIS_VECTOR_HOST=localhost
|
||||||
|
REDIS_VECTOR_PORT=6379
|
||||||
|
REDIS_VECTOR_PASSWORD=your_redis_password_here
|
||||||
|
REDIS_VECTOR_DB=0
|
||||||
|
REDIS_VECTOR_INDEX=docmost_pages
|
||||||
|
|
||||||
|
# ===== AI Embedding Configuration (OpenAI-compatible) =====
|
||||||
|
AI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
AI_EMBEDDING_DIMENSIONS=1536
|
||||||
|
AI_EMBEDDING_BASE_URL=https://api.openai.com/v1/embeddings # Optional: for custom providers
|
||||||
|
|
||||||
|
# ===== OpenAI API Key (or compatible provider key) =====
|
||||||
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Custom OpenAI-Compatible Providers
|
||||||
|
|
||||||
|
You can use any provider that follows the OpenAI embeddings API specification by setting the `AI_EMBEDDING_BASE_URL`:
|
||||||
|
|
||||||
|
### Examples:
|
||||||
|
|
||||||
|
**Azure OpenAI:**
|
||||||
|
```env
|
||||||
|
AI_EMBEDDING_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment/embeddings?api-version=2023-05-15
|
||||||
|
OPENAI_API_KEY=your_azure_openai_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ollama (local):**
|
||||||
|
```env
|
||||||
|
AI_EMBEDDING_BASE_URL=http://localhost:11434/v1/embeddings
|
||||||
|
AI_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
AI_EMBEDDING_DIMENSIONS=768
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other compatible providers:**
|
||||||
|
- Together AI
|
||||||
|
- Anyscale
|
||||||
|
- OpenRouter
|
||||||
|
- Any provider implementing OpenAI's embeddings API
|
||||||
|
|
||||||
|
## Step 4: Install Dependencies
|
||||||
|
|
||||||
|
The required dependencies are already in package.json. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Initialize the Vector Index
|
||||||
|
|
||||||
|
The vector index will be created automatically when the service starts. You can also manually trigger reindexing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the API endpoint
|
||||||
|
curl -X POST http://localhost:3000/ai-search/reindex \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-d '{"workspaceId": "your-workspace-id"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Test the Setup
|
||||||
|
|
||||||
|
### Test Semantic Search
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/ai-search/semantic \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"query": "machine learning algorithms",
|
||||||
|
"limit": 10,
|
||||||
|
"similarity_threshold": 0.7
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Hybrid Search
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/ai-search/hybrid \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"query": "neural networks",
|
||||||
|
"limit": 10
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Monitor the Setup
|
||||||
|
|
||||||
|
### Check Redis Connection
|
||||||
|
```bash
|
||||||
|
redis-cli ping
|
||||||
|
# Should return PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check RediSearch Module
|
||||||
|
```bash
|
||||||
|
redis-cli MODULE LIST
|
||||||
|
# Should show RediSearch in the list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Index Status
|
||||||
|
```bash
|
||||||
|
redis-cli FT.INFO docmost_pages
|
||||||
|
# Should show index information
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Redis Connection Error**
|
||||||
|
- Check if Redis is running: `docker ps` or `redis-cli ping`
|
||||||
|
- Verify connection details in .env file
|
||||||
|
- Check firewall/network settings
|
||||||
|
|
||||||
|
2. **RediSearch Module Not Found**
|
||||||
|
- Ensure you're using Redis Stack or Redis with RediSearch
|
||||||
|
- Check module is loaded: `redis-cli MODULE LIST`
|
||||||
|
|
||||||
|
3. **OpenAI API Errors**
|
||||||
|
- Verify API key is correct and has sufficient credits
|
||||||
|
- Check API usage limits and quotas
|
||||||
|
- Ensure model name is correct
|
||||||
|
|
||||||
|
4. **Embedding Generation Fails**
|
||||||
|
- Check text length (max 8000 characters by default)
|
||||||
|
- Verify network connectivity to embedding provider
|
||||||
|
- Check API rate limits
|
||||||
|
|
||||||
|
5. **Search Returns No Results**
|
||||||
|
- Ensure pages are indexed: check logs for indexing errors
|
||||||
|
- Verify similarity threshold (try lowering it)
|
||||||
|
- Check user permissions for searched content
|
||||||
|
|
||||||
|
### Debug Logging
|
||||||
|
|
||||||
|
Enable debug logging by setting:
|
||||||
|
```env
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tuning
|
||||||
|
|
||||||
|
1. **Batch Size**: Adjust based on your API rate limits
|
||||||
|
```env
|
||||||
|
AI_SEARCH_BATCH_SIZE=50 # Lower for rate-limited APIs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Similarity Threshold**: Balance precision vs recall
|
||||||
|
```env
|
||||||
|
AI_SEARCH_SIMILARITY_THRESHOLD=0.6 # Lower = more results
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Redis Memory**: Monitor memory usage as index grows
|
||||||
|
```bash
|
||||||
|
redis-cli INFO memory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Redis Configuration
|
||||||
|
- Use Redis Cluster for high availability
|
||||||
|
- Set up proper backup and persistence
|
||||||
|
- Monitor memory usage and performance
|
||||||
|
- Configure appropriate TTL for vectors
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Use strong Redis passwords
|
||||||
|
- Enable TLS for Redis connections
|
||||||
|
- Secure API keys in environment variables
|
||||||
|
- Implement proper rate limiting
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- Set up alerts for Redis health
|
||||||
|
- Monitor embedding API usage and costs
|
||||||
|
- Track search performance metrics
|
||||||
|
- Log search queries for analysis
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Auto-indexing**: Pages are automatically indexed on create/update
|
||||||
|
2. **Client Integration**: Add AI search to your frontend
|
||||||
|
3. **Custom Scoring**: Implement custom ranking algorithms
|
||||||
|
4. **Analytics**: Track search usage and effectiveness
|
||||||
|
|
||||||
|
For more detailed information, see the main README.md file.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AiSearchController } from './ai-search.controller';
|
||||||
|
import { AiSearchService } from './services/ai-search.service';
|
||||||
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
|
|
||||||
|
describe('AiSearchController', () => {
|
||||||
|
let controller: AiSearchController;
|
||||||
|
let service: AiSearchService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AiSearchController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AiSearchService,
|
||||||
|
useValue: {
|
||||||
|
semanticSearch: jest.fn(),
|
||||||
|
hybridSearch: jest.fn(),
|
||||||
|
reindexPages: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SpaceAbilityFactory,
|
||||||
|
useValue: {
|
||||||
|
createForUser: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AiSearchController>(AiSearchController);
|
||||||
|
service = module.get<AiSearchService>(AiSearchService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { AiSearchService } from './services/ai-search.service';
|
||||||
|
import { SemanticSearchDto, SemanticSearchShareDto } from './dto/semantic-search.dto';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { SpaceCaslAction, SpaceCaslSubject } from '../casl/interfaces/space-ability.type';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('ai-search')
|
||||||
|
export class AiSearchController {
|
||||||
|
constructor(
|
||||||
|
private readonly aiSearchService: AiSearchService,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('semantic')
|
||||||
|
async semanticSearch(
|
||||||
|
@Body() searchDto: SemanticSearchDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
delete searchDto.shareId;
|
||||||
|
|
||||||
|
if (searchDto.spaceId) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
searchDto.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.aiSearchService.semanticSearch(searchDto.query, searchDto, {
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('hybrid')
|
||||||
|
async hybridSearch(
|
||||||
|
@Body() searchDto: SemanticSearchDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
delete searchDto.shareId;
|
||||||
|
|
||||||
|
if (searchDto.spaceId) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
searchDto.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.aiSearchService.hybridSearch(searchDto.query, searchDto, {
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('semantic-share')
|
||||||
|
async semanticSearchShare(
|
||||||
|
@Body() searchDto: SemanticSearchShareDto,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
delete searchDto.spaceId;
|
||||||
|
if (!searchDto.shareId) {
|
||||||
|
throw new BadRequestException('shareId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.aiSearchService.semanticSearch(searchDto.query, searchDto, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('reindex')
|
||||||
|
async reindexPages(
|
||||||
|
@Body() body: { spaceId?: string; pageIds?: string[] },
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
if (body.spaceId) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
body.spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.aiSearchService.reindexPages({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
spaceId: body.spaceId,
|
||||||
|
pageIds: body.pageIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AiSearchController } from './ai-search.controller';
|
||||||
|
import { AiSearchService } from './services/ai-search.service';
|
||||||
|
import { VectorService } from './services/vector.service';
|
||||||
|
import { EmbeddingService } from './services/embedding.service';
|
||||||
|
import { RedisVectorService } from './services/redis-vector.service';
|
||||||
|
import { PageUpdateListener } from './listeners/page-update.listener';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
controllers: [AiSearchController],
|
||||||
|
providers: [
|
||||||
|
AiSearchService,
|
||||||
|
VectorService,
|
||||||
|
EmbeddingService,
|
||||||
|
RedisVectorService,
|
||||||
|
PageUpdateListener,
|
||||||
|
],
|
||||||
|
exports: [AiSearchService, VectorService, EmbeddingService, RedisVectorService],
|
||||||
|
})
|
||||||
|
export class AiSearchModule {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const AI_SEARCH_CONFIG = {
|
||||||
|
// Default similarity thresholds
|
||||||
|
DEFAULT_SIMILARITY_THRESHOLD: 0.7,
|
||||||
|
HIGH_SIMILARITY_THRESHOLD: 0.85,
|
||||||
|
LOW_SIMILARITY_THRESHOLD: 0.6,
|
||||||
|
|
||||||
|
// Search limits
|
||||||
|
MAX_SEARCH_LIMIT: 100,
|
||||||
|
DEFAULT_SEARCH_LIMIT: 20,
|
||||||
|
MIN_SEARCH_LIMIT: 1,
|
||||||
|
|
||||||
|
// Embedding configuration
|
||||||
|
DEFAULT_EMBEDDING_DIMENSIONS: 1536,
|
||||||
|
MAX_TEXT_LENGTH: 8000,
|
||||||
|
|
||||||
|
// Indexing configuration
|
||||||
|
DEFAULT_BATCH_SIZE: 100,
|
||||||
|
INDEX_TTL_DAYS: 30,
|
||||||
|
|
||||||
|
// Hybrid search weights
|
||||||
|
SEMANTIC_WEIGHT: 0.7,
|
||||||
|
TEXT_WEIGHT: 0.3,
|
||||||
|
|
||||||
|
// Redis configuration
|
||||||
|
REDIS_KEY_PREFIX: 'docmost:ai-search',
|
||||||
|
VECTOR_KEY_PREFIX: 'vector',
|
||||||
|
METADATA_KEY_PREFIX: 'metadata',
|
||||||
|
|
||||||
|
// Retry configuration
|
||||||
|
MAX_RETRIES: 3,
|
||||||
|
RETRY_DELAY_MS: 1000,
|
||||||
|
|
||||||
|
// OpenAI configuration
|
||||||
|
OPENAI_BATCH_SIZE: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMBEDDING_MODELS = {
|
||||||
|
OPENAI: {
|
||||||
|
'text-embedding-3-small': 1536,
|
||||||
|
'text-embedding-3-large': 3072,
|
||||||
|
'text-embedding-ada-002': 1536,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SEARCH_EVENTS = {
|
||||||
|
PAGE_CREATED: 'page.created',
|
||||||
|
PAGE_UPDATED: 'page.updated',
|
||||||
|
PAGE_DELETED: 'page.deleted',
|
||||||
|
BULK_REINDEX: 'ai-search.bulk-reindex',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class SemanticSearchDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
shareId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
creatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
offset?: number = 0;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
similarity_threshold?: number = 0.7;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
include_highlights?: boolean = true;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
filters?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SemanticSearchShareDto extends SemanticSearchDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
shareId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SemanticSearchResponseDto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
parentPageId: string;
|
||||||
|
creatorId: string;
|
||||||
|
similarity_score: number;
|
||||||
|
semantic_rank: number;
|
||||||
|
highlight: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
space?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HybridSearchResponseDto extends SemanticSearchResponseDto {
|
||||||
|
text_rank?: number;
|
||||||
|
combined_score: number;
|
||||||
|
search_type: 'semantic' | 'text' | 'hybrid';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReindexDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
pageIds?: string[];
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { AiSearchService } from '../services/ai-search.service';
|
||||||
|
import { EmbeddingService } from '../services/embedding.service';
|
||||||
|
import { RedisVectorService } from '../services/redis-vector.service';
|
||||||
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
import { UpdatedPageEvent } from '../../../collaboration/listeners/history.listener';
|
||||||
|
|
||||||
|
export interface PageUpdateEvent {
|
||||||
|
pageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId: string;
|
||||||
|
title?: string;
|
||||||
|
textContent?: string;
|
||||||
|
operation: 'create' | 'update' | 'delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PageUpdateListener {
|
||||||
|
private readonly logger = new Logger(PageUpdateListener.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly aiSearchService: AiSearchService,
|
||||||
|
private readonly embeddingService: EmbeddingService,
|
||||||
|
private readonly redisVectorService: RedisVectorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('page.created')
|
||||||
|
async handlePageCreated(event: Page) {
|
||||||
|
await this.indexPage(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('collab.page.updated')
|
||||||
|
async handlePageUpdated(event: UpdatedPageEvent) {
|
||||||
|
await this.indexPage(event.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('page.deleted')
|
||||||
|
async handlePageDeleted(event: Page) {
|
||||||
|
try {
|
||||||
|
await this.redisVectorService.deletePage(event.id, event.workspaceId);
|
||||||
|
this.logger.debug(`Removed page ${event.id} from vector index`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to remove page ${event.id} from vector index:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async indexPage(event: Page) {
|
||||||
|
try {
|
||||||
|
const content = `${event.title || ''} ${event.textContent || ''}`.trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping indexing for page ${event.id} - no content`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.embeddingService.isConfigured()) {
|
||||||
|
this.logger.debug(
|
||||||
|
'Embedding service not configured, skipping indexing',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedding = await this.embeddingService.generateEmbedding(content);
|
||||||
|
|
||||||
|
console.log('embedding', embedding);
|
||||||
|
|
||||||
|
await this.redisVectorService.indexPage({
|
||||||
|
pageId: event.id,
|
||||||
|
embedding,
|
||||||
|
metadata: {
|
||||||
|
title: event.title,
|
||||||
|
workspaceId: event.workspaceId,
|
||||||
|
spaceId: event.spaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(`Indexed page ${event.id} for AI search`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to index page ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
|
import { VectorService } from './vector.service';
|
||||||
|
import { EmbeddingService } from './embedding.service';
|
||||||
|
import { RedisVectorService } from './redis-vector.service';
|
||||||
|
import {
|
||||||
|
SemanticSearchDto,
|
||||||
|
SemanticSearchResponseDto,
|
||||||
|
HybridSearchResponseDto,
|
||||||
|
ReindexDto,
|
||||||
|
} from '../dto/semantic-search.dto';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const tsquery = require('pg-tsquery')();
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiSearchService {
|
||||||
|
private readonly logger = new Logger(AiSearchService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly shareRepo: ShareRepo,
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private readonly vectorService: VectorService,
|
||||||
|
private readonly embeddingService: EmbeddingService,
|
||||||
|
private readonly redisVectorService: RedisVectorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async semanticSearch(
|
||||||
|
query: string,
|
||||||
|
searchParams: SemanticSearchDto,
|
||||||
|
opts: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
},
|
||||||
|
): Promise<SemanticSearchResponseDto[]> {
|
||||||
|
if (query.length < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate embedding for the query
|
||||||
|
const queryEmbedding =
|
||||||
|
await this.embeddingService.generateEmbedding(query);
|
||||||
|
|
||||||
|
// Get page IDs that user has access to
|
||||||
|
const accessiblePageIds = await this.getAccessiblePageIds(
|
||||||
|
searchParams,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('accessible', accessiblePageIds);
|
||||||
|
|
||||||
|
if (accessiblePageIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform vector search
|
||||||
|
const vectorResults = await this.redisVectorService.searchSimilar(
|
||||||
|
queryEmbedding,
|
||||||
|
{
|
||||||
|
limit: searchParams.limit || 20,
|
||||||
|
offset: searchParams.offset || 0,
|
||||||
|
threshold: searchParams.similarity_threshold || 0.7,
|
||||||
|
filters: {
|
||||||
|
workspace_id: opts.workspaceId,
|
||||||
|
page_ids: accessiblePageIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('vectorResults', vectorResults);
|
||||||
|
|
||||||
|
if (vectorResults.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get page details from database
|
||||||
|
const pageIds = vectorResults.map((result) => result.pageId);
|
||||||
|
const pages = await this.getPageDetails(pageIds, searchParams);
|
||||||
|
|
||||||
|
// Combine vector results with page details
|
||||||
|
const results = this.combineVectorResultsWithPages(
|
||||||
|
vectorResults,
|
||||||
|
pages,
|
||||||
|
query,
|
||||||
|
searchParams.include_highlights,
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Semantic search failed: ${error?.['message']}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hybridSearch(
|
||||||
|
query: string,
|
||||||
|
searchParams: SemanticSearchDto,
|
||||||
|
opts: {
|
||||||
|
userId?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
},
|
||||||
|
): Promise<HybridSearchResponseDto[]> {
|
||||||
|
if (query.length < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run both semantic and text search in parallel
|
||||||
|
const [semanticResults, textResults] = await Promise.all([
|
||||||
|
this.semanticSearch(query, searchParams, opts),
|
||||||
|
this.performTextSearch(query, searchParams, opts),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine and rank results
|
||||||
|
const hybridResults = this.combineHybridResults(
|
||||||
|
semanticResults,
|
||||||
|
textResults,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return hybridResults;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Hybrid search failed: ${error?.['message']}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reindexPages(
|
||||||
|
params: ReindexDto,
|
||||||
|
): Promise<{ indexed: number; errors?: string[] }> {
|
||||||
|
try {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'title', 'textContent'])
|
||||||
|
.where('workspaceId', '=', params.workspaceId)
|
||||||
|
.where('deletedAt', 'is', null);
|
||||||
|
|
||||||
|
if (params.spaceId) {
|
||||||
|
query = query.where('spaceId', '=', params.spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.pageIds && params.pageIds.length > 0) {
|
||||||
|
query = query.where('id', 'in', params.pageIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await query.execute();
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
pages.map(async (page) => {
|
||||||
|
const content =
|
||||||
|
`${page.title || ''} ${page.textContent || ''}`.trim();
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
const embedding =
|
||||||
|
await this.embeddingService.generateEmbedding(content);
|
||||||
|
|
||||||
|
await this.redisVectorService.indexPage({
|
||||||
|
pageId: page.id,
|
||||||
|
embedding,
|
||||||
|
metadata: {
|
||||||
|
title: page.title,
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return page.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexed = results.filter(
|
||||||
|
(r) => r.status === 'fulfilled' && r.value,
|
||||||
|
).length;
|
||||||
|
const errors = results
|
||||||
|
.filter((r) => r.status === 'rejected')
|
||||||
|
.map((r) => r.reason.message);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Reindexed ${indexed} pages for workspace ${params.workspaceId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { indexed, errors: errors.length > 0 ? errors : undefined };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Reindexing failed: ${error?.['message']}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessiblePageIds(
|
||||||
|
searchParams: SemanticSearchDto,
|
||||||
|
opts: { userId?: string; workspaceId: string },
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (searchParams.shareId) {
|
||||||
|
// Handle shared pages
|
||||||
|
const share = await this.shareRepo.findById(searchParams.shareId);
|
||||||
|
if (!share || share.workspaceId !== opts.workspaceId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIdsToSearch = [];
|
||||||
|
if (share.includeSubPages) {
|
||||||
|
const pageList = await this.pageRepo.getPageAndDescendants(
|
||||||
|
share.pageId,
|
||||||
|
{ includeContent: false },
|
||||||
|
);
|
||||||
|
pageIdsToSearch.push(...pageList.map((page) => page.id));
|
||||||
|
} else {
|
||||||
|
pageIdsToSearch.push(share.pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageIdsToSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchParams.spaceId) {
|
||||||
|
// Get pages from specific space
|
||||||
|
const pages = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select('id')
|
||||||
|
.where('spaceId', '=', searchParams.spaceId)
|
||||||
|
.where('workspaceId', '=', opts.workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return pages.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.userId) {
|
||||||
|
// Get pages from user's accessible spaces
|
||||||
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
|
||||||
|
opts.userId,
|
||||||
|
);
|
||||||
|
if (userSpaceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select('id')
|
||||||
|
.where('spaceId', 'in', userSpaceIds)
|
||||||
|
.where('workspaceId', '=', opts.workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return pages.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPageDetails(
|
||||||
|
pageIds: string[],
|
||||||
|
searchParams: SemanticSearchDto,
|
||||||
|
) {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'parentPageId',
|
||||||
|
'creatorId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'textContent',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!searchParams.shareId) {
|
||||||
|
query = query.select((eb) => this.pageRepo.withSpace(eb));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await query
|
||||||
|
.where('id', 'in', pageIds)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private combineVectorResultsWithPages(
|
||||||
|
vectorResults: any[],
|
||||||
|
pages: any[],
|
||||||
|
query: string,
|
||||||
|
includeHighlights: boolean = true,
|
||||||
|
): SemanticSearchResponseDto[] {
|
||||||
|
const pageMap = new Map(pages.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
return vectorResults
|
||||||
|
.map((result, index) => {
|
||||||
|
const page = pageMap.get(result.pageId);
|
||||||
|
if (!page) return null;
|
||||||
|
|
||||||
|
let highlight = '';
|
||||||
|
if (includeHighlights && page.textContent) {
|
||||||
|
highlight = this.generateHighlight(page.textContent, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
icon: page.icon,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
creatorId: page.creatorId,
|
||||||
|
similarity_score: result.score,
|
||||||
|
semantic_rank: index + 1,
|
||||||
|
highlight,
|
||||||
|
createdAt: page.createdAt,
|
||||||
|
updatedAt: page.updatedAt,
|
||||||
|
space: page.space
|
||||||
|
? {
|
||||||
|
id: page.space.id,
|
||||||
|
name: page.space.name,
|
||||||
|
slug: page.space.slug,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performTextSearch(
|
||||||
|
query: string,
|
||||||
|
searchParams: SemanticSearchDto,
|
||||||
|
opts: { userId?: string; workspaceId: string },
|
||||||
|
) {
|
||||||
|
const searchQuery = tsquery(query.trim() + '*');
|
||||||
|
const accessiblePageIds = await this.getAccessiblePageIds(
|
||||||
|
searchParams,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accessiblePageIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'parentPageId',
|
||||||
|
'creatorId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('text_rank'),
|
||||||
|
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
|
||||||
|
'highlight',
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
||||||
|
.where('id', 'in', accessiblePageIds)
|
||||||
|
.orderBy('text_rank', 'desc')
|
||||||
|
.limit(searchParams.limit || 20)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
text_rank: result.text_rank,
|
||||||
|
search_type: 'text' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private combineHybridResults(
|
||||||
|
semanticResults: SemanticSearchResponseDto[],
|
||||||
|
textResults: any[],
|
||||||
|
query: string,
|
||||||
|
): HybridSearchResponseDto[] {
|
||||||
|
const combinedMap = new Map<string, HybridSearchResponseDto>();
|
||||||
|
|
||||||
|
// Add semantic results
|
||||||
|
semanticResults.forEach((result, index) => {
|
||||||
|
combinedMap.set(result.id, {
|
||||||
|
...result,
|
||||||
|
text_rank: undefined,
|
||||||
|
combined_score: result.similarity_score * 0.7, // Weight semantic results
|
||||||
|
search_type: 'semantic',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add text results or combine with existing
|
||||||
|
textResults.forEach((result, index) => {
|
||||||
|
const existing = combinedMap.get(result.id);
|
||||||
|
if (existing) {
|
||||||
|
// Combine scores
|
||||||
|
existing.combined_score =
|
||||||
|
existing.similarity_score * 0.7 + result.text_rank * 0.3;
|
||||||
|
existing.text_rank = result.text_rank;
|
||||||
|
existing.search_type = 'hybrid';
|
||||||
|
} else {
|
||||||
|
combinedMap.set(result.id, {
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
icon: result.icon,
|
||||||
|
parentPageId: result.parentPageId,
|
||||||
|
creatorId: result.creatorId,
|
||||||
|
similarity_score: 0,
|
||||||
|
semantic_rank: 0,
|
||||||
|
text_rank: result.text_rank,
|
||||||
|
combined_score: result.text_rank * 0.3,
|
||||||
|
highlight: result.highlight,
|
||||||
|
createdAt: result.createdAt,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
search_type: 'text',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by combined score
|
||||||
|
return Array.from(combinedMap.values())
|
||||||
|
.sort((a, b) => b.combined_score - a.combined_score)
|
||||||
|
.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateHighlight(content: string, query: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
const words = query.toLowerCase().split(/\s+/);
|
||||||
|
const sentences = content.split(/[.!?]+/);
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
const lowerSentence = sentence.toLowerCase();
|
||||||
|
if (words.some((word) => lowerSentence.includes(word))) {
|
||||||
|
return sentence.trim().substring(0, 200) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.substring(0, 200) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
export interface EmbeddingConfig {
|
||||||
|
model: string;
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
dimensions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmbeddingResult {
|
||||||
|
embedding: number[];
|
||||||
|
tokens: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmbeddingService {
|
||||||
|
private readonly logger = new Logger(EmbeddingService.name);
|
||||||
|
private readonly config: EmbeddingConfig;
|
||||||
|
private readonly openai: OpenAI;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.config = {
|
||||||
|
model: this.configService.get<string>(
|
||||||
|
'AI_EMBEDDING_MODEL',
|
||||||
|
'text-embedding-3-small',
|
||||||
|
),
|
||||||
|
apiKey: this.configService.get<string>('OPENAI_API_KEY'),
|
||||||
|
baseUrl: 'https://api.openai.com/v1/',
|
||||||
|
dimensions: Number(
|
||||||
|
this.configService.get<string>('AI_EMBEDDING_DIMENSIONS', '1536'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.config.apiKey) {
|
||||||
|
this.logger.warn(
|
||||||
|
'OpenAI API key not configured. AI search will not work.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize OpenAI client with optional custom base URL
|
||||||
|
this.openai = new OpenAI({
|
||||||
|
apiKey: this.config.apiKey || 'dummy-key',
|
||||||
|
baseURL: this.config.baseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding for a single text
|
||||||
|
*/
|
||||||
|
async generateEmbedding(text: string): Promise<number[]> {
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
throw new Error('Text cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanText = this.preprocessText(text);
|
||||||
|
console.log('generate clean text', cleanText);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.generateEmbeddingWithOpenAI(cleanText);
|
||||||
|
console.log('embedding results', result);
|
||||||
|
return result.embedding;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Embedding generation failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embeddings for multiple texts in batch
|
||||||
|
*/
|
||||||
|
async generateEmbeddings(texts: string[]): Promise<number[][]> {
|
||||||
|
if (!texts || texts.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanTexts = texts.map((text) => this.preprocessText(text));
|
||||||
|
const batchSize = this.getBatchSize();
|
||||||
|
const results: number[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < cleanTexts.length; i += batchSize) {
|
||||||
|
const batch = cleanTexts.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchResults = await this.generateBatchEmbeddings(batch);
|
||||||
|
results.push(...batchResults);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Batch embedding generation failed for batch ${i}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding using OpenAI API
|
||||||
|
*/
|
||||||
|
private async generateEmbeddingWithOpenAI(
|
||||||
|
text: string,
|
||||||
|
): Promise<EmbeddingResult> {
|
||||||
|
const response = await this.openai.embeddings.create({
|
||||||
|
model: this.config.model,
|
||||||
|
input: text,
|
||||||
|
dimensions: this.config.dimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data || response.data.length === 0) {
|
||||||
|
throw new Error('Invalid response from OpenAI API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedding: response.data[0].embedding,
|
||||||
|
tokens: response.usage?.total_tokens || 0,
|
||||||
|
model: this.config.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embeddings for multiple texts
|
||||||
|
*/
|
||||||
|
private async generateBatchEmbeddings(texts: string[]): Promise<number[][]> {
|
||||||
|
const response = await this.openai.embeddings.create({
|
||||||
|
model: this.config.model,
|
||||||
|
input: texts,
|
||||||
|
dimensions: this.config.dimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data || !Array.isArray(response.data)) {
|
||||||
|
throw new Error('Invalid response from OpenAI API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.map((item) => item.embedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocess text before embedding generation
|
||||||
|
*/
|
||||||
|
private preprocessText(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Remove excessive whitespace
|
||||||
|
let processed = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
// Truncate if too long (most models have token limits)
|
||||||
|
const maxLength = 8000; // Conservative limit
|
||||||
|
if (processed.length > maxLength) {
|
||||||
|
processed = processed.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch size for OpenAI API
|
||||||
|
*/
|
||||||
|
private getBatchSize(): number {
|
||||||
|
return 100; // OpenAI supports up to 2048 inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep utility for retries
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if embedding service is configured
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return !!this.config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get embedding configuration
|
||||||
|
*/
|
||||||
|
getConfig(): EmbeddingConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
VectorSearchOptions,
|
||||||
|
VectorSearchResult,
|
||||||
|
VectorService,
|
||||||
|
} from './vector.service';
|
||||||
|
import {
|
||||||
|
createClient,
|
||||||
|
RedisClientType,
|
||||||
|
SCHEMA_FIELD_TYPE,
|
||||||
|
SCHEMA_VECTOR_FIELD_ALGORITHM,
|
||||||
|
} from 'redis';
|
||||||
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
|
|
||||||
|
export interface IndexPageData {
|
||||||
|
pageId: string;
|
||||||
|
embedding: number[];
|
||||||
|
metadata: {
|
||||||
|
title?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisVectorConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
password?: string;
|
||||||
|
db?: number;
|
||||||
|
indexName: string;
|
||||||
|
vectorDimension: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisVectorService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisVectorService.name);
|
||||||
|
private readonly redis: RedisClientType;
|
||||||
|
private readonly config: RedisVectorConfig;
|
||||||
|
private isIndexCreated = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly vectorService: VectorService,
|
||||||
|
) {
|
||||||
|
//@ts-ignore
|
||||||
|
this.config = {
|
||||||
|
indexName: 'docmost_pages_index',
|
||||||
|
vectorDimension: 1536, //AI_EMBEDDING_DIMENSIONS
|
||||||
|
};
|
||||||
|
|
||||||
|
this.redis = createClient({
|
||||||
|
url: this.environmentService.getRedisUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('error', (err) => {
|
||||||
|
this.logger.error('Redis Client Error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initializeConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchSimilar(
|
||||||
|
queryEmbedding: number[],
|
||||||
|
options: VectorSearchOptions,
|
||||||
|
): Promise<VectorSearchResult[]> {
|
||||||
|
try {
|
||||||
|
await this.ensureIndexExists();
|
||||||
|
|
||||||
|
const { limit = 20, offset = 0, threshold = 0.7, filters } = options;
|
||||||
|
|
||||||
|
// Build query following Redis specs
|
||||||
|
let query = `*=>[KNN ${limit + offset} @embedding $vector AS score]`;
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
const filterClauses = Object.entries(filters).map(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `@${key}:{${value.join('|')}}`;
|
||||||
|
}
|
||||||
|
return `@${key}:${value}`;
|
||||||
|
});
|
||||||
|
query = `(${filterClauses.join(' ')})=>[KNN ${limit + offset} @embedding $vector AS score]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute search using proper node-redis syntax
|
||||||
|
const searchOptions = {
|
||||||
|
PARAMS: {
|
||||||
|
vector: Buffer.from(new Float32Array(queryEmbedding).buffer),
|
||||||
|
},
|
||||||
|
SORTBY: {
|
||||||
|
BY: '@score' as `@${string}`,
|
||||||
|
DIRECTION: 'ASC' as 'ASC',
|
||||||
|
},
|
||||||
|
LIMIT: {
|
||||||
|
from: offset,
|
||||||
|
size: limit,
|
||||||
|
},
|
||||||
|
RETURN: ['page_id', 'workspace_id', 'space_id', 'title', 'score'],
|
||||||
|
DIALECT: 2,
|
||||||
|
};
|
||||||
|
console.log(searchOptions);
|
||||||
|
//is not assignable to parameter of type FtSearchOptions
|
||||||
|
// Types of property SORTBY are incompatible.
|
||||||
|
// Type { BY: string; DIRECTION: string; } is not assignable to type
|
||||||
|
// RedisArgument | { BY: `@${string}` | `$.${string}`; DIRECTION?: 'DESC' | 'ASC'; }
|
||||||
|
|
||||||
|
const searchResult = await this.redis.ft.search(
|
||||||
|
this.config.indexName,
|
||||||
|
query,
|
||||||
|
searchOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = this.parseSearchResults(searchResult, threshold);
|
||||||
|
|
||||||
|
this.logger.debug(`Vector search found ${results.length} results`);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Vector search failed:', error);
|
||||||
|
throw new Error(`Vector search failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexPage(data: IndexPageData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.ensureIndexExists();
|
||||||
|
|
||||||
|
const key = this.vectorService.createVectorKey(
|
||||||
|
data.pageId,
|
||||||
|
data.metadata.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store vector and metadata using proper node-redis hash operations
|
||||||
|
await this.redis.hSet(key, {
|
||||||
|
page_id: data.pageId,
|
||||||
|
workspace_id: data.metadata.workspaceId,
|
||||||
|
space_id: data.metadata.spaceId || '',
|
||||||
|
title: data.metadata.title || '',
|
||||||
|
embedding: Buffer.from(new Float32Array(data.embedding).buffer),
|
||||||
|
indexed_at: Date.now().toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set TTL for the key
|
||||||
|
await this.redis.expire(key, 86400 * 30); // 30 days TTL
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Indexed page ${data.pageId} in workspace ${data.metadata.workspaceId}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to index page ${data.pageId}: ${error?.['message']}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePage(pageId: string, workspaceId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = this.vectorService.createVectorKey(pageId, workspaceId);
|
||||||
|
|
||||||
|
await this.redis.del(key);
|
||||||
|
|
||||||
|
this.logger.debug(`Deleted page ${pageId} from vector index`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete page ${pageId}: ${error?.['message']}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchIndexPages(
|
||||||
|
pages: IndexPageData[],
|
||||||
|
): Promise<{ indexed: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let indexed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensureIndexExists();
|
||||||
|
|
||||||
|
// Process in batches to avoid memory issues
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < pages.length; i += batchSize) {
|
||||||
|
const batch = pages.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
// Use node-redis multi for batch operations
|
||||||
|
const multi = this.redis.multi();
|
||||||
|
|
||||||
|
for (const page of batch) {
|
||||||
|
try {
|
||||||
|
const key = this.vectorService.createVectorKey(
|
||||||
|
page.pageId,
|
||||||
|
page.metadata.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
multi.hSet(key, {
|
||||||
|
page_id: page.pageId,
|
||||||
|
workspace_id: page.metadata.workspaceId,
|
||||||
|
space_id: page.metadata.spaceId || '',
|
||||||
|
title: page.metadata.title || '',
|
||||||
|
embedding: Buffer.from(new Float32Array(page.embedding).buffer),
|
||||||
|
indexed_at: Date.now().toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
multi.expire(key, 86400 * 30);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Page ${page.pageId}: ${error?.['message']}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await multi.exec();
|
||||||
|
|
||||||
|
// Count successful operations
|
||||||
|
const batchIndexed =
|
||||||
|
//@ts-ignore
|
||||||
|
results?.filter((result) => !result.error).length || 0;
|
||||||
|
indexed += Math.floor(batchIndexed / 2); // Each page has 2 operations (hSet + expire)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Batch indexed ${indexed} pages with ${errors.length} errors`,
|
||||||
|
);
|
||||||
|
return { indexed, errors };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Batch indexing failed: ${error?.['message']}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeConnection(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.connect();
|
||||||
|
console.log('create');
|
||||||
|
await this.createIndex();
|
||||||
|
this.isIndexCreated = true;
|
||||||
|
this.logger.log('Redis vector database connected and index initialized');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to initialize vector index: ${error?.['message']}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureIndexExists(): Promise<void> {
|
||||||
|
console.log('creating index 1111');
|
||||||
|
|
||||||
|
if (!this.isIndexCreated) {
|
||||||
|
console.log('creating index');
|
||||||
|
await this.createIndex();
|
||||||
|
this.isIndexCreated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createIndex(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if index already exists using proper node-redis syntax
|
||||||
|
await this.redis.ft.info(this.config.indexName);
|
||||||
|
this.logger.debug(`Vector index ${this.config.indexName} already exists`);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Index doesn't exist, create it
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create index using proper node-redis schema definition
|
||||||
|
await this.redis.ft.create(
|
||||||
|
this.config.indexName,
|
||||||
|
{
|
||||||
|
page_id: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.TEXT,
|
||||||
|
SORTABLE: true,
|
||||||
|
},
|
||||||
|
workspace_id: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.TEXT,
|
||||||
|
SORTABLE: true,
|
||||||
|
},
|
||||||
|
space_id: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.TEXT,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.TEXT,
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.VECTOR,
|
||||||
|
ALGORITHM: SCHEMA_VECTOR_FIELD_ALGORITHM.HNSW,
|
||||||
|
TYPE: 'FLOAT32',
|
||||||
|
DIM: this.config.vectorDimension,
|
||||||
|
DISTANCE_METRIC: 'COSINE',
|
||||||
|
},
|
||||||
|
indexed_at: {
|
||||||
|
type: SCHEMA_FIELD_TYPE.NUMERIC,
|
||||||
|
SORTABLE: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ON: 'HASH',
|
||||||
|
PREFIX: 'vector:',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Created vector index ${this.config.indexName}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.['message']?.includes('Index already exists')) {
|
||||||
|
this.logger.debug('Vector index already exists');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSearchResults(
|
||||||
|
results: any,
|
||||||
|
threshold: number,
|
||||||
|
): VectorSearchResult[] {
|
||||||
|
if (!results?.documents || results.documents.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: VectorSearchResult[] = [];
|
||||||
|
|
||||||
|
for (const doc of results.documents) {
|
||||||
|
const distance = parseFloat(doc.value?.distance || '1');
|
||||||
|
const similarity = 1 - distance; // Convert distance to similarity
|
||||||
|
|
||||||
|
if (similarity >= threshold) {
|
||||||
|
parsed.push({
|
||||||
|
pageId: doc.value?.page_id || doc.id.split(':')[1],
|
||||||
|
score: similarity,
|
||||||
|
metadata: {
|
||||||
|
workspaceId: doc.value?.workspace_id,
|
||||||
|
spaceId: doc.value?.space_id,
|
||||||
|
title: doc.value?.title,
|
||||||
|
distance,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIndexStats(): Promise<{
|
||||||
|
totalDocs: number;
|
||||||
|
indexSize: string;
|
||||||
|
vectorCount: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const info = await this.redis.ft.info(this.config.indexName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
//@ts-ignore
|
||||||
|
totalDocs: info.numDocs || 0,
|
||||||
|
//@ts-ignore
|
||||||
|
indexSize: info.indexSize || '0',
|
||||||
|
//@ts-ignore
|
||||||
|
vectorCount: info.numDocs || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get index stats: ${error?.['message']}`);
|
||||||
|
return { totalDocs: 0, indexSize: '0', vectorCount: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndex(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.ft.dropIndex(this.config.indexName);
|
||||||
|
this.isIndexCreated = false;
|
||||||
|
this.logger.log(`Deleted vector index ${this.config.indexName}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to delete index: ${error?.['message']}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.quit();
|
||||||
|
this.logger.log('Redis vector database disconnected');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to disconnect from Redis: ${error?.['message']}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface VectorSearchResult {
|
||||||
|
pageId: string;
|
||||||
|
score: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSearchOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
threshold?: number;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VectorService {
|
||||||
|
private readonly logger = new Logger(VectorService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cosine similarity between two vectors
|
||||||
|
*/
|
||||||
|
cosineSimilarity(vectorA: number[], vectorB: number[]): number {
|
||||||
|
if (vectorA.length !== vectorB.length) {
|
||||||
|
throw new Error('Vectors must have the same length');
|
||||||
|
}
|
||||||
|
|
||||||
|
let dotProduct = 0;
|
||||||
|
let normA = 0;
|
||||||
|
let normB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < vectorA.length; i++) {
|
||||||
|
dotProduct += vectorA[i] * vectorB[i];
|
||||||
|
normA += vectorA[i] * vectorA[i];
|
||||||
|
normB += vectorB[i] * vectorB[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
||||||
|
if (magnitude === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dotProduct / magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Euclidean distance between two vectors
|
||||||
|
*/
|
||||||
|
euclideanDistance(vectorA: number[], vectorB: number[]): number {
|
||||||
|
if (vectorA.length !== vectorB.length) {
|
||||||
|
throw new Error('Vectors must have the same length');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < vectorA.length; i++) {
|
||||||
|
const diff = vectorA[i] - vectorB[i];
|
||||||
|
sum += diff * diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sqrt(sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dot product similarity
|
||||||
|
*/
|
||||||
|
dotProductSimilarity(vectorA: number[], vectorB: number[]): number {
|
||||||
|
if (vectorA.length !== vectorB.length) {
|
||||||
|
throw new Error('Vectors must have the same length');
|
||||||
|
}
|
||||||
|
|
||||||
|
let dotProduct = 0;
|
||||||
|
for (let i = 0; i < vectorA.length; i++) {
|
||||||
|
dotProduct += vectorA[i] * vectorB[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dotProduct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a vector to unit length
|
||||||
|
*/
|
||||||
|
normalizeVector(vector: number[]): number[] {
|
||||||
|
const magnitude = Math.sqrt(
|
||||||
|
vector.reduce((sum, val) => sum + val * val, 0),
|
||||||
|
);
|
||||||
|
if (magnitude === 0) {
|
||||||
|
return vector;
|
||||||
|
}
|
||||||
|
return vector.map((val) => val / magnitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert vector to string format for Redis storage
|
||||||
|
*/
|
||||||
|
vectorToString(vector: number[]): string {
|
||||||
|
return vector.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse vector from string format
|
||||||
|
*/
|
||||||
|
stringToVector(vectorString: string): number[] {
|
||||||
|
return vectorString.split(',').map((val) => parseFloat(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate vector format and dimensions
|
||||||
|
*/
|
||||||
|
validateVector(vector: number[], expectedDimensions?: number): boolean {
|
||||||
|
if (!Array.isArray(vector)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vector.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedDimensions && vector.length !== expectedDimensions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vector.every((val) => typeof val === 'number' && !isNaN(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate similarity score with configurable method
|
||||||
|
*/
|
||||||
|
calculateSimilarity(
|
||||||
|
vectorA: number[],
|
||||||
|
vectorB: number[],
|
||||||
|
method: 'cosine' | 'euclidean' | 'dot' = 'cosine',
|
||||||
|
): number {
|
||||||
|
switch (method) {
|
||||||
|
case 'cosine':
|
||||||
|
return this.cosineSimilarity(vectorA, vectorB);
|
||||||
|
case 'euclidean': // Convert distance to similarity (0-1 scale)
|
||||||
|
{
|
||||||
|
const distance = this.euclideanDistance(vectorA, vectorB);
|
||||||
|
return 1 / (1 + distance);
|
||||||
|
}
|
||||||
|
case 'dot':
|
||||||
|
return this.dotProductSimilarity(vectorA, vectorB);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported similarity method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter results by similarity threshold
|
||||||
|
*/
|
||||||
|
filterByThreshold(
|
||||||
|
results: VectorSearchResult[],
|
||||||
|
threshold: number,
|
||||||
|
): VectorSearchResult[] {
|
||||||
|
return results.filter((result) => result.score >= threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort results by similarity score (descending)
|
||||||
|
*/
|
||||||
|
sortByScore(results: VectorSearchResult[]): VectorSearchResult[] {
|
||||||
|
return results.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply pagination to results
|
||||||
|
*/
|
||||||
|
paginateResults(
|
||||||
|
results: VectorSearchResult[],
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
): VectorSearchResult[] {
|
||||||
|
return results.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create vector index key for Redis
|
||||||
|
*/
|
||||||
|
createVectorKey(pageId: string, workspaceId: string): string {
|
||||||
|
return `vector:${workspaceId}:${pageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create metadata key for Redis
|
||||||
|
*/
|
||||||
|
createMetadataKey(pageId: string, workspaceId: string): string {
|
||||||
|
return `metadata:${workspaceId}:${pageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch process vectors with chunking
|
||||||
|
*/
|
||||||
|
async batchProcess<T, R>(
|
||||||
|
items: T[],
|
||||||
|
processor: (batch: T[]) => Promise<R[]>,
|
||||||
|
batchSize: number = 100,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const batch = items.slice(i, i + batchSize);
|
||||||
|
try {
|
||||||
|
const batchResults = await processor(batch);
|
||||||
|
results.push(...batchResults);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Batch processing failed for items ${i}-${i + batch.length}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -163,7 +108,7 @@ export class AuthController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.authService.getCollabToken(user, workspace.id);
|
return this.authService.getCollabToken(user.id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto';
|
|||||||
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
|
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
|
||||||
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
|
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
|
||||||
import { PasswordResetDto } from '../dto/password-reset.dto';
|
import { PasswordResetDto } from '../dto/password-reset.dto';
|
||||||
import { User, UserToken, Workspace } from '@docmost/db/types/entity.types';
|
import { UserToken, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { UserTokenType } from '../auth.constants';
|
import { UserTokenType } from '../auth.constants';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -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(
|
||||||
@@ -238,9 +222,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCollabToken(user: User, workspaceId: string) {
|
async getCollabToken(userId: string, workspaceId: string) {
|
||||||
const token = await this.tokenService.generateCollabToken(
|
const token = await this.tokenService.generateCollabToken(
|
||||||
user,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
);
|
);
|
||||||
return { token };
|
return { token };
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
JwtAttachmentPayload,
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
JwtMfaTokenPayload,
|
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
JwtType,
|
JwtType,
|
||||||
} from '../dto/jwt-payload';
|
} from '../dto/jwt-payload';
|
||||||
@@ -23,7 +22,7 @@ export class TokenService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateAccessToken(user: User): Promise<string> {
|
async generateAccessToken(user: User): Promise<string> {
|
||||||
if (user.deactivatedAt || user.deletedAt) {
|
if (user.deletedAt) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +35,12 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload);
|
return this.jwtService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
async generateCollabToken(
|
||||||
if (user.deactivatedAt || user.deletedAt) {
|
userId: string,
|
||||||
throw new ForbiddenException();
|
workspaceId: string,
|
||||||
}
|
): Promise<string> {
|
||||||
|
|
||||||
const payload: JwtCollabPayload = {
|
const payload: JwtCollabPayload = {
|
||||||
sub: user.id,
|
sub: userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
type: JwtType.COLLAB,
|
type: JwtType.COLLAB,
|
||||||
};
|
};
|
||||||
@@ -77,22 +75,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(),
|
||||||
@@ -43,10 +42,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
}
|
}
|
||||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
||||||
|
|
||||||
if (!user || user.deactivatedAt || user.deletedAt) {
|
if (!user || user.deletedAt) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, workspace };
|
return { user, workspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: FastifyRequest): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PageModule } from './page/page.module';
|
|||||||
import { AttachmentModule } from './attachment/attachment.module';
|
import { AttachmentModule } from './attachment/attachment.module';
|
||||||
import { CommentModule } from './comment/comment.module';
|
import { CommentModule } from './comment/comment.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { AiSearchModule } from './ai-search/ai-search.module';
|
||||||
import { SpaceModule } from './space/space.module';
|
import { SpaceModule } from './space/space.module';
|
||||||
import { GroupModule } from './group/group.module';
|
import { GroupModule } from './group/group.module';
|
||||||
import { CaslModule } from './casl/casl.module';
|
import { CaslModule } from './casl/casl.module';
|
||||||
@@ -26,6 +27,7 @@ import { ShareModule } from './share/share.module';
|
|||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
AiSearchModule,
|
||||||
SpaceModule,
|
SpaceModule,
|
||||||
GroupModule,
|
GroupModule,
|
||||||
CaslModule,
|
CaslModule,
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export class PageController {
|
|||||||
return this.pageService.getRecentPages(user.id, pagination);
|
return this.pageService.getRecentPages(user.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: scope to workspaces
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
@@ -154,10 +155,6 @@ export class PageController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
) {
|
) {
|
||||||
const page = await this.pageRepo.findById(dto.pageId);
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
if (!page) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class SearchService {
|
|||||||
if (suggestion.includeUsers) {
|
if (suggestion.includeUsers) {
|
||||||
users = await this.db
|
users = await this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
.select(['id', 'name', 'avatarUrl'])
|
||||||
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
|
|||||||
@@ -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...19197d2610
@@ -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"
|
||||||
|
|||||||
@@ -17,5 +17,4 @@ export * from "./lib/excalidraw";
|
|||||||
export * from "./lib/embed";
|
export * from "./lib/embed";
|
||||||
export * from "./lib/mention";
|
export * from "./lib/mention";
|
||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
export * from "./lib/search-and-replace";
|
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
|
|||||||
@@ -35,42 +35,6 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Mod-a": () => {
|
|
||||||
if (this.editor.isActive("codeBlock")) {
|
|
||||||
const { state } = this.editor;
|
|
||||||
const { $from } = state.selection;
|
|
||||||
|
|
||||||
let codeBlockNode = null;
|
|
||||||
let codeBlockPos = null;
|
|
||||||
let depth = 0;
|
|
||||||
|
|
||||||
for (depth = $from.depth; depth > 0; depth--) {
|
|
||||||
const node = $from.node(depth);
|
|
||||||
if (node.type.name === "codeBlock") {
|
|
||||||
codeBlockNode = node;
|
|
||||||
codeBlockPos = $from.start(depth) - 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codeBlockNode && codeBlockPos !== null) {
|
|
||||||
const codeBlockStart = codeBlockPos;
|
|
||||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
|
||||||
|
|
||||||
const contentStart = codeBlockStart + 1;
|
|
||||||
const contentEnd = codeBlockEnd - 1;
|
|
||||||
|
|
||||||
this.editor.commands.setTextSelection({
|
|
||||||
from: contentStart,
|
|
||||||
to: contentEnd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { SearchAndReplace } from './search-and-replace'
|
|
||||||
export * from './search-and-replace'
|
|
||||||
export default SearchAndReplace
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
/***
|
|
||||||
MIT License
|
|
||||||
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
***/
|
|
||||||
|
|
||||||
import { Extension, Range, type Dispatch } from "@tiptap/core";
|
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
||||||
import {
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
type EditorState,
|
|
||||||
type Transaction,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
search: {
|
|
||||||
/**
|
|
||||||
* @description Set search term in extension.
|
|
||||||
*/
|
|
||||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Set replace term in extension.
|
|
||||||
*/
|
|
||||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Set case sensitivity in extension.
|
|
||||||
*/
|
|
||||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Reset current search result to first instance.
|
|
||||||
*/
|
|
||||||
resetIndex: () => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Find next instance of search result.
|
|
||||||
*/
|
|
||||||
nextSearchResult: () => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Find previous instance of search result.
|
|
||||||
*/
|
|
||||||
previousSearchResult: () => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Replace first instance of search result with given replace term.
|
|
||||||
*/
|
|
||||||
replace: () => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Replace all instances of search result with given replace term.
|
|
||||||
*/
|
|
||||||
replaceAll: () => ReturnType;
|
|
||||||
/**
|
|
||||||
* @description Find selected instance of search result.
|
|
||||||
*/
|
|
||||||
selectCurrentItem: () => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextNodesWithPosition {
|
|
||||||
text: string;
|
|
||||||
pos: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRegex = (
|
|
||||||
s: string,
|
|
||||||
disableRegex: boolean,
|
|
||||||
caseSensitive: boolean,
|
|
||||||
): RegExp => {
|
|
||||||
return RegExp(
|
|
||||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
|
|
||||||
caseSensitive ? "gu" : "gui",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProcessedSearches {
|
|
||||||
decorationsToReturn: DecorationSet;
|
|
||||||
results: Range[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function processSearches(
|
|
||||||
doc: PMNode,
|
|
||||||
searchTerm: RegExp,
|
|
||||||
searchResultClass: string,
|
|
||||||
resultIndex: number,
|
|
||||||
): ProcessedSearches {
|
|
||||||
const decorations: Decoration[] = [];
|
|
||||||
const results: Range[] = [];
|
|
||||||
|
|
||||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
|
||||||
return {
|
|
||||||
decorationsToReturn: DecorationSet.empty,
|
|
||||||
results: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
doc?.descendants((node, pos) => {
|
|
||||||
if (node.isText) {
|
|
||||||
if (textNodesWithPosition[index]) {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: textNodesWithPosition[index].text + node.text,
|
|
||||||
pos: textNodesWithPosition[index].pos,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: `${node.text}`,
|
|
||||||
pos,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
|
||||||
|
|
||||||
for (const element of textNodesWithPosition) {
|
|
||||||
const { text, pos } = element;
|
|
||||||
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
|
||||||
([matchText]) => matchText.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const m of matches) {
|
|
||||||
if (m[0] === "") break;
|
|
||||||
|
|
||||||
if (m.index !== undefined) {
|
|
||||||
results.push({
|
|
||||||
from: pos + m.index,
|
|
||||||
to: pos + m.index + m[0].length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
|
||||||
const r = results[i];
|
|
||||||
const className =
|
|
||||||
i === resultIndex
|
|
||||||
? `${searchResultClass} ${searchResultClass}-current`
|
|
||||||
: searchResultClass;
|
|
||||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
|
||||||
class: className,
|
|
||||||
});
|
|
||||||
|
|
||||||
decorations.push(decoration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const replace = (
|
|
||||||
replaceTerm: string,
|
|
||||||
results: Range[],
|
|
||||||
resultIndex: number,
|
|
||||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
|
|
||||||
) => {
|
|
||||||
const firstResult = results[resultIndex];
|
|
||||||
|
|
||||||
if (!firstResult) return;
|
|
||||||
|
|
||||||
const { from, to } = results[resultIndex];
|
|
||||||
|
|
||||||
if (dispatch) {
|
|
||||||
const tr = state.tr;
|
|
||||||
|
|
||||||
// Get all marks that span the text being replaced
|
|
||||||
const marksSet = new Set<Mark>();
|
|
||||||
state.doc.nodesBetween(from, to, (node) => {
|
|
||||||
if (node.isText && node.marks) {
|
|
||||||
node.marks.forEach(mark => marksSet.add(mark));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
|
||||||
|
|
||||||
// Delete the old text and insert new text with preserved marks
|
|
||||||
tr.delete(from, to);
|
|
||||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
|
||||||
|
|
||||||
dispatch(tr);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceAll = (
|
|
||||||
replaceTerm: string,
|
|
||||||
results: Range[],
|
|
||||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
|
|
||||||
) => {
|
|
||||||
const resultsCopy = results.slice();
|
|
||||||
|
|
||||||
if (!resultsCopy.length) return;
|
|
||||||
|
|
||||||
// Process replacements in reverse order to avoid position shifting issues
|
|
||||||
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
|
|
||||||
const { from, to } = resultsCopy[i];
|
|
||||||
|
|
||||||
// Get all marks that span the text being replaced
|
|
||||||
const marksSet = new Set<Mark>();
|
|
||||||
tr.doc.nodesBetween(from, to, (node) => {
|
|
||||||
if (node.isText && node.marks) {
|
|
||||||
node.marks.forEach(mark => marksSet.add(mark));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
|
||||||
|
|
||||||
// Delete and insert with preserved marks
|
|
||||||
tr.delete(from, to);
|
|
||||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(tr);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplacePluginKey = new PluginKey(
|
|
||||||
"searchAndReplacePlugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface SearchAndReplaceOptions {
|
|
||||||
searchResultClass: string;
|
|
||||||
disableRegex: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchAndReplaceStorage {
|
|
||||||
searchTerm: string;
|
|
||||||
replaceTerm: string;
|
|
||||||
results: Range[];
|
|
||||||
lastSearchTerm: string;
|
|
||||||
caseSensitive: boolean;
|
|
||||||
lastCaseSensitive: boolean;
|
|
||||||
resultIndex: number;
|
|
||||||
lastResultIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchAndReplace = Extension.create<
|
|
||||||
SearchAndReplaceOptions,
|
|
||||||
SearchAndReplaceStorage
|
|
||||||
>({
|
|
||||||
name: "searchAndReplace",
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
searchResultClass: "search-result",
|
|
||||||
disableRegex: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addStorage() {
|
|
||||||
return {
|
|
||||||
searchTerm: "",
|
|
||||||
replaceTerm: "",
|
|
||||||
results: [],
|
|
||||||
lastSearchTerm: "",
|
|
||||||
caseSensitive: false,
|
|
||||||
lastCaseSensitive: false,
|
|
||||||
resultIndex: 0,
|
|
||||||
lastResultIndex: 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setSearchTerm:
|
|
||||||
(searchTerm: string) =>
|
|
||||||
({ editor }) => {
|
|
||||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
setReplaceTerm:
|
|
||||||
(replaceTerm: string) =>
|
|
||||||
({ editor }) => {
|
|
||||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
setCaseSensitive:
|
|
||||||
(caseSensitive: boolean) =>
|
|
||||||
({ editor }) => {
|
|
||||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
resetIndex:
|
|
||||||
() =>
|
|
||||||
({ editor }) => {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = 0;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
nextSearchResult:
|
|
||||||
() =>
|
|
||||||
({ editor }) => {
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
|
|
||||||
const nextIndex = resultIndex + 1;
|
|
||||||
|
|
||||||
if (results[nextIndex]) {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
|
||||||
} else {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
previousSearchResult:
|
|
||||||
() =>
|
|
||||||
({ editor }) => {
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
|
||||||
|
|
||||||
const prevIndex = resultIndex - 1;
|
|
||||||
|
|
||||||
if (results[prevIndex]) {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
|
||||||
} else {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
replace:
|
|
||||||
() =>
|
|
||||||
({ editor, state, dispatch }) => {
|
|
||||||
const { replaceTerm, results, resultIndex } =
|
|
||||||
editor.storage.searchAndReplace;
|
|
||||||
|
|
||||||
replace(replaceTerm, results, resultIndex, { state, dispatch });
|
|
||||||
|
|
||||||
// After replace, adjust index if needed
|
|
||||||
// The results will be recalculated by the plugin, but we need to ensure
|
|
||||||
// the index doesn't exceed the new bounds
|
|
||||||
setTimeout(() => {
|
|
||||||
const newResultsLength = editor.storage.searchAndReplace.results.length;
|
|
||||||
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
|
|
||||||
// Keep the same position if possible, otherwise go to the last result
|
|
||||||
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
replaceAll:
|
|
||||||
() =>
|
|
||||||
({ editor, tr, dispatch }) => {
|
|
||||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
|
||||||
|
|
||||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
selectCurrentItem:
|
|
||||||
() =>
|
|
||||||
({ editor }) => {
|
|
||||||
const { results } = editor.storage.searchAndReplace;
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
if (
|
|
||||||
results[i].from == editor.state.selection.from &&
|
|
||||||
results[i].to == editor.state.selection.to
|
|
||||||
) {
|
|
||||||
editor.storage.searchAndReplace.resultIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
const editor = this.editor;
|
|
||||||
const { searchResultClass, disableRegex } = this.options;
|
|
||||||
|
|
||||||
const setLastSearchTerm = (t: string) =>
|
|
||||||
(editor.storage.searchAndReplace.lastSearchTerm = t);
|
|
||||||
const setLastCaseSensitive = (t: boolean) =>
|
|
||||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
|
||||||
const setLastResultIndex = (t: number) =>
|
|
||||||
(editor.storage.searchAndReplace.lastResultIndex = t);
|
|
||||||
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: searchAndReplacePluginKey,
|
|
||||||
state: {
|
|
||||||
init: () => DecorationSet.empty,
|
|
||||||
apply({ doc, docChanged }, oldState) {
|
|
||||||
const {
|
|
||||||
searchTerm,
|
|
||||||
lastSearchTerm,
|
|
||||||
caseSensitive,
|
|
||||||
lastCaseSensitive,
|
|
||||||
resultIndex,
|
|
||||||
lastResultIndex,
|
|
||||||
} = editor.storage.searchAndReplace;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!docChanged &&
|
|
||||||
lastSearchTerm === searchTerm &&
|
|
||||||
lastCaseSensitive === caseSensitive &&
|
|
||||||
lastResultIndex === resultIndex
|
|
||||||
)
|
|
||||||
return oldState;
|
|
||||||
|
|
||||||
setLastSearchTerm(searchTerm);
|
|
||||||
setLastCaseSensitive(caseSensitive);
|
|
||||||
setLastResultIndex(resultIndex);
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
|
||||||
editor.storage.searchAndReplace.results = [];
|
|
||||||
return DecorationSet.empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { decorationsToReturn, results } = processSearches(
|
|
||||||
doc,
|
|
||||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
|
||||||
searchResultClass,
|
|
||||||
resultIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
editor.storage.searchAndReplace.results = results;
|
|
||||||
|
|
||||||
return decorationsToReturn;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return this.getState(state);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SearchAndReplace;
|
|
||||||
@@ -3,35 +3,4 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
|
|||||||
export const TableCell = TiptapTableCell.extend({
|
export const TableCell = TiptapTableCell.extend({
|
||||||
name: "tableCell",
|
name: "tableCell",
|
||||||
content: "paragraph+",
|
content: "paragraph+",
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
backgroundColor: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.style.backgroundColor || null,
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.backgroundColor) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
style: `background-color: ${attributes.backgroundColor}`,
|
|
||||||
'data-background-color': attributes.backgroundColor,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backgroundColorName: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.backgroundColorName) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
export const TableHeader = TiptapTableHeader.extend({
|
|
||||||
name: "tableHeader",
|
|
||||||
content: "paragraph+",
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
backgroundColor: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.style.backgroundColor || null,
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.backgroundColor) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
style: `background-color: ${attributes.backgroundColor}`,
|
|
||||||
'data-background-color': attributes.backgroundColor,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backgroundColorName: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.backgroundColorName) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./row";
|
export * from "./row";
|
||||||
export * from "./cell";
|
export * from "./cell";
|
||||||
export * from "./header";
|
|
||||||
|
|||||||
Generated
+136
-176
@@ -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
|
||||||
@@ -228,23 +222,23 @@ importers:
|
|||||||
specifier: 0.18.0-864353b
|
specifier: 0.18.0-864353b
|
||||||
version: 0.18.0-864353b(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.18.0-864353b(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/form':
|
'@mantine/form':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(react@18.3.1)
|
version: 7.17.0(react@18.3.1)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(react@18.3.1)
|
version: 7.17.0(react@18.3.1)
|
||||||
'@mantine/modals':
|
'@mantine/modals':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/notifications':
|
'@mantine/notifications':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/spotlight':
|
'@mantine/spotlight':
|
||||||
specifier: ^8.1.3
|
specifier: ^7.17.0
|
||||||
version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tabler/icons-react':
|
'@tabler/icons-react':
|
||||||
specifier: ^3.34.0
|
specifier: ^3.34.0
|
||||||
version: 3.34.0(react@18.3.1)
|
version: 3.34.0(react@18.3.1)
|
||||||
@@ -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
|
||||||
@@ -540,12 +531,12 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^7.0.3
|
specifier: ^7.0.3
|
||||||
version: 7.0.3
|
version: 7.0.3
|
||||||
|
openai:
|
||||||
|
specifier: ^5.8.2
|
||||||
|
version: 5.8.2(ws@8.18.2)(zod@3.25.56)
|
||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^5.7.1
|
specifier: ^5.7.1
|
||||||
version: 5.7.1
|
version: 5.7.1
|
||||||
otpauth:
|
|
||||||
specifier: ^9.4.0
|
|
||||||
version: 9.4.0
|
|
||||||
passport-google-oauth20:
|
passport-google-oauth20:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -564,6 +555,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
redis:
|
||||||
|
specifier: ^5.5.6
|
||||||
|
version: 5.5.6
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -2515,49 +2509,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
'@mantine/core@8.1.3':
|
'@mantine/core@7.17.0':
|
||||||
resolution: {integrity: sha512-2WOPC8GSN3MApet0MccSn6LaXRhcP6SVtZnbuHoqJ/atrfK7kLE66ILr4OXov7JAj1ASJ4Xk0bOXmu5fBExAvQ==}
|
resolution: {integrity: sha512-AU5UFewUNzBCUXIq5Jk6q402TEri7atZW61qHW6P0GufJ2W/JxGHRvgmHOVHTVIcuWQRCt9SBSqZoZ/vHs9LhA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/hooks': 8.1.3
|
'@mantine/hooks': 7.17.0
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/form@8.1.3':
|
'@mantine/form@7.17.0':
|
||||||
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
|
resolution: {integrity: sha512-LONdeb+wL8h9fvyQ339ZFLxqrvYff+b+H+kginZhnr45OBTZDLXNVAt/YoKVFEkynF9WDJjdBVrXKcOZvPgmrA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/hooks@8.1.3':
|
'@mantine/hooks@7.17.0':
|
||||||
resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==}
|
resolution: {integrity: sha512-vo3K49mLy1nJ8LQNb5KDbJgnX0xwt3Y8JOF3ythjB5LEFMptdLSSgulu64zj+QHtzvffFCsMb05DbTLLpVP/JQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/modals@8.1.3':
|
'@mantine/modals@7.17.0':
|
||||||
resolution: {integrity: sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==}
|
resolution: {integrity: sha512-4sfiFxIxMxfm2RH4jXMN+cr8tFS5AexXG4TY7TRN/ySdkiWtFVvDe5l2/KRWWeWwDUb7wQhht8Ompj5KtexlEA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 8.1.3
|
'@mantine/core': 7.17.0
|
||||||
'@mantine/hooks': 8.1.3
|
'@mantine/hooks': 7.17.0
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/notifications@8.1.3':
|
'@mantine/notifications@7.17.0':
|
||||||
resolution: {integrity: sha512-Xy6f/l1yLTo77hz8X80sOuY+HW80e1rn8ucygx9TAexK5+XtyriOv26TQ3EJ6Ej5jlchtZRFEUJ4tJGRWjGCNg==}
|
resolution: {integrity: sha512-xejr1WW02NrrrE4HPDoownILJubcjLLwCDeTk907ZeeHKBEPut7RukEq6gLzOZBhNhKdPM+vCM7GcbXdaLZq/Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 8.1.3
|
'@mantine/core': 7.17.0
|
||||||
'@mantine/hooks': 8.1.3
|
'@mantine/hooks': 7.17.0
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/spotlight@8.1.3':
|
'@mantine/spotlight@7.17.0':
|
||||||
resolution: {integrity: sha512-GhJbSoUdcALGSMLC/zjVVncRDyvxwxjtlzFeHLuY0Dgkgj+60x3tnzAulDrqYVhLMk7fGyex22VV/Xwl7mG1+Q==}
|
resolution: {integrity: sha512-T7xfXxyDg2fxf7qvKwBozQ8HBnTQ2GRCIIoeYdAoiHoFQUS7NbBAnqrjdr5iYZpJqyLRXn8uFI7DX1Zdzd6/PQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 8.1.3
|
'@mantine/core': 7.17.0
|
||||||
'@mantine/hooks': 8.1.3
|
'@mantine/hooks': 7.17.0
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/store@8.1.3':
|
'@mantine/store@7.17.0':
|
||||||
resolution: {integrity: sha512-rO72LfSJqSNCwufqJxTWiHMyOR6sR3mqAcnBcw/f5aTvyOYoHZzlm4q4+TL8/2vYGRVsr9YM2Ez6HQ1vk/RR8g==}
|
resolution: {integrity: sha512-nhWRYRLqvAjrD/ApKCXxuHyTWg2b5dC06Z5gmO8udj4pBgndNf9nmCl+Of90H6bgOa56moJA7UQyXoF1SfxqVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
@@ -2855,10 +2849,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'}
|
||||||
@@ -3365,6 +3355,34 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
|
||||||
|
'@redis/bloom@5.5.6':
|
||||||
|
resolution: {integrity: sha512-bNR3mxkwtfuCxNOzfV8B3R5zA1LiN57EH6zK4jVBIgzMzliNuReZXBFGnXvsi80/SYohajn78YdpYI+XNpqL+A==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@redis/client': ^5.5.6
|
||||||
|
|
||||||
|
'@redis/client@5.5.6':
|
||||||
|
resolution: {integrity: sha512-M3Svdwt6oSfyfQdqEr0L2HOJH2vK7GgCFx1NfAQvpWAT4+ljoT1L5S5cKT3dA9NJrxrOPDkdoTPWJnIrGCOcmw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
'@redis/json@5.5.6':
|
||||||
|
resolution: {integrity: sha512-AIsoe3SsGQagqAmSQHaqxEinm5oCWr7zxPWL90kKaEdLJ+zw8KBznf2i9oK0WUFP5pFssSQUXqnscQKe2amfDQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@redis/client': ^5.5.6
|
||||||
|
|
||||||
|
'@redis/search@5.5.6':
|
||||||
|
resolution: {integrity: sha512-JSqasYqO0mVcHL7oxvbySRBBZYRYhFl3W7f0Da7BW8M/r0Z9wCiVrdjnN4/mKBpWZkoJT/iuisLUdPGhpKxBew==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@redis/client': ^5.5.6
|
||||||
|
|
||||||
|
'@redis/time-series@5.5.6':
|
||||||
|
resolution: {integrity: sha512-jkpcgq3NOI3TX7xEAJ3JgesJTxAx7k0m6lNxNsYdEM8KOl+xj7GaB/0CbLkoricZDmFSEAz7ClA1iK9XkGHf+Q==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@redis/client': ^5.5.6
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
@@ -4358,9 +4376,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 +5123,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 +5549,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 +5648,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 +7192,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
|
||||||
@@ -7640,6 +7638,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
openai@5.8.2:
|
||||||
|
resolution: {integrity: sha512-8C+nzoHYgyYOXhHGN6r0fcb4SznuEn1R7YZMvlqDbnCuE0FM2mm3T1HiYW6WIcMS/F1Of2up/cSPjLPaWt0X9Q==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.23.8
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
openid-client@5.7.1:
|
openid-client@5.7.1:
|
||||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||||
|
|
||||||
@@ -7665,9 +7675,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'}
|
||||||
@@ -7910,10 +7917,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 +8157,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'}
|
||||||
@@ -8313,8 +8311,8 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
react-textarea-autosize@8.5.9:
|
react-textarea-autosize@8.5.6:
|
||||||
resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
|
resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
@@ -8374,6 +8372,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis@5.5.6:
|
||||||
|
resolution: {integrity: sha512-hbpqBfcuhWHOS9YLNcXcJ4akNr7HFX61Dq3JuFZ9S7uU7C7kvnzuH2PDIXOP62A3eevvACoG8UacuXP3N07xdg==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
redlock@4.2.0:
|
redlock@4.2.0:
|
||||||
resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==}
|
resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -8428,9 +8430,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 +9435,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 +9574,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 +9593,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'}
|
||||||
@@ -12293,55 +12278,55 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/ms@2.0.2': {}
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/hooks': 8.1.3(react@18.3.1)
|
'@mantine/hooks': 7.17.0(react@18.3.1)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
react-number-format: 5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
react-number-format: 5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-remove-scroll: 2.6.3(@types/react@18.3.12)(react@18.3.1)
|
react-remove-scroll: 2.6.3(@types/react@18.3.12)(react@18.3.1)
|
||||||
react-textarea-autosize: 8.5.9(@types/react@18.3.12)(react@18.3.1)
|
react-textarea-autosize: 8.5.6(@types/react@18.3.12)(react@18.3.1)
|
||||||
type-fest: 4.28.1
|
type-fest: 4.28.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/form@8.1.3(react@18.3.1)':
|
'@mantine/form@7.17.0(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
klona: 2.0.6
|
klona: 2.0.6
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
'@mantine/hooks@8.1.3(react@18.3.1)':
|
'@mantine/hooks@7.17.0(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
'@mantine/modals@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@mantine/modals@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/hooks': 8.1.3(react@18.3.1)
|
'@mantine/hooks': 7.17.0(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
'@mantine/notifications@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@mantine/notifications@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/hooks': 8.1.3(react@18.3.1)
|
'@mantine/hooks': 7.17.0(react@18.3.1)
|
||||||
'@mantine/store': 8.1.3(react@18.3.1)
|
'@mantine/store': 7.17.0(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|
||||||
'@mantine/spotlight@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@mantine/spotlight@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/hooks': 8.1.3(react@18.3.1)
|
'@mantine/hooks': 7.17.0(react@18.3.1)
|
||||||
'@mantine/store': 8.1.3(react@18.3.1)
|
'@mantine/store': 7.17.0(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
'@mantine/store@8.1.3(react@18.3.1)':
|
'@mantine/store@7.17.0(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
@@ -12593,8 +12578,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
|
||||||
@@ -13095,6 +13078,26 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
'@redis/bloom@5.5.6(@redis/client@5.5.6)':
|
||||||
|
dependencies:
|
||||||
|
'@redis/client': 5.5.6
|
||||||
|
|
||||||
|
'@redis/client@5.5.6':
|
||||||
|
dependencies:
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
|
||||||
|
'@redis/json@5.5.6(@redis/client@5.5.6)':
|
||||||
|
dependencies:
|
||||||
|
'@redis/client': 5.5.6
|
||||||
|
|
||||||
|
'@redis/search@5.5.6(@redis/client@5.5.6)':
|
||||||
|
dependencies:
|
||||||
|
'@redis/client': 5.5.6
|
||||||
|
|
||||||
|
'@redis/time-series@5.5.6(@redis/client@5.5.6)':
|
||||||
|
dependencies:
|
||||||
|
'@redis/client': 5.5.6
|
||||||
|
|
||||||
'@remirror/core-constants@3.0.0': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||||
@@ -14225,10 +14228,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 +15183,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 +15625,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 +15703,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 +17640,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
|
||||||
@@ -18226,6 +18210,11 @@ snapshots:
|
|||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
is-wsl: 2.2.0
|
is-wsl: 2.2.0
|
||||||
|
|
||||||
|
openai@5.8.2(ws@8.18.2)(zod@3.25.56):
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.18.2
|
||||||
|
zod: 3.25.56
|
||||||
|
|
||||||
openid-client@5.7.1:
|
openid-client@5.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
jose: 4.15.9
|
jose: 4.15.9
|
||||||
@@ -18271,10 +18260,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
|
||||||
@@ -18518,8 +18503,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 +18757,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
|
||||||
@@ -18947,7 +18924,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.12
|
'@types/react': 18.3.12
|
||||||
|
|
||||||
react-textarea-autosize@8.5.9(@types/react@18.3.12)(react@18.3.1):
|
react-textarea-autosize@8.5.6(@types/react@18.3.12)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.25.6
|
'@babel/runtime': 7.25.6
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -19020,6 +18997,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
|
redis@5.5.6:
|
||||||
|
dependencies:
|
||||||
|
'@redis/bloom': 5.5.6(@redis/client@5.5.6)
|
||||||
|
'@redis/client': 5.5.6
|
||||||
|
'@redis/json': 5.5.6(@redis/client@5.5.6)
|
||||||
|
'@redis/search': 5.5.6(@redis/client@5.5.6)
|
||||||
|
'@redis/time-series': 5.5.6(@redis/client@5.5.6)
|
||||||
|
|
||||||
redlock@4.2.0:
|
redlock@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bluebird: 3.7.2
|
bluebird: 3.7.2
|
||||||
@@ -19080,8 +19065,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 +20126,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 +20231,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 +20241,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
|
||||||
|
|||||||
Reference in New Issue
Block a user