Merge branch 'main' into ai-vector

This commit is contained in:
Philipinho
2025-10-07 21:21:42 +01:00
72 changed files with 2090 additions and 291 deletions

View File

@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b", "@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3", "@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3", "@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3", "@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3", "@mantine/modals": "^8.1.3",

View File

@ -533,5 +533,26 @@
"Remove image": "Remove image", "Remove image": "Remove image",
"Failed to remove image": "Failed to remove image", "Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "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"
} }

View File

@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; 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() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -96,8 +98,10 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />

View File

@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps { interface NoTableResultsProps {
colSpan: number; colSpan: number;
text?: string;
} }
export default function NoTableResults({ colSpan }: NoTableResultsProps) { export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={colSpan}> <Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center"> <Text fw={500} c="dimmed" ta="center">
{t("No results found...")} {text || t("No results found...")}
</Text> </Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts"; import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }), 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 }),
});
};

View File

@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush, icon: IconBrush,
path: "/settings/account/preferences", 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: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { 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": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
break; break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default: default:
break; break;
} }

View File

@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}

View File

@ -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 (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -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<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
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 (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -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<typeof formSchema>;
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<FormValues>({
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 (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -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";

View File

@ -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<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(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 (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -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<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(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 (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -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<IPagination<IApiKey>, 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<IApiKey, Error, ICreateApiKeyRequest>({
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<IApiKey, Error, IUpdateApiKeyRequest>({
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" });
},
});
}

View File

@ -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<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}

View File

@ -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<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}

View File

@ -3,6 +3,7 @@ import {
BubbleMenuProps, BubbleMenuProps,
isNodeSelection, isNodeSelection,
useEditor, useEditor,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [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[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Bold", name: "Bold",
isActive: () => props.editor.isActive("bold"), isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(), command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold, icon: IconBold,
}, },
{ {
name: "Italic", name: "Italic",
isActive: () => props.editor.isActive("italic"), isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(), command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic, icon: IconItalic,
}, },
{ {
name: "Underline", name: "Underline",
isActive: () => props.editor.isActive("underline"), isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(), command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline, icon: IconUnderline,
}, },
{ {
name: "Strike", name: "Strike",
isActive: () => props.editor.isActive("strike"), isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(), command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough, icon: IconStrikethrough,
}, },
{ {
name: "Code", name: "Code",
isActive: () => props.editor.isActive("code"), isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(), command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode, icon: IconCode,
}, },
@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = { const commentItem: BubbleMenuItem = {
name: "Comment", name: "Comment",
isActive: () => props.editor.isActive("comment"), isActive: () => editorState?.isComment,
command: () => { command: () => {
const commentId = uuid7(); const commentId = uuid7();

View File

@ -9,7 +9,8 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
} }
interface ColorSelectorProps { interface ColorSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -108,12 +109,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen, setIsOpen,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
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 }) => const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }), editorState[`text_${color}`]
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }), editorState[`highlight_${color}`]
); );
return ( return (
@ -151,7 +176,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
justify="left" justify="left"
fullWidth fullWidth
rightSection={ rightSection={
editor.isActive("textStyle", { color }) && ( editorState[`text_${color}`] && (
<IconCheck style={{ width: rem(16) }} /> <IconCheck style={{ width: rem(16) }} />
) )
} }

View File

@ -13,11 +13,12 @@ import {
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; 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"; import { useTranslation } from "react-i18next";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); 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[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Text", name: "Text",
@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(), editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => isActive: () =>
editor.isActive("paragraph") && editorState?.isParagraph &&
!editor.isActive("bulletList") && !editorState?.isBulletList &&
!editor.isActive("orderedList"), !editorState?.isOrderedList,
}, },
{ {
name: "Heading 1", name: "Heading 1",
icon: IconH1, icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }), isActive: () => editorState?.isHeading1,
}, },
{ {
name: "Heading 2", name: "Heading 2",
icon: IconH2, icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }), isActive: () => editorState?.isHeading2,
}, },
{ {
name: "Heading 3", name: "Heading 3",
icon: IconH3, icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }), isActive: () => editorState?.isHeading3,
}, },
{ {
name: "To-do List", name: "To-do List",
icon: IconCheckbox, icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(), command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"), isActive: () => editorState?.isTaskItem,
}, },
{ {
name: "Bullet List", name: "Bullet List",
icon: IconList, icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(), command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"), isActive: () => editorState?.isBulletList,
}, },
{ {
name: "Numbered List", name: "Numbered List",
icon: IconListNumbers, icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(), command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"), isActive: () => editorState?.isOrderedList,
}, },
{ {
name: "Blockquote", name: "Blockquote",
@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph") .toggleNode("paragraph", "paragraph")
.toggleBlockquote() .toggleBlockquote()
.run(), .run(),
isActive: () => editor.isActive("blockquote"), isActive: () => editorState?.isBlockquote,
}, },
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"), isActive: () => editorState?.isCodeBlock,
}, },
]; ];

