Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 0cf44914ad POC 2025-06-30 01:21:01 -07:00
100 changed files with 2817 additions and 3747 deletions
+6 -7
View File
@@ -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
View File
@@ -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 };
}
-19
View File
@@ -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;
}
-62
View File
@@ -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";
+10 -38
View File
@@ -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 {
@@ -1,9 +0,0 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});
@@ -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;
@@ -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>
@@ -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}
@@ -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 {
-2
View File
@@ -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: {
-1
View File
@@ -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 />
</>
);
}
+2 -1
View File
@@ -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,
},
});
-8
View File
@@ -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.
+201
View File
@@ -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.
+224
View File
@@ -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;
}
}
+4 -59
View File
@@ -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;
}
}
+2
View File
@@ -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,
+1 -4
View File
@@ -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
View File
@@ -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'>>;
-2
View File
@@ -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"
-1
View File
@@ -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;
-31
View File
@@ -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";
+136 -176
View File
@@ -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