mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 07:43:06 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf44914ad |
@@ -16,12 +16,12 @@
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^8.1.3",
|
||||
"@mantine/form": "^8.1.3",
|
||||
"@mantine/hooks": "^8.1.3",
|
||||
"@mantine/modals": "^8.1.3",
|
||||
"@mantine/notifications": "^8.1.3",
|
||||
"@mantine/spotlight": "^8.1.3",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
@@ -39,7 +39,6 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||
"Default page edit mode": "Default page edit mode",
|
||||
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
|
||||
"Reading": "Reading",
|
||||
"Reading": "Reading"
|
||||
"Delete member": "Delete member",
|
||||
"Member deleted successfully": "Member deleted successfully",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||
@@ -389,81 +389,5 @@
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"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"
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
+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 Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -47,11 +45,6 @@ export default function App() {
|
||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||
<Route
|
||||
path={"/login/mfa/setup"}
|
||||
element={<MfaSetupRequiredPage />}
|
||||
/>
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
@@ -65,10 +58,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<Route element={<ShareLayout />}>
|
||||
<Route
|
||||
path={"/share/:shareId/p/:pageSlug"}
|
||||
element={<SharedPage />}
|
||||
/>
|
||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { Group, Menu, UnstyledButton, Text } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrightnessFilled,
|
||||
IconBrush,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDeviceDesktop,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -31,7 +19,6 @@ export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@@ -88,7 +75,7 @@ export default function TopMenu() {
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<div style={{width: 190}}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
@@ -114,44 +101,6 @@ export default function TopMenu() {
|
||||
{t("My preferences")}
|
||||
</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.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
|
||||
@@ -117,8 +117,7 @@ export default function BillingDetails() {
|
||||
{billing.billingScheme === "tiered" && (
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()} /{" "}
|
||||
{billing.interval}
|
||||
${billing.amount / 100} {billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
per {billing.interval}
|
||||
@@ -130,7 +129,7 @@ export default function BillingDetails() {
|
||||
<>
|
||||
<Text fw={700} fz="lg">
|
||||
{(billing.amount / 100) * billing.quantity}{" "}
|
||||
{billing.currency.toUpperCase()} / {billing.interval}
|
||||
{billing.currency.toUpperCase()}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
${billing.amount / 100} /user/{billing.interval}
|
||||
|
||||
@@ -12,18 +12,14 @@ import {
|
||||
Badge,
|
||||
Flex,
|
||||
Switch,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
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 { 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() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const [isAnnual, setIsAnnual] = useState(true);
|
||||
const [selectedTierValue, setSelectedTierValue] = useState<string | 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if any plan is tiered
|
||||
const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0);
|
||||
const firstPlan = plans[0];
|
||||
|
||||
// Set initial tier value if not set and we have tiered plans
|
||||
if (hasTieredPlans && !selectedTierValue && firstTieredPlan) {
|
||||
setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString());
|
||||
// Set initial tier value if not set
|
||||
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
|
||||
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
// For tiered plans, ensure we have a selected tier
|
||||
if (hasTieredPlans && !selectedTierValue) {
|
||||
if (!selectedTierValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectData = firstTieredPlan?.pricingTiers
|
||||
?.filter((tier) => !tier.custom)
|
||||
const selectData = firstPlan.pricingTiers
|
||||
.filter((tier) => !tier.custom)
|
||||
.map((tier, index) => {
|
||||
const prevMaxUsers =
|
||||
index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0;
|
||||
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
|
||||
return {
|
||||
value: tier.upTo.toString(),
|
||||
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
|
||||
};
|
||||
}) || [];
|
||||
});
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<Stack gap="xl" mb="md">
|
||||
{/* Team Size and Billing Controls */}
|
||||
<Group justify="center" align="center" gap="sm">
|
||||
{hasTieredPlans && (
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Team size"
|
||||
description="Select the number of users"
|
||||
value={selectedTierValue}
|
||||
onChange={setSelectedTierValue}
|
||||
data={selectData}
|
||||
w={250}
|
||||
size="md"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
<Group justify="center" align="start">
|
||||
<Flex justify="center" gap="md" align="center">
|
||||
@@ -133,29 +102,17 @@ export default function BillingPlans() {
|
||||
{/* Plans Grid */}
|
||||
<Group justify="center" gap="lg" align="stretch">
|
||||
{plans.map((plan, index) => {
|
||||
let price;
|
||||
let displayPrice;
|
||||
const tieredPlan = plan;
|
||||
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;
|
||||
|
||||
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 (
|
||||
<Card
|
||||
key={plan.name}
|
||||
@@ -186,27 +143,25 @@ export default function BillingPlans() {
|
||||
<Stack gap="xs">
|
||||
<Group align="baseline" gap="xs">
|
||||
<Title order={1} size="h1">
|
||||
${displayPrice}
|
||||
${isAnnual ? (price / 12).toFixed(0) : price}
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed">
|
||||
{plan.billingScheme === 'per_unit'
|
||||
? `per user/month`
|
||||
: `per month`}
|
||||
per {isAnnual ? "month" : "month"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAnnual ? "Billed annually" : "Billed monthly"}
|
||||
</Text>
|
||||
{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
|
||||
{isAnnual && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Billed annually
|
||||
</Text>
|
||||
)}
|
||||
<Text size="md" fw={500}>
|
||||
For {planSelectedTier.upTo} users
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
Upgrade
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface IBillingPlan {
|
||||
};
|
||||
features: string[];
|
||||
billingScheme: string | null;
|
||||
pricingTiers?: PricingTier[];
|
||||
pricingTiers: 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 useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,10 +33,6 @@ export default function Security() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<EnforceMfa />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "mantine-form-zod-resolver";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { IRegister } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
|
||||
@@ -27,7 +27,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,17 +39,9 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await login(data);
|
||||
await login(data);
|
||||
setIsLoading(false);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response?.userHasMfa) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
|
||||
} else if (response?.requiresMfaSetup) {
|
||||
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
console.log(err);
|
||||
@@ -64,19 +56,9 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await acceptInvitation(data);
|
||||
await acceptInvitation(data);
|
||||
setIsLoading(false);
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Account created successfully. Please log in to set up two-factor authentication.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
@@ -118,22 +100,12 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await passwordReset(data);
|
||||
await passwordReset(data);
|
||||
setIsLoading(false);
|
||||
|
||||
if (response?.requiresLogin) {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Password reset was successful. Please log in with your new password.",
|
||||
),
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
} else {
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
}
|
||||
navigate(APP_ROUTE.HOME);
|
||||
notifications.show({
|
||||
message: t("Password reset was successful"),
|
||||
});
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
|
||||
@@ -4,16 +4,14 @@ import {
|
||||
ICollabToken,
|
||||
IForgotPassword,
|
||||
ILogin,
|
||||
ILoginResponse,
|
||||
IPasswordReset,
|
||||
ISetupWorkspace,
|
||||
IVerifyUserToken,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
export async function login(data: ILogin): Promise<ILoginResponse> {
|
||||
const response = await api.post<ILoginResponse>("/auth/login", data);
|
||||
return response.data;
|
||||
export async function login(data: ILogin): Promise<void> {
|
||||
await api.post<void>("/auth/login", data);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/auth/password-reset", data);
|
||||
return req.data;
|
||||
export async function passwordReset(data: IPasswordReset): Promise<void> {
|
||||
await api.post<void>("/auth/password-reset", data);
|
||||
}
|
||||
|
||||
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> {
|
||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||
return req.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,3 @@ export interface IVerifyUserToken {
|
||||
export interface ICollabToken {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface ILoginResponse {
|
||||
userHasMfa?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
mfaToken?: string;
|
||||
isMfaEnforced?: boolean;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
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 {
|
||||
|
||||
-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,
|
||||
IconRowRemove,
|
||||
IconSquareToggle,
|
||||
IconTableRow,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@@ -48,10 +45,6 @@ export const TableCellMenu = React.memo(
|
||||
editor.chain().focus().deleteRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderCell = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderCell().run();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -67,9 +60,6 @@ export const TableCellMenu = React.memo(
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
@@ -113,17 +103,6 @@ export const TableCellMenu = React.memo(
|
||||
<IconRowRemove size={18} />
|
||||
</ActionIcon>
|
||||
</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>
|
||||
</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 { Color } from "@tiptap/extension-color";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
MathInline,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TrailingNode,
|
||||
TiptapImage,
|
||||
Callout,
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -218,22 +217,6 @@ export const mainExtensions = [
|
||||
CharacterCount.configure({
|
||||
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;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 * as Y from "yjs";
|
||||
import {
|
||||
@@ -39,7 +44,6 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
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 { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
@@ -126,15 +130,7 @@ export default function PageEditor({
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
remote.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
remote.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
refetchCollabToken();
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
@@ -160,21 +156,6 @@ export default function PageEditor({
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
@@ -217,10 +198,6 @@ export default function PageEditor({
|
||||
scrollMargin: 80,
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||
const slashCommand = document.querySelector("#slash-command");
|
||||
if (slashCommand) {
|
||||
@@ -373,11 +350,6 @@ export default function PageEditor({
|
||||
<div style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
{editor && editor.isEditable && (
|
||||
<div>
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
|
||||
@@ -71,12 +71,4 @@
|
||||
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
|
||||
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 "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./find.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;
|
||||
& table {
|
||||
overflow-x: hidden;
|
||||
min-width: 700px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +38,8 @@
|
||||
|
||||
th {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
@@ -67,54 +66,8 @@
|
||||
position: absolute;
|
||||
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,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
@@ -43,8 +40,7 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
@@ -112,12 +108,7 @@ export function TitleEditor({
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
@@ -161,19 +152,13 @@ export function TitleEditor({
|
||||
}
|
||||
}, [userPageEditMode, titleEditor, editable]);
|
||||
|
||||
const openSearchDialog = () => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
function handleTitleKeyDown(event: any) {
|
||||
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
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
||||
return;
|
||||
|
||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||
|
||||
const { key } = event;
|
||||
const { $head } = titleEditor.state.selection;
|
||||
|
||||
@@ -187,16 +172,5 @@ export function TitleEditor({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconList,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -17,12 +16,7 @@ import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import {
|
||||
getHotkeyHandler,
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useHotkeys,
|
||||
} from "@mantine/hooks";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -38,7 +32,6 @@ import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} 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 { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.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 [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
[
|
||||
"mod+F",
|
||||
() => {
|
||||
const event = new CustomEvent("openFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
[
|
||||
"Escape",
|
||||
() => {
|
||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||
document.dispatchEvent(event);
|
||||
},
|
||||
],
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{yjsConnectionStatus === "disconnected" && (
|
||||
|
||||
@@ -26,9 +26,6 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
{option["type"] === "group" && <IconGroupCircle />}
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
{option["type"] === "user" && option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
@@ -50,7 +47,6 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
const userItems = suggestion?.users.map((user: IUser) => ({
|
||||
value: `user-${user.id}`,
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
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 (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div>
|
||||
<Text size="md">{t("Email")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentUser?.user.email}
|
||||
@@ -30,7 +30,7 @@ export default function ChangeEmail() {
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||
<Button onClick={open} variant="default">
|
||||
{t("Change email")}
|
||||
</Button>
|
||||
*/}
|
||||
|
||||
@@ -14,14 +14,14 @@ export default function ChangePassword() {
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div>
|
||||
<Text size="md">{t("Password")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("You can change your password here.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={open} variant="default" style={{ whiteSpace: "nowrap" }}>
|
||||
<Button onClick={open} variant="default">
|
||||
{t("Change password")}
|
||||
</Button>
|
||||
|
||||
|
||||
+4
-46
@@ -1,41 +1,23 @@
|
||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||
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 {
|
||||
useDeleteWorkspaceMemberMutation,
|
||||
useResetUserMfaMutation
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
userId: string;
|
||||
userRole: string;
|
||||
}
|
||||
export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
export default function MemberActionMenu({ userId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||
const resetUserMfaMutation = useResetUserMfaMutation();
|
||||
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 { isAdmin } = useUserRole();
|
||||
|
||||
const onRevoke = async () => {
|
||||
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const onResetMfa = async () => {
|
||||
await resetUserMfaMutation.mutateAsync({ userId });
|
||||
};
|
||||
|
||||
const openRevokeModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete member"),
|
||||
@@ -52,22 +34,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
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 (
|
||||
<>
|
||||
<Menu
|
||||
@@ -85,14 +51,6 @@ export default function MemberActionMenu({ userId, userRole }: Props) {
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{showMfaReset && (
|
||||
<Menu.Item
|
||||
onClick={openResetMfaModal}
|
||||
leftSection={<IconShieldOff size={16} />}
|
||||
>
|
||||
{t("Reset MFA")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openRevokeModal}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ export default function WorkspaceMembersTable() {
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && <MemberActionMenu userId={user.id} userRole={user.role} />}
|
||||
{isAdmin && <MemberActionMenu userId={user.id} />}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getAppVersion,
|
||||
deleteWorkspaceMember,
|
||||
} from "@/features/workspace/services/workspace-service";
|
||||
import { resetUserMfa } from "@/ee/mfa";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
@@ -193,29 +192,3 @@ export function useAppVersion(
|
||||
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;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/workspace/invites/accept", data);
|
||||
return req.data;
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
|
||||
await api.post<void>("/workspace/invites/accept", data);
|
||||
}
|
||||
|
||||
export async function getInviteLink(data: {
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface IWorkspace {
|
||||
memberCount?: number;
|
||||
plan?: string;
|
||||
hasLicenseKey?: boolean;
|
||||
enforceMfa?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
|
||||
@@ -8,8 +8,6 @@ const APP_ROUTE = {
|
||||
PASSWORD_RESET: "/password-reset",
|
||||
CREATE_WORKSPACE: "/create",
|
||||
SELECT_WORKSPACE: "/select",
|
||||
MFA_CHALLENGE: "/login/mfa",
|
||||
MFA_SETUP_REQUIRED: "/login/mfa/setup",
|
||||
},
|
||||
SETTINGS: {
|
||||
ACCOUNT: {
|
||||
|
||||
@@ -36,7 +36,6 @@ if (isCloud() && isPostHogEnabled) {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
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 AccountAvatar from "@/features/user/components/account-avatar";
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import {getAppName} from "@/lib/config.ts";
|
||||
import {Helmet} from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("My Profile")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Helmet>
|
||||
<title>{t("My Profile")} - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("My Profile")} />
|
||||
|
||||
<AccountAvatar />
|
||||
@@ -32,10 +29,6 @@ export default function AccountSettings() {
|
||||
<Divider my="lg" />
|
||||
|
||||
<ChangePassword />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<AccountMfaSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,14 +70,15 @@
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^5.8.2",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"redis": "^5.5.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.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 { Youtube } from '@tiptap/extension-youtube';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import {
|
||||
Callout,
|
||||
Comment,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
LinkExtension,
|
||||
MathBlock,
|
||||
MathInline,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TiptapImage,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention,
|
||||
Mention
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
@@ -46,11 +46,9 @@ export const tiptapExtensions = [
|
||||
codeBlock: false,
|
||||
}),
|
||||
Comment,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
TaskItem,
|
||||
Underline,
|
||||
LinkExtension,
|
||||
Superscript,
|
||||
@@ -66,9 +64,9 @@ export const tiptapExtensions = [
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Youtube,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
@@ -78,7 +76,7 @@ export const tiptapExtensions = [
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
Mention,
|
||||
Mention
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -46,10 +46,6 @@ export class AuthenticationExtension implements Extension {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
if (!page) {
|
||||
this.logger.warn(`Page not found: ${pageId}`);
|
||||
|
||||
@@ -156,6 +156,7 @@ export class PersistenceExtension implements Extension {
|
||||
page: {
|
||||
...page,
|
||||
content: tiptapJson,
|
||||
textContent: textContent,
|
||||
lastUpdatedById: context.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
|
||||
@@ -75,10 +74,3 @@ export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
}
|
||||
|
||||
export function extractBearerTokenFromHeader(
|
||||
request: FastifyRequest,
|
||||
): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
Res,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
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 { FastifyReply } from 'fastify';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -44,45 +39,6 @@ export class AuthController {
|
||||
) {
|
||||
validateSsoEnforcement(workspace);
|
||||
|
||||
let MfaModule: any;
|
||||
let isMfaModuleReady = false;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
MfaModule = require('./../../ee/mfa/services/mfa.service');
|
||||
isMfaModuleReady = true;
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'MFA module requested but EE module not bundled in this build',
|
||||
);
|
||||
isMfaModuleReady = false;
|
||||
}
|
||||
if (isMfaModuleReady) {
|
||||
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const mfaResult = await mfaService.checkMfaRequirements(
|
||||
loginInput,
|
||||
workspace,
|
||||
res,
|
||||
);
|
||||
|
||||
if (mfaResult) {
|
||||
// If user has MFA enabled OR workspace enforces MFA, require MFA verification
|
||||
if (mfaResult.userHasMfa || mfaResult.requiresMfaSetup) {
|
||||
return {
|
||||
userHasMfa: mfaResult.userHasMfa,
|
||||
requiresMfaSetup: mfaResult.requiresMfaSetup,
|
||||
isMfaEnforced: mfaResult.isMfaEnforced,
|
||||
};
|
||||
} else if (mfaResult.authToken) {
|
||||
// User doesn't have MFA and workspace doesn't require it
|
||||
this.setAuthCookie(res, mfaResult.authToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||
this.setAuthCookie(res, authToken);
|
||||
}
|
||||
@@ -129,22 +85,11 @@ export class AuthController {
|
||||
@Body() passwordResetDto: PasswordResetDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const result = await this.authService.passwordReset(
|
||||
const authToken = await this.authService.passwordReset(
|
||||
passwordResetDto,
|
||||
workspace,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (result.requiresLogin) {
|
||||
return {
|
||||
requiresLogin: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Set auth cookie if no MFA is required
|
||||
this.setAuthCookie(res, result.authToken);
|
||||
return {
|
||||
requiresLogin: false,
|
||||
};
|
||||
this.setAuthCookie(res, authToken);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -163,7 +108,7 @@ export class AuthController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.authService.getCollabToken(user, workspace.id);
|
||||
return this.authService.getCollabToken(user.id, workspace.id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -3,7 +3,6 @@ export enum JwtType {
|
||||
COLLAB = 'collab',
|
||||
EXCHANGE = 'exchange',
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -31,8 +30,3 @@ export type JwtAttachmentPayload = {
|
||||
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 { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
|
||||
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 { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -47,7 +47,7 @@ export class AuthService {
|
||||
includePassword: true,
|
||||
});
|
||||
|
||||
const errorMessage = 'Email or password does not match';
|
||||
const errorMessage = 'email or password does not match';
|
||||
if (!user || user?.deletedAt) {
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
@@ -156,13 +156,10 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async passwordReset(
|
||||
passwordResetDto: PasswordResetDto,
|
||||
workspace: Workspace,
|
||||
) {
|
||||
async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) {
|
||||
const userToken = await this.userTokenRepo.findById(
|
||||
passwordResetDto.token,
|
||||
workspace.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -173,9 +170,7 @@ export class AuthService {
|
||||
throw new BadRequestException('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(userToken.userId, workspace.id, {
|
||||
includeUserMfa: true,
|
||||
});
|
||||
const user = await this.userRepo.findById(userToken.userId, workspaceId);
|
||||
if (!user || user.deletedAt) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
@@ -188,7 +183,7 @@ export class AuthService {
|
||||
password: newPasswordHash,
|
||||
},
|
||||
user.id,
|
||||
workspace.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
@@ -206,18 +201,7 @@ export class AuthService {
|
||||
template: emailTemplate,
|
||||
});
|
||||
|
||||
// Check if user has MFA enabled or workspace enforces MFA
|
||||
const userHasMfa = user?.['mfa']?.isEnabled || false;
|
||||
const workspaceEnforcesMfa = workspace.enforceMfa || false;
|
||||
|
||||
if (userHasMfa || workspaceEnforcesMfa) {
|
||||
return {
|
||||
requiresLogin: true,
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
return { authToken };
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
}
|
||||
|
||||
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(
|
||||
user,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
return { token };
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
JwtAttachmentPayload,
|
||||
JwtCollabPayload,
|
||||
JwtExchangePayload,
|
||||
JwtMfaTokenPayload,
|
||||
JwtPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
@@ -23,7 +22,7 @@ export class TokenService {
|
||||
) {}
|
||||
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
if (user.deletedAt) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -36,13 +35,12 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
|
||||
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
async generateCollabToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const payload: JwtCollabPayload = {
|
||||
sub: user.id,
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
type: JwtType.COLLAB,
|
||||
};
|
||||
@@ -77,22 +75,6 @@ export class TokenService {
|
||||
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) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
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 { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@@ -19,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
return req.cookies?.authToken || extractBearerTokenFromHeader(req);
|
||||
return req.cookies?.authToken || this.extractTokenFromHeader(req);
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: environmentService.getAppSecret(),
|
||||
@@ -43,10 +42,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
||||
|
||||
if (!user || user.deactivatedAt || user.deletedAt) {
|
||||
if (!user || user.deletedAt) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
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 { CommentModule } from './comment/comment.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AiSearchModule } from './ai-search/ai-search.module';
|
||||
import { SpaceModule } from './space/space.module';
|
||||
import { GroupModule } from './group/group.module';
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
@@ -26,6 +27,7 @@ import { ShareModule } from './share/share.module';
|
||||
AttachmentModule,
|
||||
CommentModule,
|
||||
SearchModule,
|
||||
AiSearchModule,
|
||||
SpaceModule,
|
||||
GroupModule,
|
||||
CaslModule,
|
||||
|
||||
@@ -146,6 +146,7 @@ export class PageController {
|
||||
return this.pageService.getRecentPages(user.id, pagination);
|
||||
}
|
||||
|
||||
// TODO: scope to workspaces
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history')
|
||||
async getPageHistory(
|
||||
@@ -154,10 +155,6 @@ export class PageController {
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
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);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
|
||||
@@ -140,7 +140,7 @@ export class SearchService {
|
||||
if (suggestion.includeUsers) {
|
||||
users = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
||||
.select(['id', 'name', 'avatarUrl'])
|
||||
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
|
||||
@@ -29,8 +29,7 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||
@@ -258,27 +257,17 @@ export class WorkspaceController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
const result = await this.workspaceInvitationService.acceptInvitation(
|
||||
const authToken = await this.workspaceInvitationService.acceptInvitation(
|
||||
acceptInviteDto,
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (result.requiresLogin) {
|
||||
return {
|
||||
requiresLogin: true,
|
||||
};
|
||||
}
|
||||
|
||||
res.setCookie('authToken', result.authToken, {
|
||||
res.setCookie('authToken', authToken, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
});
|
||||
|
||||
return {
|
||||
requiresLogin: false,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -14,8 +14,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceSso: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceMfa: boolean;
|
||||
}
|
||||
|
||||
@@ -177,14 +177,7 @@ export class WorkspaceInvitationService {
|
||||
}
|
||||
}
|
||||
|
||||
async acceptInvitation(
|
||||
dto: AcceptInviteDto,
|
||||
workspace: Workspace,
|
||||
): Promise<{
|
||||
authToken?: string;
|
||||
requiresLogin?: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
@@ -296,14 +289,7 @@ export class WorkspaceInvitationService {
|
||||
});
|
||||
}
|
||||
|
||||
if (workspace.enforceMfa) {
|
||||
return {
|
||||
requiresLogin: true,
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(newUser);
|
||||
return { authToken };
|
||||
return this.tokenService.generateAccessToken(newUser);
|
||||
}
|
||||
|
||||
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 { InjectKysely } from 'nestjs-kysely';
|
||||
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 { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class UserRepo {
|
||||
@@ -41,7 +40,6 @@ export class UserRepo {
|
||||
workspaceId: string,
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -50,7 +48,6 @@ export class UserRepo {
|
||||
.selectFrom('users')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.where('id', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -61,7 +58,6 @@ export class UserRepo {
|
||||
workspaceId: string,
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -70,7 +66,6 @@ export class UserRepo {
|
||||
.selectFrom('users')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -182,18 +177,4 @@ export class UserRepo {
|
||||
.returning(this.baseFields)
|
||||
.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',
|
||||
'enforceSso',
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
|
||||
-14
@@ -247,18 +247,6 @@ export interface Spaces {
|
||||
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 {
|
||||
avatarUrl: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
@@ -312,7 +300,6 @@ export interface Workspaces {
|
||||
deletedAt: Timestamp | null;
|
||||
description: string | null;
|
||||
emailDomains: Generated<string[] | null>;
|
||||
enforceMfa: Generated<boolean | null>;
|
||||
enforceSso: Generated<boolean>;
|
||||
hostname: string | null;
|
||||
id: Generated<string>;
|
||||
@@ -342,7 +329,6 @@ export interface DB {
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userTokens: UserTokens;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@@ -114,8 +113,3 @@ export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
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/starter-kit": "^2.10.3",
|
||||
"@tiptap/suggestion": "^2.10.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -71,7 +70,6 @@
|
||||
"linkifyjs": "^4.2.0",
|
||||
"marked": "13.0.3",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.1.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.27"
|
||||
|
||||
@@ -17,5 +17,4 @@ export * from "./lib/excalidraw";
|
||||
export * from "./lib/embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
|
||||
@@ -35,42 +35,6 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
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({
|
||||
name: "tableCell",
|
||||
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 "./cell";
|
||||
export * from "./header";
|
||||
|
||||
Generated
+136
-176
@@ -142,9 +142,6 @@ importers:
|
||||
'@tiptap/suggestion':
|
||||
specifier: ^2.10.3
|
||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5
|
||||
bytes:
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2
|
||||
@@ -175,9 +172,6 @@ importers:
|
||||
ms:
|
||||
specifier: 3.0.0-canary.1
|
||||
version: 3.0.0-canary.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
@@ -228,23 +222,23 @@ importers:
|
||||
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)
|
||||
'@mantine/core':
|
||||
specifier: ^8.1.3
|
||||
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)
|
||||
specifier: ^7.17.0
|
||||
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':
|
||||
specifier: ^8.1.3
|
||||
version: 8.1.3(react@18.3.1)
|
||||
specifier: ^7.17.0
|
||||
version: 7.17.0(react@18.3.1)
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.1.3
|
||||
version: 8.1.3(react@18.3.1)
|
||||
specifier: ^7.17.0
|
||||
version: 7.17.0(react@18.3.1)
|
||||
'@mantine/modals':
|
||||
specifier: ^8.1.3
|
||||
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)
|
||||
specifier: ^7.17.0
|
||||
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':
|
||||
specifier: ^8.1.3
|
||||
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)
|
||||
specifier: ^7.17.0
|
||||
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':
|
||||
specifier: ^8.1.3
|
||||
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)
|
||||
specifier: ^7.17.0
|
||||
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':
|
||||
specifier: ^3.34.0
|
||||
version: 3.34.0(react@18.3.1)
|
||||
@@ -296,9 +290,6 @@ importers:
|
||||
lowlight:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
mantine-form-zod-resolver:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56)
|
||||
mermaid:
|
||||
specifier: ^11.6.0
|
||||
version: 11.6.0
|
||||
@@ -540,12 +531,12 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^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:
|
||||
specifier: ^5.7.1
|
||||
version: 5.7.1
|
||||
otpauth:
|
||||
specifier: ^9.4.0
|
||||
version: 9.4.0
|
||||
passport-google-oauth20:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@@ -564,6 +555,9 @@ importers:
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
redis:
|
||||
specifier: ^5.5.6
|
||||
version: 5.5.6
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
@@ -2515,49 +2509,49 @@ packages:
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@mantine/core@8.1.3':
|
||||
resolution: {integrity: sha512-2WOPC8GSN3MApet0MccSn6LaXRhcP6SVtZnbuHoqJ/atrfK7kLE66ILr4OXov7JAj1ASJ4Xk0bOXmu5fBExAvQ==}
|
||||
'@mantine/core@7.17.0':
|
||||
resolution: {integrity: sha512-AU5UFewUNzBCUXIq5Jk6q402TEri7atZW61qHW6P0GufJ2W/JxGHRvgmHOVHTVIcuWQRCt9SBSqZoZ/vHs9LhA==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 8.1.3
|
||||
'@mantine/hooks': 7.17.0
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/form@8.1.3':
|
||||
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
|
||||
'@mantine/form@7.17.0':
|
||||
resolution: {integrity: sha512-LONdeb+wL8h9fvyQ339ZFLxqrvYff+b+H+kginZhnr45OBTZDLXNVAt/YoKVFEkynF9WDJjdBVrXKcOZvPgmrA==}
|
||||
peerDependencies:
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
'@mantine/hooks@8.1.3':
|
||||
resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==}
|
||||
'@mantine/hooks@7.17.0':
|
||||
resolution: {integrity: sha512-vo3K49mLy1nJ8LQNb5KDbJgnX0xwt3Y8JOF3ythjB5LEFMptdLSSgulu64zj+QHtzvffFCsMb05DbTLLpVP/JQ==}
|
||||
peerDependencies:
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
'@mantine/modals@8.1.3':
|
||||
resolution: {integrity: sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==}
|
||||
'@mantine/modals@7.17.0':
|
||||
resolution: {integrity: sha512-4sfiFxIxMxfm2RH4jXMN+cr8tFS5AexXG4TY7TRN/ySdkiWtFVvDe5l2/KRWWeWwDUb7wQhht8Ompj5KtexlEA==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 8.1.3
|
||||
'@mantine/hooks': 8.1.3
|
||||
'@mantine/core': 7.17.0
|
||||
'@mantine/hooks': 7.17.0
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/notifications@8.1.3':
|
||||
resolution: {integrity: sha512-Xy6f/l1yLTo77hz8X80sOuY+HW80e1rn8ucygx9TAexK5+XtyriOv26TQ3EJ6Ej5jlchtZRFEUJ4tJGRWjGCNg==}
|
||||
'@mantine/notifications@7.17.0':
|
||||
resolution: {integrity: sha512-xejr1WW02NrrrE4HPDoownILJubcjLLwCDeTk907ZeeHKBEPut7RukEq6gLzOZBhNhKdPM+vCM7GcbXdaLZq/Q==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 8.1.3
|
||||
'@mantine/hooks': 8.1.3
|
||||
'@mantine/core': 7.17.0
|
||||
'@mantine/hooks': 7.17.0
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/spotlight@8.1.3':
|
||||
resolution: {integrity: sha512-GhJbSoUdcALGSMLC/zjVVncRDyvxwxjtlzFeHLuY0Dgkgj+60x3tnzAulDrqYVhLMk7fGyex22VV/Xwl7mG1+Q==}
|
||||
'@mantine/spotlight@7.17.0':
|
||||
resolution: {integrity: sha512-T7xfXxyDg2fxf7qvKwBozQ8HBnTQ2GRCIIoeYdAoiHoFQUS7NbBAnqrjdr5iYZpJqyLRXn8uFI7DX1Zdzd6/PQ==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 8.1.3
|
||||
'@mantine/hooks': 8.1.3
|
||||
'@mantine/core': 7.17.0
|
||||
'@mantine/hooks': 7.17.0
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/store@8.1.3':
|
||||
resolution: {integrity: sha512-rO72LfSJqSNCwufqJxTWiHMyOR6sR3mqAcnBcw/f5aTvyOYoHZzlm4q4+TL8/2vYGRVsr9YM2Ez6HQ1vk/RR8g==}
|
||||
'@mantine/store@7.17.0':
|
||||
resolution: {integrity: sha512-nhWRYRLqvAjrD/ApKCXxuHyTWg2b5dC06Z5gmO8udj4pBgndNf9nmCl+Of90H6bgOa56moJA7UQyXoF1SfxqVg==}
|
||||
peerDependencies:
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
@@ -2855,10 +2849,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@1.7.1':
|
||||
resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@node-saml/node-saml@5.0.1':
|
||||
resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3365,6 +3355,34 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
@@ -4358,9 +4376,6 @@ packages:
|
||||
'@types/prop-types@15.7.11':
|
||||
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
|
||||
|
||||
'@types/qrcode@1.5.5':
|
||||
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
|
||||
|
||||
'@types/qs@6.9.14':
|
||||
resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==}
|
||||
|
||||
@@ -5108,9 +5123,6 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5537,10 +5549,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.4.3:
|
||||
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
||||
|
||||
@@ -5640,9 +5648,6 @@ packages:
|
||||
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dnd-core@14.0.1:
|
||||
resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==}
|
||||
|
||||
@@ -7187,13 +7192,6 @@ packages:
|
||||
makeerror@1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
mantine-form-zod-resolver@1.3.0:
|
||||
resolution: {integrity: sha512-XlXXkJCYuUuOllW0zedYW+m/lbdFQ/bso1Vz+pJOYkxgjhoGvzN2EXWCS2+0iTOT9Q7WnOwWvHmvpTJN3PxSXw==}
|
||||
engines: {node: '>=16.6.0'}
|
||||
peerDependencies:
|
||||
'@mantine/form': '>=7.0.0'
|
||||
zod: '>=3.25.0'
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
hasBin: true
|
||||
@@ -7640,6 +7638,18 @@ packages:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||
|
||||
@@ -7665,9 +7675,6 @@ packages:
|
||||
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
otpauth@9.4.0:
|
||||
resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -7910,10 +7917,6 @@ packages:
|
||||
png-chunks-extract@1.0.0:
|
||||
resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -8154,11 +8157,6 @@ packages:
|
||||
pwacompat@2.0.17:
|
||||
resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.12.0:
|
||||
resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -8313,8 +8311,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-textarea-autosize@8.5.9:
|
||||
resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
|
||||
react-textarea-autosize@8.5.6:
|
||||
resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -8374,6 +8372,10 @@ packages:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis@5.5.6:
|
||||
resolution: {integrity: sha512-hbpqBfcuhWHOS9YLNcXcJ4akNr7HFX61Dq3JuFZ9S7uU7C7kvnzuH2PDIXOP62A3eevvACoG8UacuXP3N07xdg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
redlock@4.2.0:
|
||||
resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -8428,9 +8430,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-cwd@3.0.0:
|
||||
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9436,9 +9435,6 @@ packages:
|
||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which-typed-array@1.1.16:
|
||||
resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9578,9 +9574,6 @@ packages:
|
||||
peerDependencies:
|
||||
yjs: ^13.0.0
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9600,18 +9593,10 @@ packages:
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -12293,55 +12278,55 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@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
|
||||
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-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
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/form@8.1.3(react@18.3.1)':
|
||||
'@mantine/form@7.17.0(react@18.3.1)':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
klona: 2.0.6
|
||||
react: 18.3.1
|
||||
|
||||
'@mantine/hooks@8.1.3(react@18.3.1)':
|
||||
'@mantine/hooks@7.17.0(react@18.3.1)':
|
||||
dependencies:
|
||||
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:
|
||||
'@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)
|
||||
'@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: 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:
|
||||
'@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)
|
||||
'@mantine/store': 8.1.3(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': 7.17.0(react@18.3.1)
|
||||
'@mantine/store': 7.17.0(react@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)
|
||||
|
||||
'@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:
|
||||
'@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)
|
||||
'@mantine/store': 8.1.3(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': 7.17.0(react@18.3.1)
|
||||
'@mantine/store': 7.17.0(react@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:
|
||||
react: 18.3.1
|
||||
|
||||
@@ -12593,8 +12578,6 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@14.2.10':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.7.1': {}
|
||||
|
||||
'@node-saml/node-saml@5.0.1':
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
@@ -13095,6 +13078,26 @@ snapshots:
|
||||
dependencies:
|
||||
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': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
@@ -14225,10 +14228,6 @@ snapshots:
|
||||
|
||||
'@types/prop-types@15.7.11': {}
|
||||
|
||||
'@types/qrcode@1.5.5':
|
||||
dependencies:
|
||||
'@types/node': 22.13.4
|
||||
|
||||
'@types/qs@6.9.14': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@@ -15184,12 +15183,6 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -15632,8 +15625,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.4.3: {}
|
||||
|
||||
decode-named-character-reference@1.1.0:
|
||||
@@ -15712,8 +15703,6 @@ snapshots:
|
||||
|
||||
diff@5.2.0: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dnd-core@14.0.1:
|
||||
dependencies:
|
||||
'@react-dnd/asap': 4.0.1
|
||||
@@ -17651,11 +17640,6 @@ snapshots:
|
||||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
|
||||
mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56):
|
||||
dependencies:
|
||||
'@mantine/form': 8.1.3(react@18.3.1)
|
||||
zod: 3.25.56
|
||||
|
||||
markdown-it@14.1.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -18226,6 +18210,11 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
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:
|
||||
dependencies:
|
||||
jose: 4.15.9
|
||||
@@ -18271,10 +18260,6 @@ snapshots:
|
||||
|
||||
os-tmpdir@1.0.2: {}
|
||||
|
||||
otpauth@9.4.0:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.7.1
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
@@ -18518,8 +18503,6 @@ snapshots:
|
||||
dependencies:
|
||||
crc-32: 0.3.0
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-curve@1.0.1: {}
|
||||
@@ -18774,12 +18757,6 @@ snapshots:
|
||||
|
||||
pwacompat@2.0.17: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.12.0:
|
||||
dependencies:
|
||||
side-channel: 1.0.6
|
||||
@@ -18947,7 +18924,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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:
|
||||
'@babel/runtime': 7.25.6
|
||||
react: 18.3.1
|
||||
@@ -19020,6 +18997,14 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
@@ -19080,8 +19065,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-cwd@3.0.0:
|
||||
dependencies:
|
||||
resolve-from: 5.0.0
|
||||
@@ -20143,8 +20126,6 @@ snapshots:
|
||||
is-weakmap: 2.0.2
|
||||
is-weakset: 2.0.3
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which-typed-array@1.1.16:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
@@ -20250,8 +20231,6 @@ snapshots:
|
||||
lib0: 0.2.108
|
||||
yjs: 13.6.27
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
@@ -20262,27 +20241,8 @@ snapshots:
|
||||
|
||||
yaml@2.7.0: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||
Reference in New Issue
Block a user