View File

@ -8,11 +8,12 @@ import {
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; 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"; import { useTranslation } from "react-i18next";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => { }) => {
const { t } = useTranslation(); 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[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Align left", name: "Align left",
isActive: () => editor.isActive({ textAlign: "left" }), isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
isActive: () => editor.isActive({ textAlign: "center" }), isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
isActive: () => editor.isActive({ textAlign: "right" }), isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
{ {
name: "Justify", name: "Justify",
isActive: () => editor.isActive({ textAlign: "justify" }), isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(), command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified, icon: IconAlignJustified,
}, },
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? { const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
name: "Multiple",
};
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<IconAlignLeft style={{ width: rem(16) }} stroke={2} /> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Popover.Target> </Popover.Target>

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
@ -9,7 +10,7 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip, Divider } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [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 getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`callout-menu}`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -111,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={ variant={editorState?.isInfo ? "light" : "default"}
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled size={18} />
</ActionIcon> </ActionIcon>
@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={ variant={editorState?.isSuccess ? "light" : "default"}
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled size={18} />
</ActionIcon> </ActionIcon>
@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={ variant={editorState?.isWarning ? "light" : "default"}
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled size={18} />
</ActionIcon> </ActionIcon>
@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={ variant={editorState?.isDanger ? "light" : "default"}
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
> >
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} />
</ActionIcon> </ActionIcon>

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false; 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 getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; 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); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` }); editor.commands.updateAttributes("drawio", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu}`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('drawio')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false; 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 getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; 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); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` }); editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('excalidraw')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor], [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 getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu}`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={ variant={editorState?.isAlignLeft ? "light" : "default"}
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={ variant={editorState?.isAlignCenter ? "light" : "default"}
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={ variant={editorState?.isAlignRight ? "light" : "default"}
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editor.getAttributes("image")?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );

View File

@ -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 React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; 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"); return editor.isActive("link");
}, [editor]); }, [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(() => { const handleEdit = useCallback(() => {
setShowEdit(true); setShowEdit(true);
@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs" padding="xs"
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
> >
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} /> <LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card> </Card>
) : ( ) : (
<LinkPreviewPanel <LinkPreviewPanel
url={link} url={editorState?.href}
onClear={onUnsetLink} onClear={onUnsetLink}
onEdit={handleEdit} onEdit={handleEdit}
/> />

View File

@ -9,7 +9,8 @@ import {
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface TableColorItem { export interface TableColorItem {
@ -18,7 +19,7 @@ export interface TableColorItem {
} }
interface TableBackgroundColorProps { interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
} }
const TABLE_COLORS: TableColorItem[] = [ const TABLE_COLORS: TableColorItem[] = [
@ -38,37 +39,50 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); 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) => { const setTableCellBackground = (color: string, colorName: string) => {
editor editor
.chain() .chain()
.focus() .focus()
.updateAttributes("tableCell", { .updateAttributes("tableCell", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null backgroundColorName: color ? colorName : null,
}) })
.updateAttributes("tableHeader", { .updateAttributes("tableHeader", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null backgroundColorName: color ? colorName : null,
}) })
.run(); .run();
setOpened(false); 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 ( return (
<Popover <Popover
width={200} width={200}
@ -123,7 +137,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer", cursor: "pointer",
}} }}
> >
{currentColor === item.color && ( {editorState.currentColor === item.color && (
<IconCheck <IconCheck
size={18} size={18}
style={{ style={{

View File

@ -9,15 +9,15 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Popover, Popover,
rem,
ScrollArea, ScrollArea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps { interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
} }
interface AlignmentItem { interface AlignmentItem {
@ -32,25 +32,44 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); 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[] = [ const items: AlignmentItem[] = [
{ {
name: "Align left", name: "Align left",
value: "left", value: "left",
isActive: () => editor.isActive({ textAlign: "left" }), isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
value: "center", value: "center",
isActive: () => editor.isActive({ textAlign: "center" }), isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
value: "right", value: "right",
isActive: () => editor.isActive({ textAlign: "right" }), isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
@ -64,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened} onChange={setOpened}
position="bottom" position="bottom"
withArrow withArrow
transitionProps={{ transition: 'pop' }} transitionProps={{ transition: "pop" }}
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text alignment")} withArrow> <Tooltip label={t("Text alignment")} withArrow>
@ -87,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index} key={index}
variant="default" variant="default"
leftSection={<item.icon size={16} />} leftSection={<item.icon size={16} />}
rightSection={ rightSection={item.isActive() && <IconCheck size={16} />}
item.isActive() && <IconCheck size={16} />
}
justify="left" justify="left"
fullWidth fullWidth
onClick={() => { onClick={() => {

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor], [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 getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`video-menu}`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@ -103,9 +123,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft} onClick={alignVideoLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={ variant={editorState?.isAlignLeft ? "light" : "default"}
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter} onClick={alignVideoCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={ variant={editorState?.isAlignCenter ? "light" : "default"}
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight} onClick={alignVideoRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={ variant={editorState?.isAlignRight ? "light" : "default"}
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editor.getAttributes("video")?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );

View File

@ -7,7 +7,12 @@ import {
onAuthenticationFailedParameters, onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { EditorContent, EditorProvider, useEditor } from "@tiptap/react"; import {
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import { import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts'; import { searchSpotlight } from "@/features/search/constants.ts";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@ -77,7 +82,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;
@ -213,17 +218,17 @@ export default function PageEditor({
extensions, extensions,
editable, editable,
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') { if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@ -268,9 +273,16 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); 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 debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@ -306,7 +318,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent handleActiveCommentEvent,
); );
}; };
}, []); }, []);
@ -389,7 +401,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editor.isEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />

View File

@ -1,11 +1,12 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts"; 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 * as z from "zod";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx"; import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(2).max(50), name: z.string().trim().min(2).max(50),

View File

@ -4,10 +4,11 @@ import {
useGroupQuery, useGroupQuery,
useUpdateGroupMutation, useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts"; } from "@/features/group/queries/group-query.ts";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import * as z from "zod"; import * as z from "zod";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),

View File

@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery( export function useGetGroupsQuery(
params?: QueryParams, params?: QueryParams,
@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() { export function useUpdateGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data), mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" }); notifications.show({ message: t("Group updated successfully") });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["group", variables.groupId], queryKey: ["group", variables.groupId],
}); });
@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() { export function useDeleteGroupMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }), mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" }); notifications.show({ message: t("Group deleted successfully") });
queryClient.refetchQueries({ queryKey: ["groups"] }); queryClient.refetchQueries({ queryKey: ["groups"] });
}, },
onError: (error) => { onError: (error) => {
@ -119,11 +122,12 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({ return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data), mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" }); notifications.show({ message: t("Added successfully") });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() { export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation< return useMutation<
void, void,
@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
>({ >({
mutationFn: (data) => removeGroupMember(data), mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" }); notifications.show({ message: t("Removed successfully") });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });

View File

@ -2,6 +2,7 @@ export interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
limit?: number; limit?: number;
adminView?: boolean;
} }
export enum UserRole { export enum UserRole {

View File

@ -1,6 +1,8 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css"; import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import '@mantine/dates/styles.css';
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme"; import { mantineCssResolver, theme } from "@/theme";

View File

@ -38,6 +38,7 @@
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.2", "@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
@ -56,14 +57,15 @@
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.53.2", "bullmq": "^5.61.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"happy-dom": "^15.11.6", "happy-dom": "^18.0.1",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
@ -92,6 +94,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.2", "ws": "^8.18.2",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },

View File

@ -16,6 +16,8 @@ import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module'; import { ImportModule } from './integrations/import/import.module';
import { SecurityModule } from './integrations/security/security.module'; import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.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 = []; const enterpriseModules = [];
try { try {
@ -36,6 +38,9 @@ try {
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CollaborationModule, CollaborationModule,
WsModule, WsModule,
QueueModule, QueueModule,

View File

@ -35,11 +35,10 @@ import {
Subpages, Subpages,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not 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/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [

View File

@ -1,3 +1,8 @@
export enum EventName { export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated', COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated',
PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored',
} }

View File

@ -1,21 +1,29 @@
import { Extensions, getSchema, JSONContent } from '@tiptap/core'; import { type Extensions, type JSONContent, getSchema } from '@tiptap/core';
import { DOMSerializer, Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { Window } from 'happy-dom'; 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 { 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 schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc); const contentNode = Node.fromJSON(schema, doc);
const window = new Window(); return getHTMLFromFragment(contentNode, schema);
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);
} }

View File

@ -1,21 +1,55 @@
import { Extensions, getSchema } from '@tiptap/core'; import type { Extensions } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model'; import { getSchema } from '@tiptap/core';
import { type ParseOptions, DOMParser as PMDOMParser } from '@tiptap/pm/model';
import { Window } from 'happy-dom'; 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<Record<string, any>>} - A promise with the generated JSON object.
* @example
* const html = '<p>Hello, world!</p>'
* const extensions = [...]
* const json = generateJSON(html, extensions)
* console.log(json) // { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }] }
*/
export function generateJSON( export function generateJSON(
html: string, html: string,
extensions: Extensions, extensions: Extensions,
options?: ParseOptions, options?: ParseOptions,
): Record<string, any> { ): Record<string, any> {
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 localWindow = new Window();
const localDOMParser = new localWindow.DOMParser();
let result: Record<string, any>;
try {
const schema = getSchema(extensions); const schema = getSchema(extensions);
let doc: ReturnType<typeof localDOMParser.parseFromString> | null = null;
const window = new Window(); const htmlString = `<!DOCTYPE html><html><body>${html}</body></html>`;
const document = window.document; doc = localDOMParser.parseFromString(htmlString, 'text/html');
document.body.innerHTML = html;
return DOMParser.fromSchema(schema) if (!doc) {
.parse(document as never, options) throw new Error('Failed to parse HTML string');
}
result = PMDOMParser.fromSchema(schema)
.parse(doc.body as unknown as Node, options)
.toJSON(); .toJSON();
} finally {
// clean up happy-dom to avoid memory leaks
localWindow.happyDOM.abort();
localWindow.happyDOM.close();
}
return result;
} }

View File

@ -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;
}

View File

@ -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,
);
}

View File

@ -4,6 +4,7 @@ export enum JwtType {
EXCHANGE = 'exchange', EXCHANGE = 'exchange',
ATTACHMENT = 'attachment', ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token', MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
} }
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
workspaceId: string; workspaceId: string;
type: 'mfa_token'; type: 'mfa_token';
} }
export type JwtApiKeyPayload = {
sub: string;
workspaceId: string;
apiKeyId: string;
type: 'api_key';
};

View File

@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import {
JwtApiKeyPayload,
JwtAttachmentPayload, JwtAttachmentPayload,
JwtCollabPayload, JwtCollabPayload,
JwtExchangePayload, JwtExchangePayload,
@ -77,10 +78,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '1h' }); return this.jwtService.sign(payload, { expiresIn: '1h' });
} }
async generateMfaToken( async generateMfaToken(user: User, workspaceId: string): Promise<string> {
user: User,
workspaceId: string,
): Promise<string> {
if (user.deactivatedAt || user.deletedAt) { if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@ -93,6 +91,27 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '5m' }); return this.jwtService.sign(payload, { expiresIn: '5m' });
} }
async generateApiToken(opts: {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: string | number;
}): Promise<string> {
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) { async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(), secret: this.environmentService.getAppSecret(),

View File

@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; 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 { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify'; import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers'; import { extractBearerTokenFromHeader } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private userRepo: UserRepo, private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo, private workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) { ) {
super({ super({
jwtFromRequest: (req: FastifyRequest) => { jwtFromRequest: (req: FastifyRequest) => {
@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
async validate(req: any, payload: JwtPayload) { async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) { if (!payload.workspaceId) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match'); 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); const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) { if (!workspace) {
@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace }; 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');
}
} }

View File

@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build(); return build();
} }
@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build(); return build();
} }
@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space); can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group); can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
return build(); return build();
} }

View File

@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
Space = 'space', Space = 'space',
Group = 'group', Group = 'group',
Attachment = 'attachment', Attachment = 'attachment',
API = 'api_key',
} }
export type IWorkspaceAbility = export type IWorkspaceAbility =
@ -18,4 +19,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member] | [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space] | [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group] | [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]; | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];

View File

@ -9,6 +9,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
controllers: [PageController], controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule] imports: [StorageModule],
}) })
export class PageModule {} export class PageModule {}

