diff --git a/apps/client/package.json b/apps/client/package.json
index 0cff05cc..37ecb93d 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
+ "@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 6d9e548b..36985540 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -533,5 +533,26 @@
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
- "Image removed successfully": "Image removed successfully"
+ "Image removed successfully": "Image removed successfully",
+ "API key": "API key",
+ "API key created successfully": "API key created successfully",
+ "API keys": "API keys",
+ "API management": "API management",
+ "Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
+ "Create API Key": "Create API Key",
+ "Custom expiration date": "Custom expiration date",
+ "Enter a descriptive token name": "Enter a descriptive token name",
+ "Expiration": "Expiration",
+ "Expired": "Expired",
+ "Expires": "Expires",
+ "I've saved my API key": "I've saved my API key",
+ "Last use": "Last Used",
+ "No API keys found": "No API keys found",
+ "No expiration": "No expiration",
+ "Revoke API key": "Revoke API key",
+ "Revoked successfully": "Revoked successfully",
+ "Select expiration date": "Select expiration date",
+ "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
+ "Update API key": "Update API key",
+ "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
}
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 3995191d..7048f08a 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx";
+import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
+import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
export default function App() {
const { t } = useTranslation();
@@ -96,8 +98,10 @@ export default function App() {
path={"account/preferences"}
element={}
/>
+ } />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/client/src/components/common/no-table-results.tsx b/apps/client/src/components/common/no-table-results.tsx
index 124bbb9b..0f34fa2f 100644
--- a/apps/client/src/components/common/no-table-results.tsx
+++ b/apps/client/src/components/common/no-table-results.tsx
@@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
+ text?: string;
}
-export default function NoTableResults({ colSpan }: NoTableResultsProps) {
+export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation();
return (
- {t("No results found...")}
+ {text || t("No results found...")}
diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx
index 2f3b46bd..bc528466 100644
--- a/apps/client/src/components/settings/settings-queries.tsx
+++ b/apps/client/src/components/settings/settings-queries.tsx
@@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
+import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
@@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};
+
+export const prefetchApiKeys = () => {
+ queryClient.prefetchQuery({
+ queryKey: ["api-key-list", { page: 1 }],
+ queryFn: () => getApiKeys({ page: 1 }),
+ });
+};
+
+export const prefetchApiKeyManagement = () => {
+ queryClient.prefetchQuery({
+ queryKey: ["api-key-list", { page: 1 }],
+ queryFn: () => getApiKeys({ page: 1, adminView: true }),
+ });
+};
diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx
index fe0c7e88..abd3f962 100644
--- a/apps/client/src/components/settings/settings-sidebar.tsx
+++ b/apps/client/src/components/settings/settings-sidebar.tsx
@@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
+ prefetchApiKeyManagement,
+ prefetchApiKeys,
prefetchBilling,
prefetchGroups,
prefetchLicense,
@@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
+ {
+ label: "API keys",
+ icon: IconKey,
+ path: "/settings/account/api-keys",
+ isCloud: true,
+ isEnterprise: true,
+ showDisabledInNonEE: true,
+ },
],
},
{
@@ -90,6 +100,15 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
+ {
+ label: "API management",
+ icon: IconKey,
+ path: "/settings/api-keys",
+ isCloud: true,
+ isEnterprise: true,
+ isAdmin: true,
+ showDisabledInNonEE: true,
+ },
],
},
{
@@ -195,6 +214,12 @@ export default function SettingsSidebar() {
case "Public sharing":
prefetchHandler = prefetchShares;
break;
+ case "API keys":
+ prefetchHandler = prefetchApiKeys;
+ break;
+ case "API management":
+ prefetchHandler = prefetchApiKeyManagement;
+ break;
default:
break;
}
diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx
new file mode 100644
index 00000000..6a01ee3c
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx
@@ -0,0 +1,72 @@
+import {
+ Modal,
+ Text,
+ Stack,
+ Alert,
+ Group,
+ Button,
+ TextInput,
+} from "@mantine/core";
+import { IconAlertTriangle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { IApiKey } from "@/ee/api-key";
+import CopyTextButton from "@/components/common/copy.tsx";
+
+interface ApiKeyCreatedModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey;
+}
+
+export function ApiKeyCreatedModal({
+ opened,
+ onClose,
+ apiKey,
+}: ApiKeyCreatedModalProps) {
+ const { t } = useTranslation();
+
+ if (!apiKey) return null;
+
+ return (
+
+
+ }
+ title={t("Important")}
+ color="red"
+ >
+ {t(
+ "Make sure to copy your API key now. You won't be able to see it again!",
+ )}
+
+
+
+
+ {t("API key")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx
new file mode 100644
index 00000000..48757acc
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/api-key-table.tsx
@@ -0,0 +1,143 @@
+import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
+import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
+import { format } from "date-fns";
+import { useTranslation } from "react-i18next";
+import { IApiKey } from "@/ee/api-key";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import React from "react";
+import NoTableResults from "@/components/common/no-table-results";
+
+interface ApiKeyTableProps {
+ apiKeys: IApiKey[];
+ isLoading?: boolean;
+ showUserColumn?: boolean;
+ onUpdate?: (apiKey: IApiKey) => void;
+ onRevoke?: (apiKey: IApiKey) => void;
+}
+
+export function ApiKeyTable({
+ apiKeys,
+ isLoading,
+ showUserColumn = false,
+ onUpdate,
+ onRevoke,
+}: ApiKeyTableProps) {
+ const { t } = useTranslation();
+
+ const formatDate = (date: Date | string | null) => {
+ if (!date) return t("Never");
+ return format(new Date(date), "MMM dd, yyyy");
+ };
+
+ const isExpired = (expiresAt: string | null) => {
+ if (!expiresAt) return false;
+ return new Date(expiresAt) < new Date();
+ };
+
+ return (
+
+
+
+
+ {t("Name")}
+ {showUserColumn && {t("User")}}
+ {t("Last used")}
+ {t("Expires")}
+ {t("Created")}
+
+
+
+
+
+ {apiKeys && apiKeys.length > 0 ? (
+ apiKeys.map((apiKey: IApiKey, index: number) => (
+
+
+
+ {apiKey.name}
+
+
+
+ {showUserColumn && apiKey.creator && (
+
+
+
+
+ {apiKey.creator.name}
+
+
+
+ )}
+
+
+
+ {formatDate(apiKey.lastUsedAt)}
+
+
+
+
+ {apiKey.expiresAt ? (
+ isExpired(apiKey.expiresAt) ? (
+
+ {t("Expired")}
+
+ ) : (
+
+ {formatDate(apiKey.expiresAt)}
+
+ )
+ ) : (
+
+ {t("Never")}
+
+ )}
+
+
+
+
+ {formatDate(apiKey.createdAt)}
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx
new file mode 100644
index 00000000..cade36e8
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx
@@ -0,0 +1,153 @@
+import { lazy, Suspense, useState } from "react";
+import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+import { useTranslation } from "react-i18next";
+import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
+import { IconCalendar } from "@tabler/icons-react";
+import { IApiKey } from "@/ee/api-key";
+
+const DateInput = lazy(() =>
+ import("@mantine/dates").then((module) => ({
+ default: module.DateInput,
+ })),
+);
+
+interface CreateApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onSuccess: (response: IApiKey) => void;
+}
+
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ expiresAt: z.string().optional(),
+});
+type FormValues = z.infer;
+
+export function CreateApiKeyModal({
+ opened,
+ onClose,
+ onSuccess,
+}: CreateApiKeyModalProps) {
+ const { t } = useTranslation();
+ const [expirationOption, setExpirationOption] = useState("30");
+ const createApiKeyMutation = useCreateApiKeyMutation();
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ name: "",
+ expiresAt: "",
+ },
+ });
+
+ const getExpirationDate = (): string | undefined => {
+ if (expirationOption === "never") {
+ return undefined;
+ }
+ if (expirationOption === "custom") {
+ return form.values.expiresAt;
+ }
+ const days = parseInt(expirationOption);
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ return date.toISOString();
+ };
+
+ const getExpirationLabel = (days: number) => {
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ const formatted = date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+ return `${days} days (${formatted})`;
+ };
+
+ const expirationOptions = [
+ { value: "30", label: getExpirationLabel(30) },
+ { value: "60", label: getExpirationLabel(60) },
+ { value: "90", label: getExpirationLabel(90) },
+ { value: "365", label: getExpirationLabel(365) },
+ { value: "custom", label: t("Custom") },
+ { value: "never", label: t("No expiration") },
+ ];
+
+ const handleSubmit = async (data: {
+ name?: string;
+ expiresAt?: string | Date;
+ }) => {
+ const groupData = {
+ name: data.name,
+ expiresAt: getExpirationDate(),
+ };
+
+ try {
+ const createdKey = await createApiKeyMutation.mutateAsync(groupData);
+ onSuccess(createdKey);
+ form.reset();
+ onClose();
+ } catch (err) {
+ //
+ }
+ };
+
+ const handleClose = () => {
+ form.reset();
+ setExpirationOption("30");
+ onClose();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx
new file mode 100644
index 00000000..3092ead4
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx
@@ -0,0 +1,62 @@
+import { Modal, Text, Button, Group, Stack } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+
+interface RevokeApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey | null;
+}
+
+export function RevokeApiKeyModal({
+ opened,
+ onClose,
+ apiKey,
+}: RevokeApiKeyModalProps) {
+ const { t } = useTranslation();
+ const revokeApiKeyMutation = useRevokeApiKeyMutation();
+
+ const handleRevoke = async () => {
+ if (!apiKey) return;
+ await revokeApiKeyMutation.mutateAsync({
+ apiKeyId: apiKey.id,
+ });
+ onClose();
+ };
+
+ return (
+
+
+
+ {t("Are you sure you want to revoke this API key")}{" "}
+ {apiKey?.name}?
+
+
+ {t(
+ "This action cannot be undone. Any applications using this API key will stop working.",
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx
new file mode 100644
index 00000000..6edeb1c3
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx
@@ -0,0 +1,80 @@
+import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+import { useTranslation } from "react-i18next";
+import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
+import { IApiKey } from "@/ee/api-key";
+import { useEffect } from "react";
+
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+});
+type FormValues = z.infer;
+
+interface UpdateApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey | null;
+}
+
+export function UpdateApiKeyModal({
+ opened,
+ onClose,
+ apiKey,
+}: UpdateApiKeyModalProps) {
+ const { t } = useTranslation();
+ const updateApiKeyMutation = useUpdateApiKeyMutation();
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ name: "",
+ },
+ });
+
+ useEffect(() => {
+ if (opened && apiKey) {
+ form.setValues({ name: apiKey.name });
+ }
+ }, [opened, apiKey]);
+
+ const handleSubmit = async (data: { name?: string }) => {
+ const apiKeyData = {
+ apiKeyId: apiKey.id,
+ name: data.name,
+ };
+
+ await updateApiKeyMutation.mutateAsync(apiKeyData);
+ onClose();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/index.ts b/apps/client/src/ee/api-key/index.ts
new file mode 100644
index 00000000..24bc3d1c
--- /dev/null
+++ b/apps/client/src/ee/api-key/index.ts
@@ -0,0 +1,11 @@
+export { ApiKeyTable } from "./components/api-key-table";
+export { CreateApiKeyModal } from "./components/create-api-key-modal";
+export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
+export { UpdateApiKeyModal } from "./components/update-api-key-modal";
+export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
+
+// Services
+export * from "./services/api-key-service";
+
+// Types
+export * from "./types/api-key.types";
diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
new file mode 100644
index 00000000..a0aae3b6
--- /dev/null
+++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
@@ -0,0 +1,106 @@
+import React, { useState } from "react";
+import { Button, Group, Space } from "@mantine/core";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import SettingsTitle from "@/components/settings/settings-title";
+import { getAppName } from "@/lib/config";
+import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
+import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
+import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
+import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
+import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
+import Paginate from "@/components/common/paginate";
+import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
+import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+
+export default function UserApiKeys() {
+ const { t } = useTranslation();
+ const { page, setPage } = usePaginateAndSearch();
+ const [createModalOpened, setCreateModalOpened] = useState(false);
+ const [createdApiKey, setCreatedApiKey] = useState(null);
+ const [updateModalOpened, setUpdateModalOpened] = useState(false);
+ const [revokeModalOpened, setRevokeModalOpened] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState(null);
+ const { data, isLoading } = useGetApiKeysQuery({ page });
+
+ const handleCreateSuccess = (response: IApiKey) => {
+ setCreatedApiKey(response);
+ };
+
+ const handleUpdate = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setUpdateModalOpened(true);
+ };
+
+ const handleRevoke = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setRevokeModalOpened(true);
+ };
+
+ return (
+ <>
+
+
+ {t("API keys")} - {getAppName()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data?.items.length > 0 && (
+
+ )}
+
+ setCreateModalOpened(false)}
+ onSuccess={handleCreateSuccess}
+ />
+
+ setCreatedApiKey(null)}
+ apiKey={createdApiKey}
+ />
+
+ {
+ setUpdateModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+
+ {
+ setRevokeModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
new file mode 100644
index 00000000..cf459373
--- /dev/null
+++ b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
@@ -0,0 +1,117 @@
+import React, { useState } from "react";
+import { Button, Group, Space, Text } from "@mantine/core";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import SettingsTitle from "@/components/settings/settings-title";
+import { getAppName } from "@/lib/config";
+import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
+import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
+import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
+import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
+import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
+import Paginate from "@/components/common/paginate";
+import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
+import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+import useUserRole from '@/hooks/use-user-role.tsx';
+
+export default function WorkspaceApiKeys() {
+ const { t } = useTranslation();
+ const { page, setPage } = usePaginateAndSearch();
+ const [createModalOpened, setCreateModalOpened] = useState(false);
+ const [createdApiKey, setCreatedApiKey] = useState(null);
+ const [updateModalOpened, setUpdateModalOpened] = useState(false);
+ const [revokeModalOpened, setRevokeModalOpened] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState(null);
+ const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
+ const { isAdmin } = useUserRole();
+
+ if (!isAdmin) {
+ return null;
+ }
+
+ const handleCreateSuccess = (response: IApiKey) => {
+ setCreatedApiKey(response);
+ };
+
+ const handleUpdate = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setUpdateModalOpened(true);
+ };
+
+ const handleRevoke = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setRevokeModalOpened(true);
+ };
+
+ return (
+ <>
+
+
+ {t("API management")} - {getAppName()}
+
+
+
+
+
+
+ {t("Manage API keys for all users in the workspace")}
+
+
+
+
+
+
+
+
+
+
+ {data?.items.length > 0 && (
+
+ )}
+
+ setCreateModalOpened(false)}
+ onSuccess={handleCreateSuccess}
+ />
+
+ setCreatedApiKey(null)}
+ apiKey={createdApiKey}
+ />
+
+ {
+ setUpdateModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+
+ {
+ setRevokeModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/api-key/queries/api-key-query.ts b/apps/client/src/ee/api-key/queries/api-key-query.ts
new file mode 100644
index 00000000..75f2d351
--- /dev/null
+++ b/apps/client/src/ee/api-key/queries/api-key-query.ts
@@ -0,0 +1,97 @@
+import { IPagination, QueryParams } from "@/lib/types.ts";
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ createApiKey,
+ getApiKeys,
+ IApiKey,
+ ICreateApiKeyRequest,
+ IUpdateApiKeyRequest,
+ revokeApiKey,
+ updateApiKey,
+} from "@/ee/api-key";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+export function useGetApiKeysQuery(
+ params?: QueryParams,
+): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ["api-key-list", params],
+ queryFn: () => getApiKeys(params),
+ staleTime: 0,
+ gcTime: 0,
+ placeholderData: keepPreviousData,
+ });
+}
+
+export function useRevokeApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation<
+ void,
+ Error,
+ {
+ apiKeyId: string;
+ }
+ >({
+ mutationFn: (data) => revokeApiKey(data),
+ onSuccess: (data, variables) => {
+ notifications.show({ message: t("Revoked successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
+export function useCreateApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => createApiKey(data),
+ onSuccess: () => {
+ notifications.show({ message: t("API key created successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
+export function useUpdateApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => updateApiKey(data),
+ onSuccess: (data, variables) => {
+ notifications.show({ message: t("Updated successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
diff --git a/apps/client/src/ee/api-key/services/api-key-service.ts b/apps/client/src/ee/api-key/services/api-key-service.ts
new file mode 100644
index 00000000..c83e25d0
--- /dev/null
+++ b/apps/client/src/ee/api-key/services/api-key-service.ts
@@ -0,0 +1,32 @@
+import api from "@/lib/api-client";
+import {
+ ICreateApiKeyRequest,
+ IApiKey,
+ IUpdateApiKeyRequest,
+} from "@/ee/api-key/types/api-key.types";
+import { IPagination, QueryParams } from "@/lib/types.ts";
+
+export async function getApiKeys(
+ params?: QueryParams,
+): Promise> {
+ const req = await api.post("/api-keys", { ...params });
+ return req.data;
+}
+
+export async function createApiKey(
+ data: ICreateApiKeyRequest,
+): Promise {
+ const req = await api.post("/api-keys/create", data);
+ return req.data;
+}
+
+export async function updateApiKey(
+ data: IUpdateApiKeyRequest,
+): Promise {
+ const req = await api.post("/api-keys/update", data);
+ return req.data;
+}
+
+export async function revokeApiKey(data: { apiKeyId: string }): Promise {
+ await api.post("/api-keys/revoke", data);
+}
diff --git a/apps/client/src/ee/api-key/types/api-key.types.ts b/apps/client/src/ee/api-key/types/api-key.types.ts
new file mode 100644
index 00000000..57890d1a
--- /dev/null
+++ b/apps/client/src/ee/api-key/types/api-key.types.ts
@@ -0,0 +1,23 @@
+import { IUser } from "@/features/user/types/user.types.ts";
+
+export interface IApiKey {
+ id: string;
+ name: string;
+ token?: string;
+ creatorId: string;
+ workspaceId: string;
+ expiresAt: string | null;
+ lastUsedAt: string | null;
+ createdAt: string;
+ creator: Partial;
+}
+
+export interface ICreateApiKeyRequest {
+ name: string;
+ expiresAt?: string;
+}
+
+export interface IUpdateApiKeyRequest {
+ apiKeyId: string;
+ name: string;
+}
diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
index 3b7692f4..d28eae98 100644
--- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
@@ -3,6 +3,7 @@ import {
BubbleMenuProps,
isNodeSelection,
useEditor,
+ useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
@@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
+ const editorState = useEditorState({
+ editor: props.editor,
+ selector: (ctx) => {
+ if (!props.editor) {
+ return null;
+ }
+
+ return {
+ isBold: ctx.editor.isActive("bold"),
+ isItalic: ctx.editor.isActive("italic"),
+ isUnderline: ctx.editor.isActive("underline"),
+ isStrike: ctx.editor.isActive("strike"),
+ isCode: ctx.editor.isActive("code"),
+ isComment: ctx.editor.isActive("comment"),
+ };
+ },
+ });
+
const items: BubbleMenuItem[] = [
{
name: "Bold",
- isActive: () => props.editor.isActive("bold"),
+ isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold,
},
{
name: "Italic",
- isActive: () => props.editor.isActive("italic"),
+ isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic,
},
{
name: "Underline",
- isActive: () => props.editor.isActive("underline"),
+ isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline,
},
{
name: "Strike",
- isActive: () => props.editor.isActive("strike"),
+ isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough,
},
{
name: "Code",
- isActive: () => props.editor.isActive("code"),
+ isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
@@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC = (props) => {
const commentItem: BubbleMenuItem = {
name: "Comment",
- isActive: () => props.editor.isActive("comment"),
+ isActive: () => editorState?.isComment,
command: () => {
const commentId = uuid7();
diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx
index 1148e0f4..a59eb8e4 100644
--- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx
@@ -9,7 +9,8 @@ import {
Text,
Tooltip,
} from "@mantine/core";
-import { useEditor } from "@tiptap/react";
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem {
@@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
}
interface ColorSelectorProps {
- editor: ReturnType;
+ editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch>;
}
@@ -108,12 +109,36 @@ export const ColorSelector: FC = ({
setIsOpen,
}) => {
const { t } = useTranslation();
+
+ const editorState = useEditorState({
+ editor,
+ selector: ctx => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const activeColors: Record = {};
+ TEXT_COLORS.forEach(({ color }) => {
+ activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
+ });
+ HIGHLIGHT_COLORS.forEach(({ color }) => {
+ activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
+ });
+
+ return activeColors;
+ },
+ });
+
+ if (!editor || !editorState) {
+ return null;
+ }
+
const activeColorItem = TEXT_COLORS.find(({ color }) =>
- editor.isActive("textStyle", { color }),
+ editorState[`text_${color}`]
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
- editor.isActive("highlight", { color }),
+ editorState[`highlight_${color}`]
);
return (
@@ -151,7 +176,7 @@ export const ColorSelector: FC = ({
justify="left"
fullWidth
rightSection={
- editor.isActive("textStyle", { color }) && (
+ editorState[`text_${color}`] && (
)
}
diff --git a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx
index bc2eb702..13b2117f 100644
--- a/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/node-selector.tsx
@@ -13,11 +13,12 @@ import {
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
-import { useEditor } from "@tiptap/react";
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface NodeSelectorProps {
- editor: ReturnType;
+ editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch>;
}
@@ -36,6 +37,27 @@ export const NodeSelector: FC = ({
}) => {
const { t } = useTranslation();
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!editor) {
+ return null;
+ }
+
+ return {
+ isParagraph: ctx.editor.isActive("paragraph"),
+ isBulletList: ctx.editor.isActive("bulletList"),
+ isOrderedList: ctx.editor.isActive("orderedList"),
+ isHeading1: ctx.editor.isActive("heading", { level: 1 }),
+ isHeading2: ctx.editor.isActive("heading", { level: 2 }),
+ isHeading3: ctx.editor.isActive("heading", { level: 3 }),
+ isTaskItem: ctx.editor.isActive("taskItem"),
+ isBlockquote: ctx.editor.isActive("blockquote"),
+ isCodeBlock: ctx.editor.isActive("codeBlock"),
+ };
+ },
+ });
+
const items: BubbleMenuItem[] = [
{
name: "Text",
@@ -43,45 +65,45 @@ export const NodeSelector: FC = ({
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
- editor.isActive("paragraph") &&
- !editor.isActive("bulletList") &&
- !editor.isActive("orderedList"),
+ editorState?.isParagraph &&
+ !editorState?.isBulletList &&
+ !editorState?.isOrderedList,
},
{
name: "Heading 1",
icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
- isActive: () => editor.isActive("heading", { level: 1 }),
+ isActive: () => editorState?.isHeading1,
},
{
name: "Heading 2",
icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
- isActive: () => editor.isActive("heading", { level: 2 }),
+ isActive: () => editorState?.isHeading2,
},
{
name: "Heading 3",
icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
- isActive: () => editor.isActive("heading", { level: 3 }),
+ isActive: () => editorState?.isHeading3,
},
{
name: "To-do List",
icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(),
- isActive: () => editor.isActive("taskItem"),
+ isActive: () => editorState?.isTaskItem,
},
{
name: "Bullet List",
icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(),
- isActive: () => editor.isActive("bulletList"),
+ isActive: () => editorState?.isBulletList,
},
{
name: "Numbered List",
icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(),
- isActive: () => editor.isActive("orderedList"),
+ isActive: () => editorState?.isOrderedList,
},
{
name: "Blockquote",
@@ -93,13 +115,13 @@ export const NodeSelector: FC = ({
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
- isActive: () => editor.isActive("blockquote"),
+ isActive: () => editorState?.isBlockquote,
},
{
name: "Code",
icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(),
- isActive: () => editor.isActive("codeBlock"),
+ isActive: () => editorState?.isCodeBlock,
},
];
diff --git a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx
index 8330684b..b5277651 100644
--- a/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/text-alignment-selector.tsx
@@ -8,11 +8,12 @@ import {
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
-import { useEditor } from "@tiptap/react";
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TextAlignmentProps {
- editor: ReturnType;
+ editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch>;
}
@@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC = ({
}) => {
const { t } = useTranslation();
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ return {
+ isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
+ isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
+ isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
+ isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
+ };
+ },
+ });
+
+ if (!editor || !editorState) {
+ return null;
+ }
+
const items: BubbleMenuItem[] = [
{
name: "Align left",
- isActive: () => editor.isActive({ textAlign: "left" }),
+ isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
- isActive: () => editor.isActive({ textAlign: "center" }),
+ isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
- isActive: () => editor.isActive({ textAlign: "right" }),
+ isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
{
name: "Justify",
- isActive: () => editor.isActive({ textAlign: "justify" }),
+ isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified,
},
];
- const activeItem = items.filter((item) => item.isActive()).pop() ?? {
- name: "Multiple",
- };
+ const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return (
@@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC = ({
rightSection={}
onClick={() => setIsOpen(!isOpen)}
>
-
+
diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx
index 988f214a..c0485614 100644
--- a/apps/client/src/features/editor/components/callout/callout-menu.tsx
+++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
+ useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
@@ -9,7 +10,7 @@ import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
-import { ActionIcon, Tooltip, Divider } from "@mantine/core";
+import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
@@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ return {
+ isCallout: ctx.editor.isActive("callout"),
+ isInfo: ctx.editor.isActive("callout", { type: "info" }),
+ isSuccess: ctx.editor.isActive("callout", { type: "success" }),
+ isWarning: ctx.editor.isActive("callout", { type: "warning" }),
+ isDanger: ctx.editor.isActive("callout", { type: "danger" }),
+ };
+ },
+ });
+
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
@@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return (
setCalloutType("info")}
size="lg"
aria-label={t("Info")}
- variant={
- editor.isActive("callout", { type: "info" }) ? "light" : "default"
- }
+ variant={editorState?.isInfo ? "light" : "default"}
>
@@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")}
size="lg"
aria-label={t("Success")}
- variant={
- editor.isActive("callout", { type: "success" })
- ? "light"
- : "default"
- }
+ variant={editorState?.isSuccess ? "light" : "default"}
>
@@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")}
size="lg"
aria-label={t("Warning")}
- variant={
- editor.isActive("callout", { type: "warning" })
- ? "light"
- : "default"
- }
+ variant={editorState?.isWarning ? "light" : "default"}
>
@@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")}
size="lg"
aria-label={t("Danger")}
- variant={
- editor.isActive("callout", { type: "danger" })
- ? "light"
- : "default"
- }
+ variant={editorState?.isDanger ? "light" : "default"}
>
diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
index 76771b10..0efc2ec0 100644
--- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
+++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
-} from '@tiptap/react';
-import { useCallback } from 'react';
-import { sticky } from 'tippy.js';
-import { Node as PMNode } from 'prosemirror-model';
+ useEditorState,
+} from "@tiptap/react";
+import { useCallback } from "react";
+import { sticky } from "tippy.js";
+import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
-} from '@/features/editor/components/table/types/types.ts';
-import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
+} from "@/features/editor/components/table/types/types.ts";
+import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false;
}
- return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
+ return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
- [editor]
+ [editor],
);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const drawioAttr = ctx.editor.getAttributes("drawio");
+ return {
+ isDrawio: ctx.editor.isActive("drawio"),
+ width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
+ };
+ },
+ });
+
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
- const predicate = (node: PMNode) => node.type.name === 'drawio';
+ const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
@@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
- editor.commands.updateAttributes('drawio', { width: `${value}%` });
+ editor.commands.updateAttributes("drawio", { width: `${value}%` });
},
- [editor]
+ [editor],
);
return (
- {editor.getAttributes('drawio')?.width && (
-
+ {editorState?.width && (
+
)}
diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
index 5672e4f8..42329e5c 100644
--- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
+++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
-} from '@tiptap/react';
-import { useCallback } from 'react';
-import { sticky } from 'tippy.js';
-import { Node as PMNode } from 'prosemirror-model';
+ useEditorState,
+} from "@tiptap/react";
+import { useCallback } from "react";
+import { sticky } from "tippy.js";
+import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
-} from '@/features/editor/components/table/types/types.ts';
-import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
+} from "@/features/editor/components/table/types/types.ts";
+import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false;
}
- return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
+ return (
+ editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
+ );
},
- [editor]
+ [editor],
);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
+ return {
+ isExcalidraw: ctx.editor.isActive("excalidraw"),
+ width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
+ };
+ },
+ });
+
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
- const predicate = (node: PMNode) => node.type.name === 'excalidraw';
+ const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
@@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
- editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
+ editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
},
- [editor]
+ [editor],
);
return (
@@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8],
zIndex: 99,
popperOptions: {
- modifiers: [{ name: 'flip', enabled: false }],
+ modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
- sticky: 'popper',
+ sticky: "popper",
}}
shouldShow={shouldShow}
>
- {editor.getAttributes('excalidraw')?.width && (
-
+ {editorState?.width && (
+
)}
diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx
index abb1c1ca..723ec299 100644
--- a/apps/client/src/features/editor/components/image/image-menu.tsx
+++ b/apps/client/src/features/editor/components/image/image-menu.tsx
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
+ useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor],
);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const imageAttrs = ctx.editor.getAttributes("image");
+
+ return {
+ isImage: ctx.editor.isActive("image"),
+ isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
+ isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
+ isAlignRight: ctx.editor.isActive("image", { align: "right" }),
+ width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
+ };
+ },
+ });
+
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
@@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return (
@@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
- variant={
- editor.isActive("image", { align: "center" })
- ? "light"
- : "default"
- }
+ variant={editorState?.isAlignCenter ? "light" : "default"}
>
@@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
- variant={
- editor.isActive("image", { align: "right" }) ? "light" : "default"
- }
+ variant={editorState?.isAlignRight ? "light" : "default"}
>
- {editor.getAttributes("image")?.width && (
-
+ {editorState?.width && (
+
)}
);
diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx
index 7cdd2f0f..69f7c449 100644
--- a/apps/client/src/features/editor/components/link/link-menu.tsx
+++ b/apps/client/src/features/editor/components/link/link-menu.tsx
@@ -1,4 +1,4 @@
-import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
+import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link");
}, [editor]);
- const { href: link } = editor.getAttributes("link");
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+ const link = ctx.editor.getAttributes("link");
+ return {
+ href: link.href,
+ };
+ },
+ });
const handleEdit = useCallback(() => {
setShowEdit(true);
@@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs"
bg="var(--mantine-color-body)"
>
-
+
) : (
diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx
index 204f0b02..7508d4fe 100644
--- a/apps/client/src/features/editor/components/table/table-background-color.tsx
+++ b/apps/client/src/features/editor/components/table/table-background-color.tsx
@@ -9,7 +9,8 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
-import { useEditor } from "@tiptap/react";
+import type { Editor } from "@tiptap/react";
+import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
@@ -18,7 +19,7 @@ export interface TableColorItem {
}
interface TableBackgroundColorProps {
- editor: ReturnType;
+ editor: Editor | null;
}
const TABLE_COLORS: TableColorItem[] = [
@@ -38,37 +39,50 @@ export const TableBackgroundColor: FC = ({
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ let currentColor = "";
+ if (ctx.editor.isActive("tableCell")) {
+ const attrs = ctx.editor.getAttributes("tableCell");
+ currentColor = attrs.backgroundColor || "";
+ } else if (ctx.editor.isActive("tableHeader")) {
+ const attrs = ctx.editor.getAttributes("tableHeader");
+ currentColor = attrs.backgroundColor || "";
+ }
+
+ return {
+ currentColor,
+ isTableCell: ctx.editor.isActive("tableCell"),
+ isTableHeader: ctx.editor.isActive("tableHeader"),
+ };
+ },
+ });
+
+ if (!editor || !editorState) {
+ return null;
+ }
+
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
- .updateAttributes("tableCell", {
+ .updateAttributes("tableCell", {
backgroundColor: color || null,
- backgroundColorName: color ? colorName : null
+ backgroundColorName: color ? colorName : null,
})
- .updateAttributes("tableHeader", {
+ .updateAttributes("tableHeader", {
backgroundColor: color || null,
- backgroundColorName: color ? colorName : 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 (
= ({
cursor: "pointer",
}}
>
- {currentColor === item.color && (
+ {editorState.currentColor === item.color && (
;
+ editor: Editor | null;
}
interface AlignmentItem {
@@ -32,25 +32,44 @@ export const TableTextAlignment: FC = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ return {
+ isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
+ isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
+ isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
+ };
+ },
+ });
+
+ if (!editor || !editorState) {
+ return null;
+ }
+
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
- isActive: () => editor.isActive({ textAlign: "left" }),
+ isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
- isActive: () => editor.isActive({ textAlign: "center" }),
+ isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
- isActive: () => editor.isActive({ textAlign: "right" }),
+ isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
@@ -64,7 +83,7 @@ export const TableTextAlignment: FC = ({ editor }) => {
onChange={setOpened}
position="bottom"
withArrow
- transitionProps={{ transition: 'pop' }}
+ transitionProps={{ transition: "pop" }}
>
@@ -87,9 +106,7 @@ export const TableTextAlignment: FC = ({ editor }) => {
key={index}
variant="default"
leftSection={}
- rightSection={
- item.isActive() &&
- }
+ rightSection={item.isActive() && }
justify="left"
fullWidth
onClick={() => {
@@ -106,4 +123,4 @@ export const TableTextAlignment: FC = ({ editor }) => {
);
-};
\ No newline at end of file
+};
diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx
index 0c671cd6..3252e621 100644
--- a/apps/client/src/features/editor/components/video/video-menu.tsx
+++ b/apps/client/src/features/editor/components/video/video-menu.tsx
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
+ useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor],
);
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const videoAttrs = ctx.editor.getAttributes("video");
+
+ return {
+ isVideo: ctx.editor.isActive("video"),
+ isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
+ isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
+ isAlignRight: ctx.editor.isActive("video", { align: "right" }),
+ width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
+ };
+ },
+ });
+
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
@@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return (
@@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter}
size="lg"
aria-label={t("Align center")}
- variant={
- editor.isActive("video", { align: "center" })
- ? "light"
- : "default"
- }
+ variant={editorState?.isAlignCenter ? "light" : "default"}
>
@@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight}
size="lg"
aria-label={t("Align right")}
- variant={
- editor.isActive("video", { align: "right" }) ? "light" : "default"
- }
+ variant={editorState?.isAlignRight ? "light" : "default"}
>
- {editor.getAttributes("video")?.width && (
-
+ {editorState?.width && (
+
)}
);
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index e97a783f..a2dc0d93 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -7,7 +7,12 @@ import {
onAuthenticationFailedParameters,
WebSocketStatus,
} from "@hocuspocus/provider";
-import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
+import {
+ EditorContent,
+ EditorProvider,
+ useEditor,
+ useEditorState,
+} from "@tiptap/react";
import {
collabExtensions,
mainExtensions,
@@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
-import { searchSpotlight } from '@/features/search/constants.ts';
+import { searchSpotlight } from "@/features/search/constants.ts";
interface PageEditorProps {
pageId: string;
@@ -77,7 +82,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
- yjsConnectionStatusAtom
+ yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
@@ -213,17 +218,17 @@ export default function PageEditor({
extensions,
editable,
immediatelyRender: true,
- shouldRerenderOnTransaction: true,
+ shouldRerenderOnTransaction: false,
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
+ if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault();
return true;
}
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') {
+ if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@@ -268,9 +273,16 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
- [pageId, editable, remoteProvider]
+ [pageId, editable, remoteProvider],
);
+ const editorIsEditable = useEditorState({
+ editor,
+ selector: (ctx) => {
+ return ctx.editor?.isEditable ?? false;
+ },
+ });
+
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData(["pages", slugId]);
@@ -306,7 +318,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
- handleActiveCommentEvent
+ handleActiveCommentEvent,
);
};
}, []);
@@ -389,7 +401,7 @@ export default function PageEditor({
)}
- {editor && editor.isEditable && (
+ {editor && editorIsEditable && (
diff --git a/apps/client/src/features/group/components/create-group-form.tsx b/apps/client/src/features/group/components/create-group-form.tsx
index 10d4375f..a08e725a 100644
--- a/apps/client/src/features/group/components/create-group-form.tsx
+++ b/apps/client/src/features/group/components/create-group-form.tsx
@@ -1,11 +1,12 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
-import { useForm, zodResolver } from "@mantine/form";
+import { useForm } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next";
+import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
diff --git a/apps/client/src/features/group/components/edit-group-form.tsx b/apps/client/src/features/group/components/edit-group-form.tsx
index 0ea679b4..a682e0b4 100644
--- a/apps/client/src/features/group/components/edit-group-form.tsx
+++ b/apps/client/src/features/group/components/edit-group-form.tsx
@@ -4,10 +4,11 @@ import {
useGroupQuery,
useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts";
-import { useForm, zodResolver } from "@mantine/form";
+import { useForm } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
+import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(50),
diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts
index aeb7787f..01f4509e 100644
--- a/apps/client/src/features/group/queries/group-query.ts
+++ b/apps/client/src/features/group/queries/group-query.ts
@@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx";
+import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery(
params?: QueryParams,
@@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation
>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Group updated successfully" });
+ notifications.show({ message: t("Group updated successfully") });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
});
@@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
- notifications.show({ message: "Group deleted successfully" });
+ notifications.show({ message: t("Group deleted successfully") });
queryClient.refetchQueries({ queryKey: ["groups"] });
},
onError: (error) => {
@@ -119,11 +122,12 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Added successfully" });
+ notifications.show({ message: t("Added successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
@@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation<
void,
@@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Removed successfully" });
+ notifications.show({ message: t("Removed successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
diff --git a/apps/client/src/lib/types.ts b/apps/client/src/lib/types.ts
index fd0e3212..2a926d46 100644
--- a/apps/client/src/lib/types.ts
+++ b/apps/client/src/lib/types.ts
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
+ adminView?: boolean;
}
export enum UserRole {
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx
index 22c3f1c1..f084b089 100644
--- a/apps/client/src/main.tsx
+++ b/apps/client/src/main.tsx
@@ -1,6 +1,8 @@
import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
+import '@mantine/dates/styles.css';
+
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme";
diff --git a/apps/server/package.json b/apps/server/package.json
index 7f14c272..56adba66 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -38,6 +38,7 @@
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
+ "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2",
@@ -56,14 +57,15 @@
"@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1",
- "bullmq": "^5.53.2",
+ "bullmq": "^5.61.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
- "happy-dom": "^15.11.6",
+ "happy-dom": "^18.0.1",
+ "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
@@ -92,6 +94,7 @@
"socket.io": "^4.8.1",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
+ "typesense": "^2.1.0",
"ws": "^8.18.2",
"yauzl": "^3.2.0"
},
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 052faed2..56691444 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -16,6 +16,8 @@ import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
+import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
+import { RedisConfigService } from './integrations/redis/redis-config.service';
const enterpriseModules = [];
try {
@@ -36,6 +38,9 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
+ RedisModule.forRootAsync({
+ useClass: RedisConfigService,
+ }),
CollaborationModule,
WsModule,
QueueModule,
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 37645f44..008bfa31 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -35,11 +35,10 @@ import {
Subpages,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
-import { generateHTML } from '../common/helpers/prosemirror/html';
+import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
-import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts
index 23149288..7adeb043 100644
--- a/apps/server/src/common/events/event.contants.ts
+++ b/apps/server/src/common/events/event.contants.ts
@@ -1,3 +1,8 @@
export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated',
-}
\ No newline at end of file
+ PAGE_CREATED = 'page.created',
+ PAGE_UPDATED = 'page.updated',
+ PAGE_DELETED = 'page.deleted',
+ PAGE_SOFT_DELETED = 'page.soft_deleted',
+ PAGE_RESTORED = 'page.restored',
+}
diff --git a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts
index 3622ed4c..52196aa2 100644
--- a/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts
+++ b/apps/server/src/common/helpers/prosemirror/html/generateHTML.ts
@@ -1,21 +1,29 @@
-import { Extensions, getSchema, JSONContent } from '@tiptap/core';
-import { DOMSerializer, Node } from '@tiptap/pm/model';
-import { Window } from 'happy-dom';
+import { type Extensions, type JSONContent, getSchema } from '@tiptap/core';
+import { Node } from '@tiptap/pm/model';
+import { getHTMLFromFragment } from './getHTMLFromFragment';
+/**
+ * This function generates HTML from a ProseMirror JSON content object.
+ *
+ * @remarks **Important**: This function requires `happy-dom` to be installed in your project.
+ * @param doc - The ProseMirror JSON content object.
+ * @param extensions - The Tiptap extensions used to build the schema.
+ * @returns The generated HTML string.
+ * @example
+ * ```js
+ * const html = generateHTML(doc, extensions)
+ * console.log(html)
+ * ```
+ */
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
+ if (typeof window !== 'undefined') {
+ throw new Error(
+ 'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
+ );
+ }
+
const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc);
- const window = new Window();
-
- const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
- contentNode.content,
- {
- document: window.document as unknown as Document,
- },
- );
-
- const serializer = new window.XMLSerializer();
- // @ts-ignore
- return serializer.serializeToString(fragment as unknown as Node);
+ return getHTMLFromFragment(contentNode, schema);
}
diff --git a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts
index 23d66119..bd6e735c 100644
--- a/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts
+++ b/apps/server/src/common/helpers/prosemirror/html/generateJSON.ts
@@ -1,21 +1,55 @@
-import { Extensions, getSchema } from '@tiptap/core';
-import { DOMParser, ParseOptions } from '@tiptap/pm/model';
+import type { Extensions } from '@tiptap/core';
+import { getSchema } from '@tiptap/core';
+import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
-// this function does not work as intended
-// it has issues with closing tags
+/**
+ * Generates a JSON object from the given HTML string and converts it into a Prosemirror node with content.
+ * @remarks **Important**: This function requires `happy-dom` to be installed in your project.
+ * @param {string} html - The HTML string to be converted into a Prosemirror node.
+ * @param {Extensions} extensions - The extensions to be used for generating the schema.
+ * @param {ParseOptions} options - The options to be supplied to the parser.
+ * @returns {Promise>} - A promise with the generated JSON object.
+ * @example
+ * const html = 'Hello, world!
'
+ * const extensions = [...]
+ * const json = generateJSON(html, extensions)
+ * console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] }
+ */
export function generateJSON(
html: string,
extensions: Extensions,
options?: ParseOptions,
): Record {
- const schema = getSchema(extensions);
+ if (typeof window !== 'undefined') {
+ throw new Error(
+ 'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
+ );
+ }
- const window = new Window();
- const document = window.document;
- document.body.innerHTML = html;
+ const localWindow = new Window();
+ const localDOMParser = new localWindow.DOMParser();
+ let result: Record;
- return DOMParser.fromSchema(schema)
- .parse(document as never, options)
- .toJSON();
+ try {
+ const schema = getSchema(extensions);
+ let doc: ReturnType | null = null;
+
+ const htmlString = `${html}`;
+ doc = localDOMParser.parseFromString(htmlString, 'text/html');
+
+ if (!doc) {
+ throw new Error('Failed to parse HTML string');
+ }
+
+ result = PMDOMParser.fromSchema(schema)
+ .parse(doc.body as unknown as Node, options)
+ .toJSON();
+ } finally {
+ // clean up happy-dom to avoid memory leaks
+ localWindow.happyDOM.abort();
+ localWindow.happyDOM.close();
+ }
+
+ return result;
}
diff --git a/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts
new file mode 100644
index 00000000..635ee6a4
--- /dev/null
+++ b/apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts
@@ -0,0 +1,54 @@
+import type { Node, Schema } from '@tiptap/pm/model';
+import { DOMSerializer } from '@tiptap/pm/model';
+import { Window } from 'happy-dom';
+
+/**
+ * Returns the HTML string representation of a given document node.
+ *
+ * @remarks **Important**: This function requires `happy-dom` to be installed in your project.
+ * @param doc - The document node to serialize.
+ * @param schema - The Prosemirror schema to use for serialization.
+ * @returns A promise containing the HTML string representation of the document fragment.
+ *
+ * @example
+ * ```typescript
+ * const html = getHTMLFromFragment(doc, schema)
+ * ```
+ */
+export function getHTMLFromFragment(
+ doc: Node,
+ schema: Schema,
+ options?: { document?: Document },
+): string {
+ if (options?.document) {
+ const wrap = options.document.createElement('div');
+
+ DOMSerializer.fromSchema(schema).serializeFragment(
+ doc.content,
+ { document: options.document },
+ wrap,
+ );
+ return wrap.innerHTML;
+ }
+
+ const localWindow = new Window();
+ let result: string;
+
+ try {
+ const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
+ doc.content,
+ {
+ document: localWindow.document as unknown as Document,
+ },
+ );
+
+ const serializer = new localWindow.XMLSerializer();
+ result = serializer.serializeToString(fragment as any);
+ } finally {
+ // clean up happy-dom to avoid memory leaks
+ localWindow.happyDOM.abort();
+ localWindow.happyDOM.close();
+ }
+
+ return result;
+}
diff --git a/apps/server/src/common/validator/is-iso6391.ts b/apps/server/src/common/validator/is-iso6391.ts
new file mode 100644
index 00000000..888157f0
--- /dev/null
+++ b/apps/server/src/common/validator/is-iso6391.ts
@@ -0,0 +1,34 @@
+// MIT - https://github.com/typestack/class-validator/pull/2626
+import isISO6391Validator from 'validator/lib/isISO6391';
+import { buildMessage, ValidateBy, ValidationOptions } from 'class-validator';
+
+export const IS_ISO6391 = 'isISO6391';
+
+/**
+ * Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
+ */
+export function isISO6391(value: unknown): boolean {
+ return typeof value === 'string' && isISO6391Validator(value);
+}
+
+/**
+ * Check if the string is a valid [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) officially assigned language code.
+ */
+export function IsISO6391(
+ validationOptions?: ValidationOptions,
+): PropertyDecorator {
+ return ValidateBy(
+ {
+ name: IS_ISO6391,
+ validator: {
+ validate: (value, args): boolean => isISO6391(value),
+ defaultMessage: buildMessage(
+ (eachPrefix) =>
+ eachPrefix + '$property must be a valid ISO 639-1 language code',
+ validationOptions,
+ ),
+ },
+ },
+ validationOptions,
+ );
+}
diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts
index 06322712..0f7db401 100644
--- a/apps/server/src/core/auth/dto/jwt-payload.ts
+++ b/apps/server/src/core/auth/dto/jwt-payload.ts
@@ -4,6 +4,7 @@ export enum JwtType {
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
+ API_KEY = 'api_key',
}
export type JwtPayload = {
sub: string;
@@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
workspaceId: string;
type: 'mfa_token';
}
+
+export type JwtApiKeyPayload = {
+ sub: string;
+ workspaceId: string;
+ apiKeyId: string;
+ type: 'api_key';
+};
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index f1c4c5c0..0415b22d 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
+ JwtApiKeyPayload,
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
@@ -77,10 +78,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
- async generateMfaToken(
- user: User,
- workspaceId: string,
- ): Promise {
+ async generateMfaToken(user: User, workspaceId: string): Promise {
if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
@@ -93,6 +91,27 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '5m' });
}
+ async generateApiToken(opts: {
+ apiKeyId: string;
+ user: User;
+ workspaceId: string;
+ expiresIn?: string | number;
+ }): Promise {
+ const { apiKeyId, user, workspaceId, expiresIn } = opts;
+ if (user.deactivatedAt || user.deletedAt) {
+ throw new ForbiddenException();
+ }
+
+ const payload: JwtApiKeyPayload = {
+ sub: user.id,
+ apiKeyId: apiKeyId,
+ workspaceId,
+ type: JwtType.API_KEY,
+ };
+
+ return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
+ }
+
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index c31a597b..7616fb61 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
-import { JwtPayload, JwtType } from '../dto/jwt-payload';
+import { JwtApiKeyPayload, 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';
+import { ModuleRef } from '@nestjs/core';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService,
+ private moduleRef: ModuleRef,
) {
super({
jwtFromRequest: (req: FastifyRequest) => {
@@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
- async validate(req: any, payload: JwtPayload) {
- if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
+ async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
+ if (!payload.workspaceId) {
throw new UnauthorizedException();
}
@@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match');
}
+ if (payload.type === JwtType.API_KEY) {
+ return this.validateApiKey(req, payload as JwtApiKeyPayload);
+ }
+
+ if (payload.type !== JwtType.ACCESS) {
+ throw new UnauthorizedException();
+ }
+
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
@@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace };
}
+
+ private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
+ let ApiKeyModule: any;
+ let isApiKeyModuleReady = false;
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ ApiKeyModule = require('./../../../ee/api-key/api-key.service');
+ isApiKeyModuleReady = true;
+ } catch (err) {
+ this.logger.debug(
+ 'API Key module requested but enterprise module not bundled in this build',
+ );
+ isApiKeyModuleReady = false;
+ }
+
+ if (isApiKeyModuleReady) {
+ const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
+ strict: false,
+ });
+
+ return ApiKeyService.validateApiKey(payload);
+ }
+
+ throw new UnauthorizedException('Enterprise API Key module missing');
+ }
}
diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
index 2fcac679..7344fcbb 100644
--- a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
+++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
@@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
return build();
}
diff --git a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
index 99d56ffe..00d1677d 100644
--- a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
+++ b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
@@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
Space = 'space',
Group = 'group',
Attachment = 'attachment',
+ API = 'api_key',
}
export type IWorkspaceAbility =
@@ -18,4 +19,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
- | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
+ | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
+ | [WorkspaceCaslAction, WorkspaceCaslSubject.API];
diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts
index 42693e3d..9dfba84a 100644
--- a/apps/server/src/core/page/page.module.ts
+++ b/apps/server/src/core/page/page.module.ts
@@ -9,6 +9,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService],
- imports: [StorageModule]
+ imports: [StorageModule],
})
export class PageModule {}
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index a538eedf..b3d08c6b 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
+import { EventName } from '../../../common/events/event.contants';
+import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class PageService {
@@ -49,6 +51,7 @@ export class PageService {
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
+ private eventEmitter: EventEmitter2,
) {}
async findById(
@@ -231,21 +234,28 @@ export class PageService {
);
}
- // update spaceId in shares
if (pageIds.length > 0) {
+ // update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
- }
- // Update attachments
- await this.attachmentRepo.updateAttachmentsByPageId(
- { spaceId },
- pageIds,
- trx,
- );
+ // Update comments
+ await trx
+ .updateTable('comments')
+ .set({ spaceId: spaceId })
+ .where('pageId', 'in', pageIds)
+ .execute();
+
+ // Update attachments
+ await this.attachmentRepo.updateAttachmentsByPageId(
+ { spaceId },
+ pageIds,
+ trx,
+ );
+ }
});
}
@@ -380,6 +390,11 @@ export class PageService {
await this.db.insertInto('pages').values(insertablePages).execute();
+ const insertedPageIds = insertablePages.map((page) => page.id);
+ this.eventEmitter.emit(EventName.PAGE_CREATED, {
+ pageIds: insertedPageIds,
+ });
+
//TODO: best to handle this in a queue
const attachmentsIds = Array.from(attachmentMap.keys());
if (attachmentsIds.length > 0) {
@@ -606,6 +621,9 @@ export class PageService {
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
+ this.eventEmitter.emit(EventName.PAGE_DELETED, {
+ pageIds: pageIds,
+ });
}
}
diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts
index bf8db9d1..8f5b343d 100644
--- a/apps/server/src/core/search/dto/search-response.dto.ts
+++ b/apps/server/src/core/search/dto/search-response.dto.ts
@@ -1,3 +1,5 @@
+import { Space } from '@docmost/db/types/entity.types';
+
export class SearchResponseDto {
id: string;
title: string;
@@ -8,4 +10,5 @@ export class SearchResponseDto {
highlight: string;
createdAt: Date;
updatedAt: Date;
+ space: Partial;
}
diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts
index 35083faf..c968c344 100644
--- a/apps/server/src/core/search/search.controller.ts
+++ b/apps/server/src/core/search/search.controller.ts
@@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
+ Logger,
Post,
UseGuards,
} from '@nestjs/common';
@@ -24,13 +25,19 @@ import {
} from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator';
+import { EnvironmentService } from '../../integrations/environment/environment.service';
+import { ModuleRef } from '@nestjs/core';
@UseGuards(JwtAuthGuard)
@Controller('search')
export class SearchController {
+ private readonly logger = new Logger(SearchController.name);
+
constructor(
private readonly searchService: SearchService,
private readonly spaceAbility: SpaceAbilityFactory,
+ private readonly environmentService: EnvironmentService,
+ private moduleRef: ModuleRef,
) {}
@HttpCode(HttpStatus.OK)
@@ -53,7 +60,14 @@ export class SearchController {
}
}
- return this.searchService.searchPage(searchDto.query, searchDto, {
+ if (this.environmentService.getSearchDriver() === 'typesense') {
+ return this.searchTypesense(searchDto, {
+ userId: user.id,
+ workspaceId: workspace.id,
+ });
+ }
+
+ return this.searchService.searchPage(searchDto, {
userId: user.id,
workspaceId: workspace.id,
});
@@ -81,8 +95,47 @@ export class SearchController {
throw new BadRequestException('shareId is required');
}
- return this.searchService.searchPage(searchDto.query, searchDto, {
+ if (this.environmentService.getSearchDriver() === 'typesense') {
+ return this.searchTypesense(searchDto, {
+ workspaceId: workspace.id,
+ });
+ }
+
+ return this.searchService.searchPage(searchDto, {
workspaceId: workspace.id,
});
}
+
+ async searchTypesense(
+ searchParams: SearchDTO,
+ opts: {
+ userId?: string;
+ workspaceId: string;
+ },
+ ) {
+ const { userId, workspaceId } = opts;
+ let TypesenseModule: any;
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ TypesenseModule = require('./../../ee/typesense/services/page-search.service');
+
+ const PageSearchService = this.moduleRef.get(
+ TypesenseModule.PageSearchService,
+ {
+ strict: false,
+ },
+ );
+
+ return PageSearchService.searchPage(searchParams, {
+ userId: userId,
+ workspaceId,
+ });
+ } catch (err) {
+ this.logger.debug(
+ 'Typesense module requested but enterprise module not bundled in this build',
+ );
+ }
+
+ throw new BadRequestException('Enterprise Typesense search module missing');
+ }
}
diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts
index 60432ce8..0f8dbb90 100644
--- a/apps/server/src/core/search/search.service.ts
+++ b/apps/server/src/core/search/search.service.ts
@@ -21,13 +21,14 @@ export class SearchService {
) {}
async searchPage(
- query: string,
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise {
+ const { query } = searchParams;
+
if (query.length < 1) {
return;
}
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index a0182a77..8e4df4e1 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
enforceMfa: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ restrictApiToAdmins: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 694eaa44..a3db431e 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -303,6 +303,15 @@ export class WorkspaceService {
}
}
+ if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
+ await this.workspaceRepo.updateApiSettings(
+ workspaceId,
+ 'restrictToAdmins',
+ updateWorkspaceDto.restrictApiToAdmins,
+ );
+ delete updateWorkspaceDto.restrictApiToAdmins;
+ }
+
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 68c35dd3..bd331ada 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
+import { PageListener } from '@docmost/db/listeners/page.listener';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
- ShareRepo
+ ShareRepo,
+ PageListener,
],
exports: [
WorkspaceRepo,
@@ -90,7 +92,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
- ShareRepo
+ ShareRepo,
],
})
export class DatabaseModule
diff --git a/apps/server/src/database/listeners/page.listener.ts b/apps/server/src/database/listeners/page.listener.ts
new file mode 100644
index 00000000..7e2d97e2
--- /dev/null
+++ b/apps/server/src/database/listeners/page.listener.ts
@@ -0,0 +1,49 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import { EventName } from '../../common/events/event.contants';
+import { InjectQueue } from '@nestjs/bullmq';
+import { QueueJob, QueueName } from '../../integrations/queue/constants';
+import { Queue } from 'bullmq';
+
+export class PageEvent {
+ pageIds: string[];
+}
+
+@Injectable()
+export class PageListener {
+ private readonly logger = new Logger(PageListener.name);
+
+ constructor(
+ @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
+ ) {}
+
+ @OnEvent(EventName.PAGE_CREATED)
+ async handlePageCreated(event: PageEvent) {
+ const { pageIds } = event;
+ await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds });
+ }
+
+ @OnEvent(EventName.PAGE_UPDATED)
+ async handlePageUpdated(event: PageEvent) {
+ const { pageIds } = event;
+ await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
+ }
+
+ @OnEvent(EventName.PAGE_DELETED)
+ async handlePageDeleted(event: PageEvent) {
+ const { pageIds } = event;
+ await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
+ }
+
+ @OnEvent(EventName.PAGE_SOFT_DELETED)
+ async handlePageSoftDeleted(event: PageEvent) {
+ const { pageIds } = event;
+ await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
+ }
+
+ @OnEvent(EventName.PAGE_RESTORED)
+ async handlePageRestored(event: PageEvent) {
+ const { pageIds } = event;
+ await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
+ }
+}
diff --git a/apps/server/src/database/migrations/20250912T101500-api-keys.ts b/apps/server/src/database/migrations/20250912T101500-api-keys.ts
new file mode 100644
index 00000000..da130ff7
--- /dev/null
+++ b/apps/server/src/database/migrations/20250912T101500-api-keys.ts
@@ -0,0 +1,30 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('api_keys')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('name', 'text', (col) => col)
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.notNull().references('users.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('expires_at', 'timestamptz')
+ .addColumn('last_used_at', 'timestamptz')
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz', (col) => col)
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('api_keys').execute();
+}
diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts
index e0481910..63d02f18 100644
--- a/apps/server/src/database/pagination/pagination-options.ts
+++ b/apps/server/src/database/pagination/pagination-options.ts
@@ -1,4 +1,5 @@
import {
+ IsBoolean,
IsNumber,
IsOptional,
IsPositive,
@@ -23,4 +24,8 @@ export class PaginationOptions {
@IsOptional()
@IsString()
query: string;
+
+ @IsOptional()
+ @IsBoolean()
+ adminView: boolean;
}
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 0ceca366..ca46ddc9 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -14,32 +14,17 @@ import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import { EventName } from '../../../common/events/event.contants';
@Injectable()
export class PageRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo,
+ private eventEmitter: EventEmitter2,
) {}
- withHasChildren(eb: ExpressionBuilder) {
- return eb
- .selectFrom('pages as child')
- .select((eb) =>
- eb
- .case()
- .when(eb.fn.countAll(), '>', 0)
- .then(true)
- .else(false)
- .end()
- .as('count'),
- )
- .whereRef('child.parentPageId', '=', 'pages.id')
- .where('child.deletedAt', 'is', null)
- .limit(1)
- .as('hasChildren');
- }
-
private baseFields: Array = [
'id',
'slugId',
@@ -63,7 +48,7 @@ export class PageRepo {
pageId: string,
opts?: {
includeContent?: boolean;
- includeText?: boolean;
+ includeTextContent?: boolean;
includeYdoc?: boolean;
includeSpace?: boolean;
includeCreator?: boolean;
@@ -80,8 +65,8 @@ export class PageRepo {
.selectFrom('pages')
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
- .$if(opts?.includeText, (qb) => qb.select('textContent'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
+ .$if(opts?.includeTextContent, (qb) => qb.select('textContent'))
.$if(opts?.includeHasChildren, (qb) =>
qb.select((eb) => this.withHasChildren(eb)),
);
@@ -128,7 +113,7 @@ export class PageRepo {
pageIds: string[],
trx?: KyselyTransaction,
) {
- return dbOrTx(this.db, trx)
+ const result = await dbOrTx(this.db, trx)
.updateTable('pages')
.set({ ...updatePageData, updatedAt: new Date() })
.where(
@@ -137,6 +122,12 @@ export class PageRepo {
pageIds,
)
.executeTakeFirst();
+
+ this.eventEmitter.emit(EventName.PAGE_UPDATED, {
+ pageIds: pageIds,
+ });
+
+ return result;
}
async insertPage(
@@ -144,11 +135,17 @@ export class PageRepo {
trx?: KyselyTransaction,
): Promise {
const db = dbOrTx(this.db, trx);
- return db
+ const result = await db
.insertInto('pages')
.values(insertablePage)
.returning(this.baseFields)
.executeTakeFirst();
+
+ this.eventEmitter.emit(EventName.PAGE_CREATED, {
+ pageIds: [result.id],
+ });
+
+ return result;
}
async deletePage(pageId: string): Promise {
@@ -198,6 +195,9 @@ export class PageRepo {
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
});
+ this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
+ pageIds: pageIds,
+ });
}
}
@@ -261,6 +261,9 @@ export class PageRepo {
.where('id', '=', pageId)
.execute();
}
+ this.eventEmitter.emit(EventName.PAGE_RESTORED, {
+ pageIds: pageIds,
+ });
}
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -381,6 +384,24 @@ export class PageRepo {
).as('contributors');
}
+ withHasChildren(eb: ExpressionBuilder) {
+ return eb
+ .selectFrom('pages as child')
+ .select((eb) =>
+ eb
+ .case()
+ .when(eb.fn.countAll(), '>', 0)
+ .then(true)
+ .else(false)
+ .end()
+ .as('count'),
+ )
+ .whereRef('child.parentPageId', '=', 'pages.id')
+ .where('child.deletedAt', 'is', null)
+ .limit(1)
+ .as('hasChildren');
+ }
+
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean },
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index 6a15fcd9..790d6402 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -157,4 +157,22 @@ export class WorkspaceRepo {
return activeUsers.length;
}
+
+ async updateApiSettings(
+ workspaceId: string,
+ prefKey: string,
+ prefValue: string | boolean,
+ ) {
+ return this.db
+ .updateTable('workspaces')
+ .set({
+ settings: sql`COALESCE(settings, '{}'::jsonb)
+ || jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
+ || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', workspaceId)
+ .returning(this.baseFields)
+ .executeTakeFirst();
+ }
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 2f8baaf7..fe5b8fab 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -3,13 +3,18 @@
* Please do not edit it manually.
*/
-import type { ColumnType } from "kysely";
+import type { ColumnType } from 'kysely';
-export type Generated = T extends ColumnType
- ? ColumnType
- : ColumnType;
+export type Generated =
+ T extends ColumnType
+ ? ColumnType
+ : ColumnType;
-export type Int8 = ColumnType;
+export type Int8 = ColumnType<
+ string,
+ bigint | number | string,
+ bigint | number | string
+>;
export type Json = JsonValue;
@@ -25,6 +30,18 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType;
+export interface ApiKeys {
+ createdAt: Generated;
+ deletedAt: Timestamp | null;
+ expiresAt: Timestamp | null;
+ id: Generated;
+ lastUsedAt: Timestamp | null;
+ name: string | null;
+ updatedAt: Generated;
+ creatorId: string;
+ workspaceId: string;
+}
+
export interface Attachments {
createdAt: Generated;
creatorId: string;
@@ -344,6 +361,7 @@ export interface Workspaces {
}
export interface DB {
+ apiKeys: ApiKeys;
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts
index 02f4a962..bf9c4ea5 100644
--- a/apps/server/src/database/types/db.interface.ts
+++ b/apps/server/src/database/types/db.interface.ts
@@ -1,4 +1,5 @@
import {
+ ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
@@ -42,4 +43,5 @@ export interface DbInterface {
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
+ apiKeys: ApiKeys;
}
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index 428a2e60..db68a8d7 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -19,6 +19,7 @@ import {
Shares,
FileTasks,
UserMfa as _UserMFA,
+ ApiKeys,
} from './db';
import { Embeddings } from '@docmost/db/types/embeddings.types';
@@ -121,6 +122,11 @@ export type UserMFA = Selectable<_UserMFA>;
export type InsertableUserMFA = Insertable<_UserMFA>;
export type UpdatableUserMFA = Updateable>;
+// Api Keys
+export type ApiKey = Selectable;
+export type InsertableApiKey = Insertable;
+export type UpdatableApiKey = Updateable>;
+
// Page Embedding
export type Embedding = Selectable;
export type InsertableEmbedding = Insertable;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index dc1f3e8c..bce4a883 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit dc1f3e8c78d780c514539825e1b46d9a0d1520cc
+Subproject commit bce4a8839d7c4bfd3fa7380321518a4eab5ab76b
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index 6a47e9bb..4c917aac 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -214,6 +214,26 @@ export class EnvironmentService {
return this.configService.get('POSTHOG_KEY');
}
+ getSearchDriver(): string {
+ return this.configService
+ .get('SEARCH_DRIVER', 'database')
+ .toLowerCase();
+ }
+
+ getTypesenseUrl(): string {
+ return this.configService
+ .get('TYPESENSE_URL', 'http://localhost:8108')
+ .toLowerCase();
+ }
+
+ getTypesenseApiKey(): string {
+ return this.configService.get('TYPESENSE_API_KEY');
+ }
+
+ getTypesenseLocale(): string {
+ return this.configService.get('TYPESENSE_LOCALE', 'en').toLowerCase();
+ }
+
getOpenAiApiKey(): string {
return this.configService.get('OPENAI_API_KEY');
}
diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts
index a2aeb6dd..d59558f8 100644
--- a/apps/server/src/integrations/environment/environment.validation.ts
+++ b/apps/server/src/integrations/environment/environment.validation.ts
@@ -3,12 +3,14 @@ import {
IsNotEmpty,
IsNotIn,
IsOptional,
+ IsString,
IsUrl,
MinLength,
ValidateIf,
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
+import { IsISO6391 } from '../../common/validator/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
@@ -68,6 +70,37 @@ export class EnvironmentVariables {
)
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string;
+
+ @IsOptional()
+ @IsIn(['database', 'typesense'])
+ @IsString()
+ SEARCH_DRIVER: string;
+
+ @IsOptional()
+ @IsUrl(
+ {
+ protocols: ['http', 'https'],
+ require_tld: false,
+ allow_underscores: true,
+ },
+ {
+ message:
+ 'TYPESENSE_URL must be a valid typesense url e.g http://localhost:8108',
+ },
+ )
+ @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
+ TYPESENSE_URL: string;
+
+ @IsOptional()
+ @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
+ @IsString()
+ TYPESENSE_API_KEY: string;
+
+ @IsOptional()
+ @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
+ @IsISO6391()
+ @IsString()
+ TYPESENSE_LOCALE: string;
}
export function validate(config: Record) {
diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts
index f7d93ec0..6337f9e1 100644
--- a/apps/server/src/integrations/import/services/file-import-task.service.ts
+++ b/apps/server/src/integrations/import/services/file-import-task.service.ts
@@ -32,6 +32,8 @@ import { ImportAttachmentService } from './import-attachment.service';
import { ModuleRef } from '@nestjs/core';
import { PageService } from '../../../core/page/services/page.service';
import { ImportPageNode } from '../dto/file-task-dto';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import { EventName } from '../../../common/events/event.contants';
@Injectable()
export class FileImportTaskService {
@@ -45,6 +47,7 @@ export class FileImportTaskService {
@InjectKysely() private readonly db: KyselyDB,
private readonly importAttachmentService: ImportAttachmentService,
private moduleRef: ModuleRef,
+ private eventEmitter: EventEmitter2,
) {}
async processZIpImport(fileTaskId: string): Promise {
@@ -396,6 +399,12 @@ export class FileImportTaskService {
}
}
+ if (validPageIds.size > 0) {
+ this.eventEmitter.emit(EventName.PAGE_CREATED, {
+ pageIds: Array.from(validPageIds),
+ });
+ }
+
this.logger.log(
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
);
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index cf630817..26234c31 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -4,6 +4,7 @@ export enum QueueName {
GENERAL_QUEUE = '{general-queue}',
BILLING_QUEUE = '{billing-queue}',
FILE_TASK_QUEUE = '{file-task-queue}',
+ SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
}
@@ -27,6 +28,23 @@ export enum QueueJob {
IMPORT_TASK = 'import-task',
EXPORT_TASK = 'export-task',
+ SEARCH_INDEX_PAGE = 'search-index-page',
+ SEARCH_INDEX_PAGES = 'search-index-pages',
+ SEARCH_INDEX_COMMENT = 'search-index-comment',
+ SEARCH_INDEX_COMMENTS = 'search-index-comments',
+ SEARCH_INDEX_ATTACHMENT = 'search-index-attachment',
+ SEARCH_INDEX_ATTACHMENTS = 'search-index-attachments',
+ SEARCH_REMOVE_PAGE = 'search-remove-page',
+ SEARCH_REMOVE_ASSET = 'search-remove-attachment',
+ SEARCH_REMOVE_FACE = 'search-remove-comment',
+ TYPESENSE_FLUSH = 'typesense-flush',
+
+ PAGE_CREATED = 'page-created',
+ PAGE_UPDATED = 'page-updated',
+ PAGE_SOFT_DELETED = 'page-soft-deleted',
+ PAGE_RESTORED = 'page-restored',
+ PAGE_DELETED = 'page-deleted',
+
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
}
diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts
index 39c5a034..6787e010 100644
--- a/apps/server/src/integrations/queue/queue.module.ts
+++ b/apps/server/src/integrations/queue/queue.module.ts
@@ -57,6 +57,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
+ BullModule.registerQueue({
+ name: QueueName.SEARCH_QUEUE,
+ defaultJobOptions: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ attempts: 2,
+ },
+ }),
BullModule.registerQueue({
name: QueueName.AI_QUEUE,
defaultJobOptions: {
diff --git a/apps/server/src/integrations/redis/redis-config.service.ts b/apps/server/src/integrations/redis/redis-config.service.ts
new file mode 100644
index 00000000..719f89f1
--- /dev/null
+++ b/apps/server/src/integrations/redis/redis-config.service.ts
@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+import {
+ RedisModuleOptions,
+ RedisOptionsFactory,
+} from '@nestjs-labs/nestjs-ioredis';
+import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
+import { EnvironmentService } from '../environment/environment.service';
+
+@Injectable()
+export class RedisConfigService implements RedisOptionsFactory {
+ constructor(private readonly environmentService: EnvironmentService) {}
+ createRedisOptions(): RedisModuleOptions {
+ const redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
+ return {
+ readyLog: true,
+ config: {
+ host: redisConfig.host,
+ port: redisConfig.port,
+ password: redisConfig.password,
+ db: redisConfig.db,
+ family: redisConfig.family,
+ retryStrategy: createRetryStrategy(),
+ },
+ };
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8ccbe50..7f736dc9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -236,6 +236,9 @@ importers:
'@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)
+ '@mantine/dates':
+ specifier: ^8.3.2
+ version: 8.3.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/hooks@8.1.3(react@18.3.1))(dayjs@1.11.13)(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)
@@ -450,9 +453,12 @@ importers:
'@langchain/textsplitters':
specifier: ^0.1.0
version: 0.1.0(@langchain/core@0.3.72(@opentelemetry/api@1.9.0)(openai@5.12.2(ws@8.18.2)(zod@3.25.56)))
+ '@nestjs-labs/nestjs-ioredis':
+ specifier: ^11.0.4
+ version: 11.0.4(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(ioredis@5.4.1)
'@nestjs/bullmq':
specifier: ^11.0.2
- version: 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.53.2)
+ version: 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.61.0)
'@nestjs/common':
specifier: ^11.1.3
version: 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -505,8 +511,8 @@ importers:
specifier: ^5.1.1
version: 5.1.1
bullmq:
- specifier: ^5.53.2
- version: 5.53.2
+ specifier: ^5.61.0
+ version: 5.61.0
cache-manager:
specifier: ^6.4.3
version: 6.4.3
@@ -526,8 +532,11 @@ importers:
specifier: ^11.3.0
version: 11.3.0
happy-dom:
- specifier: ^15.11.6
- version: 15.11.7
+ specifier: ^18.0.1
+ version: 18.0.1
+ ioredis:
+ specifier: ^5.4.1
+ version: 5.4.1
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@@ -612,6 +621,9 @@ importers:
tmp-promise:
specifier: ^3.0.3
version: 3.0.3
+ typesense:
+ specifier: ^2.1.0
+ version: 2.1.0(@babel/runtime@7.25.6)
ws:
specifier: ^8.18.2
version: 8.18.2
@@ -2699,6 +2711,15 @@ packages:
react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x
+ '@mantine/dates@8.3.2':
+ resolution: {integrity: sha512-qO9Aft+icFGSeLFTbbHfef/UIKpmUzwujsYuRFw8o6cqOqhqjlC9ObE/3DATxvS+vK9BxODUZYGtE2sI4XUO3Q==}
+ peerDependencies:
+ '@mantine/core': 8.3.2
+ '@mantine/hooks': 8.3.2
+ dayjs: '>=1.0.0'
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
'@mantine/form@8.1.3':
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
peerDependencies:
@@ -2842,6 +2863,14 @@ packages:
'@napi-rs/wasm-runtime@0.2.4':
resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==}
+ '@nestjs-labs/nestjs-ioredis@11.0.4':
+ resolution: {integrity: sha512-4jPNOrxDiwNMIN5OLmsMWhA782kxv/ZBxkySX9l8n6sr55acHX/BciaFsOXVa/ILsm+Y7893y98/6WNhmEoiNQ==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ '@nestjs/common': ^10.0.0 || ^11.0.0
+ '@nestjs/core': ^10.0.0 || ^11.0.0
+ ioredis: ^5.0.0
+
'@nestjs/bull-shared@11.0.2':
resolution: {integrity: sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==}
peerDependencies:
@@ -4569,6 +4598,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+ '@types/node@20.19.19':
+ resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
+
'@types/node@22.10.0':
resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==}
@@ -4662,6 +4694,9 @@ packages:
'@types/validator@13.12.0':
resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
'@types/ws@8.5.14':
resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==}
@@ -5242,8 +5277,8 @@ packages:
builtins@5.0.1:
resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==}
- bullmq@5.53.2:
- resolution: {integrity: sha512-xHgxrP/yNJHD7VCw1h+eRBh+2TCPBCM39uC9gCyksYc6ufcJP+HTZ/A2lzB2x7qMFWrvsX7tM40AT2BmdkYL/Q==}
+ bullmq@5.61.0:
+ resolution: {integrity: sha512-khaTjc1JnzaYFl4FrUtsSsqugAW/urRrcZ9Q0ZE+REAw8W+gkHFqxbGlutOu6q7j7n91wibVaaNlOUMdiEvoSQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
@@ -6568,9 +6603,9 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
- happy-dom@15.11.7:
- resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
- engines: {node: '>=18.0.0'}
+ happy-dom@18.0.1:
+ resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==}
+ engines: {node: '>=20.0.0'}
has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
@@ -7493,10 +7528,6 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
- luxon@3.5.0:
- resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
- engines: {node: '>=12'}
-
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
@@ -9551,6 +9582,12 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ typesense@2.1.0:
+ resolution: {integrity: sha512-a/IRTL+dRXlpRDU4UodyGj8hl5xBz3nKihVRd/KfSFAfFPGcpdX6lxIgwdXy3O6VLNNiEsN8YwIsPHQPVT0vNw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@babel/runtime': ^7.23.2
+
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -9581,6 +9618,9 @@ packages:
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
undici@7.10.0:
resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==}
engines: {node: '>=20.18.1'}
@@ -12875,6 +12915,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ '@mantine/dates@8.3.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/hooks@8.1.3(react@18.3.1))(dayjs@1.11.13)(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)
+ clsx: 2.1.1
+ dayjs: 1.11.13
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@mantine/form@8.1.3(react@18.3.1)':
dependencies:
fast-deep-equal: 3.1.3
@@ -13000,18 +13049,25 @@ snapshots:
'@emnapi/runtime': 1.2.0
'@tybys/wasm-util': 0.9.0
+ '@nestjs-labs/nestjs-ioredis@11.0.4(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(ioredis@5.4.1)':
+ dependencies:
+ '@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ ioredis: 5.4.1
+ tslib: 2.8.1
+
'@nestjs/bull-shared@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)':
dependencies:
'@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
- '@nestjs/bullmq@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.53.2)':
+ '@nestjs/bullmq@11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)(bullmq@5.61.0)':
dependencies:
'@nestjs/bull-shared': 11.0.2(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)
'@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- bullmq: 5.53.2
+ bullmq: 5.61.0
tslib: 2.8.1
'@nestjs/cli@11.0.4(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)':
@@ -14796,6 +14852,10 @@ snapshots:
'@types/ms@2.1.0': {}
+ '@types/node@20.19.19':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@22.10.0':
dependencies:
undici-types: 6.20.0
@@ -14910,6 +14970,8 @@ snapshots:
'@types/validator@13.12.0': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
'@types/ws@8.5.14':
dependencies:
'@types/node': 22.13.4
@@ -15673,7 +15735,7 @@ snapshots:
dependencies:
semver: 7.7.2
- bullmq@5.53.2:
+ bullmq@5.61.0:
dependencies:
cron-parser: 4.9.0
ioredis: 5.4.1
@@ -15681,7 +15743,7 @@ snapshots:
node-abort-controller: 3.1.1
semver: 7.7.2
tslib: 2.8.1
- uuid: 9.0.1
+ uuid: 11.1.0
transitivePeerDependencies:
- supports-color
@@ -16009,7 +16071,7 @@ snapshots:
cron-parser@4.9.0:
dependencies:
- luxon: 3.5.0
+ luxon: 3.6.1
cron@4.3.0:
dependencies:
@@ -17223,10 +17285,10 @@ snapshots:
hachure-fill@0.5.2: {}
- happy-dom@15.11.7:
+ happy-dom@18.0.1:
dependencies:
- entities: 4.5.0
- webidl-conversions: 7.0.0
+ '@types/node': 20.19.19
+ '@types/whatwg-mimetype': 3.0.2
whatwg-mimetype: 3.0.0
has-bigints@1.0.2: {}
@@ -18331,8 +18393,6 @@ snapshots:
dependencies:
yallist: 4.0.0
- luxon@3.5.0: {}
-
luxon@3.6.1: {}
magic-string@0.30.17:
@@ -20679,6 +20739,15 @@ snapshots:
typescript@5.7.3: {}
+ typesense@2.1.0(@babel/runtime@7.25.6):
+ dependencies:
+ '@babel/runtime': 7.25.6
+ axios: 1.9.0
+ loglevel: 1.9.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - debug
+
uc.micro@2.1.0: {}
ufo@1.6.1: {}
@@ -20704,6 +20773,8 @@ snapshots:
undici-types@6.20.0: {}
+ undici-types@6.21.0: {}
+
undici@7.10.0: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}