View File

@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable() @Injectable()
export class PageService { export class PageService {
@ -49,6 +51,7 @@ export class PageService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
private eventEmitter: EventEmitter2,
) {} ) {}
async findById( async findById(
@ -231,14 +234,20 @@ export class PageService {
); );
} }
// update spaceId in shares
if (pageIds.length > 0) { if (pageIds.length > 0) {
// update spaceId in shares
await trx await trx
.updateTable('shares') .updateTable('shares')
.set({ spaceId: spaceId }) .set({ spaceId: spaceId })
.where('pageId', 'in', pageIds) .where('pageId', 'in', pageIds)
.execute(); .execute();
}
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
// Update attachments // Update attachments
await this.attachmentRepo.updateAttachmentsByPageId( await this.attachmentRepo.updateAttachmentsByPageId(
@ -246,6 +255,7 @@ export class PageService {
pageIds, pageIds,
trx, trx,
); );
}
}); });
} }
@ -380,6 +390,11 @@ export class PageService {
await this.db.insertInto('pages').values(insertablePages).execute(); 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 //TODO: best to handle this in a queue
const attachmentsIds = Array.from(attachmentMap.keys()); const attachmentsIds = Array.from(attachmentMap.keys());
if (attachmentsIds.length > 0) { if (attachmentsIds.length > 0) {
@ -606,6 +621,9 @@ export class PageService {
if (pageIds.length > 0) { if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
this.eventEmitter.emit(EventName.PAGE_DELETED, {
pageIds: pageIds,
});
} }
} }

View File

@ -1,3 +1,5 @@
import { Space } from '@docmost/db/types/entity.types';
export class SearchResponseDto { export class SearchResponseDto {
id: string; id: string;
title: string; title: string;
@ -8,4 +10,5 @@ export class SearchResponseDto {
highlight: string; highlight: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
space: Partial<Space>;
} }

View File

@ -5,6 +5,7 @@ import {
ForbiddenException, ForbiddenException,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger,
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
@ -24,13 +25,19 @@ import {
} from '../casl/interfaces/space-ability.type'; } from '../casl/interfaces/space-ability.type';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { Public } from 'src/common/decorators/public.decorator'; import { Public } from 'src/common/decorators/public.decorator';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { ModuleRef } from '@nestjs/core';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('search') @Controller('search')
export class SearchController { export class SearchController {
private readonly logger = new Logger(SearchController.name);
constructor( constructor(
private readonly searchService: SearchService, private readonly searchService: SearchService,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) {} ) {}
@HttpCode(HttpStatus.OK) @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, userId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
@ -81,8 +95,47 @@ export class SearchController {
throw new BadRequestException('shareId is required'); 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, 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');
}
} }

View File

@ -21,13 +21,14 @@ export class SearchService {
) {} ) {}
async searchPage( async searchPage(
query: string,
searchParams: SearchDTO, searchParams: SearchDTO,
opts: { opts: {
userId?: string; userId?: string;
workspaceId: string; workspaceId: string;
}, },
): Promise<SearchResponseDto[]> { ): Promise<SearchResponseDto[]> {
const { query } = searchParams;
if (query.length < 1) { if (query.length < 1) {
return; return;
} }

View File

@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enforceMfa: boolean; enforceMfa: boolean;
@IsOptional()
@IsBoolean()
restrictApiToAdmins: boolean;
} }

View File

@ -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); await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {

View File

@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.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 // https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val)); types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo, AttachmentRepo,
UserTokenRepo, UserTokenRepo,
BacklinkRepo, BacklinkRepo,
ShareRepo ShareRepo,
PageListener,
], ],
exports: [ exports: [
WorkspaceRepo, WorkspaceRepo,
@ -90,7 +92,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo, AttachmentRepo,
UserTokenRepo, UserTokenRepo,
BacklinkRepo, BacklinkRepo,
ShareRepo ShareRepo,
], ],
}) })
export class DatabaseModule export class DatabaseModule

View File

@ -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 });
}
}

View File

@ -0,0 +1,30 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('api_keys').execute();
}

View File

@ -1,4 +1,5 @@
import { import {
IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive, IsPositive,
@ -23,4 +24,8 @@ export class PaginationOptions {
@IsOptional() @IsOptional()
@IsString() @IsString()
query: string; query: string;
@IsOptional()
@IsBoolean()
adminView: boolean;
} }

View File

@ -14,32 +14,17 @@ import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class PageRepo { export class PageRepo {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
private eventEmitter: EventEmitter2,
) {} ) {}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
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<keyof Page> = [ private baseFields: Array<keyof Page> = [
'id', 'id',
'slugId', 'slugId',
@ -63,7 +48,7 @@ export class PageRepo {
pageId: string, pageId: string,
opts?: { opts?: {
includeContent?: boolean; includeContent?: boolean;
includeText?: boolean; includeTextContent?: boolean;
includeYdoc?: boolean; includeYdoc?: boolean;
includeSpace?: boolean; includeSpace?: boolean;
includeCreator?: boolean; includeCreator?: boolean;
@ -80,8 +65,8 @@ export class PageRepo {
.selectFrom('pages') .selectFrom('pages')
.select(this.baseFields) .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content')) .$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeText, (qb) => qb.select('textContent'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc')) .$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
.$if(opts?.includeTextContent, (qb) => qb.select('textContent'))
.$if(opts?.includeHasChildren, (qb) => .$if(opts?.includeHasChildren, (qb) =>
qb.select((eb) => this.withHasChildren(eb)), qb.select((eb) => this.withHasChildren(eb)),
); );
@ -128,7 +113,7 @@ export class PageRepo {
pageIds: string[], pageIds: string[],
trx?: KyselyTransaction, trx?: KyselyTransaction,
) { ) {
return dbOrTx(this.db, trx) const result = await dbOrTx(this.db, trx)
.updateTable('pages') .updateTable('pages')
.set({ ...updatePageData, updatedAt: new Date() }) .set({ ...updatePageData, updatedAt: new Date() })
.where( .where(
@ -137,6 +122,12 @@ export class PageRepo {
pageIds, pageIds,
) )
.executeTakeFirst(); .executeTakeFirst();
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: pageIds,
});
return result;
} }
async insertPage( async insertPage(
@ -144,11 +135,17 @@ export class PageRepo {
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<Page> { ): Promise<Page> {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, trx);
return db const result = await db
.insertInto('pages') .insertInto('pages')
.values(insertablePage) .values(insertablePage)
.returning(this.baseFields) .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id],
});
return result;
} }
async deletePage(pageId: string): Promise<void> { async deletePage(pageId: string): Promise<void> {
@ -198,6 +195,9 @@ export class PageRepo {
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute(); 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) .where('id', '=', pageId)
.execute(); .execute();
} }
this.eventEmitter.emit(EventName.PAGE_RESTORED, {
pageIds: pageIds,
});
} }
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) { async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@ -381,6 +384,24 @@ export class PageRepo {
).as('contributors'); ).as('contributors');
} }
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
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( async getPageAndDescendants(
parentPageId: string, parentPageId: string,
opts: { includeContent: boolean }, opts: { includeContent: boolean },

View File

@ -157,4 +157,22 @@ export class WorkspaceRepo {
return activeUsers.length; 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();
}
} }

View File

@ -3,13 +3,18 @@
* Please do not edit it manually. * Please do not edit it manually.
*/ */
import type { ColumnType } from "kysely"; import type { ColumnType } from 'kysely';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>; export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Json = JsonValue; export type Json = JsonValue;
@ -25,6 +30,18 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface ApiKeys {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
expiresAt: Timestamp | null;
id: Generated<string>;
lastUsedAt: Timestamp | null;
name: string | null;
updatedAt: Generated<Timestamp>;
creatorId: string;
workspaceId: string;
}
export interface Attachments { export interface Attachments {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
creatorId: string; creatorId: string;
@ -344,6 +361,7 @@ export interface Workspaces {
} }
export interface DB { export interface DB {
apiKeys: ApiKeys;
attachments: Attachments; attachments: Attachments;
authAccounts: AuthAccounts; authAccounts: AuthAccounts;
authProviders: AuthProviders; authProviders: AuthProviders;

View File

@ -1,4 +1,5 @@
import { import {
ApiKeys,
Attachments, Attachments,
AuthAccounts, AuthAccounts,
AuthProviders, AuthProviders,
@ -42,4 +43,5 @@ export interface DbInterface {
userTokens: UserTokens; userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations; workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces; workspaces: Workspaces;
apiKeys: ApiKeys;
} }

View File

@ -19,6 +19,7 @@ import {
Shares, Shares,
FileTasks, FileTasks,
UserMfa as _UserMFA, UserMfa as _UserMFA,
ApiKeys,
} from './db'; } from './db';
import { Embeddings } from '@docmost/db/types/embeddings.types'; import { Embeddings } from '@docmost/db/types/embeddings.types';
@ -121,6 +122,11 @@ export type UserMFA = Selectable<_UserMFA>;
export type InsertableUserMFA = Insertable<_UserMFA>; export type InsertableUserMFA = Insertable<_UserMFA>;
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>; export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
// Api Keys
export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Page Embedding // Page Embedding
export type Embedding = Selectable<Embeddings>; export type Embedding = Selectable<Embeddings>;
export type InsertableEmbedding = Insertable<Embeddings>; export type InsertableEmbedding = Insertable<Embeddings>;

View File

@ -214,6 +214,26 @@ export class EnvironmentService {
return this.configService.get<string>('POSTHOG_KEY'); return this.configService.get<string>('POSTHOG_KEY');
} }
getSearchDriver(): string {
return this.configService
.get<string>('SEARCH_DRIVER', 'database')
.toLowerCase();
}
getTypesenseUrl(): string {
return this.configService
.get<string>('TYPESENSE_URL', 'http://localhost:8108')
.toLowerCase();
}
getTypesenseApiKey(): string {
return this.configService.get<string>('TYPESENSE_API_KEY');
}
getTypesenseLocale(): string {
return this.configService.get<string>('TYPESENSE_LOCALE', 'en').toLowerCase();
}
getOpenAiApiKey(): string { getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY'); return this.configService.get<string>('OPENAI_API_KEY');
} }

View File

@ -3,12 +3,14 @@ import {
IsNotEmpty, IsNotEmpty,
IsNotIn, IsNotIn,
IsOptional, IsOptional,
IsString,
IsUrl, IsUrl,
MinLength, MinLength,
ValidateIf, ValidateIf,
validateSync, validateSync,
} from 'class-validator'; } from 'class-validator';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validator/is-iso6391';
export class EnvironmentVariables { export class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
@ -68,6 +70,37 @@ export class EnvironmentVariables {
) )
@ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase()) @ValidateIf((obj) => obj.CLOUD === 'true'.toLowerCase())
SUBDOMAIN_HOST: string; 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<string, any>) { export function validate(config: Record<string, any>) {

View File

@ -32,6 +32,8 @@ import { ImportAttachmentService } from './import-attachment.service';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { PageService } from '../../../core/page/services/page.service'; import { PageService } from '../../../core/page/services/page.service';
import { ImportPageNode } from '../dto/file-task-dto'; import { ImportPageNode } from '../dto/file-task-dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class FileImportTaskService { export class FileImportTaskService {
@ -45,6 +47,7 @@ export class FileImportTaskService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly importAttachmentService: ImportAttachmentService, private readonly importAttachmentService: ImportAttachmentService,
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
) {} ) {}
async processZIpImport(fileTaskId: string): Promise<void> { async processZIpImport(fileTaskId: string): Promise<void> {
@ -396,6 +399,12 @@ export class FileImportTaskService {
} }
} }
if (validPageIds.size > 0) {
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: Array.from(validPageIds),
});
}
this.logger.log( this.logger.log(
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`, `Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
); );

View File

@ -4,6 +4,7 @@ export enum QueueName {
GENERAL_QUEUE = '{general-queue}', GENERAL_QUEUE = '{general-queue}',
BILLING_QUEUE = '{billing-queue}', BILLING_QUEUE = '{billing-queue}',
FILE_TASK_QUEUE = '{file-task-queue}', FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}', AI_QUEUE = '{ai-queue}',
} }
@ -27,6 +28,23 @@ export enum QueueJob {
IMPORT_TASK = 'import-task', IMPORT_TASK = 'import-task',
EXPORT_TASK = 'export-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', GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
} }

View File

@ -57,6 +57,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1, attempts: 1,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.SEARCH_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueName.AI_QUEUE, name: QueueName.AI_QUEUE,
defaultJobOptions: { defaultJobOptions: {

View File

@ -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(),
},
};
}
}

119
pnpm-lock.yaml generated
View File

@ -236,6 +236,9 @@ importers:
'@mantine/core': '@mantine/core':
specifier: ^8.1.3 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) 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': '@mantine/form':
specifier: ^8.1.3 specifier: ^8.1.3
version: 8.1.3(react@18.3.1) version: 8.1.3(react@18.3.1)
@ -450,9 +453,12 @@ importers:
'@langchain/textsplitters': '@langchain/textsplitters':
specifier: ^0.1.0 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))) 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': '@nestjs/bullmq':
specifier: ^11.0.2 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': '@nestjs/common':
specifier: ^11.1.3 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) 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 specifier: ^5.1.1
version: 5.1.1 version: 5.1.1
bullmq: bullmq:
specifier: ^5.53.2 specifier: ^5.61.0
version: 5.53.2 version: 5.61.0
cache-manager: cache-manager:
specifier: ^6.4.3 specifier: ^6.4.3
version: 6.4.3 version: 6.4.3
@ -526,8 +532,11 @@ importers:
specifier: ^11.3.0 specifier: ^11.3.0
version: 11.3.0 version: 11.3.0
happy-dom: happy-dom:
specifier: ^15.11.6 specifier: ^18.0.1
version: 15.11.7 version: 18.0.1
ioredis:
specifier: ^5.4.1
version: 5.4.1
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
@ -612,6 +621,9 @@ importers:
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
typesense:
specifier: ^2.1.0
version: 2.1.0(@babel/runtime@7.25.6)
ws: ws:
specifier: ^8.18.2 specifier: ^8.18.2
version: 8.18.2 version: 8.18.2
@ -2699,6 +2711,15 @@ packages:
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^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': '@mantine/form@8.1.3':
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==} resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
peerDependencies: peerDependencies:
@ -2842,6 +2863,14 @@ packages:
'@napi-rs/wasm-runtime@0.2.4': '@napi-rs/wasm-runtime@0.2.4':
resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} 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': '@nestjs/bull-shared@11.0.2':
resolution: {integrity: sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==} resolution: {integrity: sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==}
peerDependencies: peerDependencies:
@ -4569,6 +4598,9 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.19.19':
resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
'@types/node@22.10.0': '@types/node@22.10.0':
resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==} resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==}
@ -4662,6 +4694,9 @@ packages:
'@types/validator@13.12.0': '@types/validator@13.12.0':
resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.5.14': '@types/ws@8.5.14':
resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==}
@ -5242,8 +5277,8 @@ packages:
builtins@5.0.1: builtins@5.0.1:
resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==}
bullmq@5.53.2: bullmq@5.61.0:
resolution: {integrity: sha512-xHgxrP/yNJHD7VCw1h+eRBh+2TCPBCM39uC9gCyksYc6ufcJP+HTZ/A2lzB2x7qMFWrvsX7tM40AT2BmdkYL/Q==} resolution: {integrity: sha512-khaTjc1JnzaYFl4FrUtsSsqugAW/urRrcZ9Q0ZE+REAw8W+gkHFqxbGlutOu6q7j7n91wibVaaNlOUMdiEvoSQ==}
busboy@1.6.0: busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
@ -6568,9 +6603,9 @@ packages:
hachure-fill@0.5.2: hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
happy-dom@15.11.7: happy-dom@18.0.1:
resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==}
engines: {node: '>=18.0.0'} engines: {node: '>=20.0.0'}
has-bigints@1.0.2: has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
@ -7493,10 +7528,6 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
luxon@3.6.1: luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -9551,6 +9582,12 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true 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: uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@ -9581,6 +9618,9 @@ packages:
undici-types@6.20.0: undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@7.10.0: undici@7.10.0:
resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
@ -12875,6 +12915,15 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@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)': '@mantine/form@8.1.3(react@18.3.1)':
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@ -13000,18 +13049,25 @@ snapshots:
'@emnapi/runtime': 1.2.0 '@emnapi/runtime': 1.2.0
'@tybys/wasm-util': 0.9.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)': '@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: 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/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) '@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 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: 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/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/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) '@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 tslib: 2.8.1
'@nestjs/cli@11.0.4(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@22.13.4)': '@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/ms@2.1.0': {}
'@types/node@20.19.19':
dependencies:
undici-types: 6.21.0
'@types/node@22.10.0': '@types/node@22.10.0':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@ -14910,6 +14970,8 @@ snapshots:
'@types/validator@13.12.0': {} '@types/validator@13.12.0': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.5.14': '@types/ws@8.5.14':
dependencies: dependencies:
'@types/node': 22.13.4 '@types/node': 22.13.4
@ -15673,7 +15735,7 @@ snapshots:
dependencies: dependencies:
semver: 7.7.2 semver: 7.7.2
bullmq@5.53.2: bullmq@5.61.0:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
ioredis: 5.4.1 ioredis: 5.4.1
@ -15681,7 +15743,7 @@ snapshots:
node-abort-controller: 3.1.1 node-abort-controller: 3.1.1
semver: 7.7.2 semver: 7.7.2
tslib: 2.8.1 tslib: 2.8.1
uuid: 9.0.1 uuid: 11.1.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -16009,7 +16071,7 @@ snapshots:
cron-parser@4.9.0: cron-parser@4.9.0:
dependencies: dependencies:
luxon: 3.5.0 luxon: 3.6.1
cron@4.3.0: cron@4.3.0:
dependencies: dependencies:
@ -17223,10 +17285,10 @@ snapshots:
hachure-fill@0.5.2: {} hachure-fill@0.5.2: {}
happy-dom@15.11.7: happy-dom@18.0.1:
dependencies: dependencies:
entities: 4.5.0 '@types/node': 20.19.19
webidl-conversions: 7.0.0 '@types/whatwg-mimetype': 3.0.2
whatwg-mimetype: 3.0.0 whatwg-mimetype: 3.0.0
has-bigints@1.0.2: {} has-bigints@1.0.2: {}
@ -18331,8 +18393,6 @@ snapshots:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
luxon@3.5.0: {}
luxon@3.6.1: {} luxon@3.6.1: {}
magic-string@0.30.17: magic-string@0.30.17:
@ -20679,6 +20739,15 @@ snapshots:
typescript@5.7.3: {} 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: {} uc.micro@2.1.0: {}
ufo@1.6.1: {} ufo@1.6.1: {}
@ -20704,6 +20773,8 @@ snapshots:
undici-types@6.20.0: {} undici-types@6.20.0: {}
undici-types@6.21.0: {}
undici@7.10.0: {} undici@7.10.0: {}
unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-canonical-property-names-ecmascript@2.0.0: {}