mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
18 Commits
base-duck
...
17f3158a3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 17f3158a3b | |||
| b74ca00bfd | |||
| c247d4c1e3 | |||
| 641ce142df | |||
| 1d2486455f | |||
| a0aea43e25 | |||
| 09c69d7a0f | |||
| 9943e104a5 | |||
| b16f1e5a55 | |||
| 24be90b95f | |||
| 3ecf27c6b0 | |||
| 980521f957 | |||
| fe44dc92a9 | |||
| fad410ef23 | |||
| 15b8908b1a | |||
| 8e15b22d8c | |||
| ec83fc82d5 | |||
| a573acedd0 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.80.0",
|
||||
"version": "0.80.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -31,8 +31,8 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^25.10.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"jotai": "^2.18.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -42,7 +42,7 @@
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "1.363.1",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
@@ -50,7 +50,7 @@
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
@@ -74,7 +74,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -391,7 +391,7 @@
|
||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
|
||||
"Write...": "\"Schreiben...\"",
|
||||
"Column count": "Spaltenanzahl",
|
||||
"{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}",
|
||||
"{{count}} Columns": "{{count}} Spalten",
|
||||
"Equal columns": "Gleich breite Spalten",
|
||||
"Left sidebar": "Linke Seitenleiste",
|
||||
"Right sidebar": "Rechte Seitenleiste",
|
||||
|
||||
@@ -608,25 +608,21 @@
|
||||
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
|
||||
"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",
|
||||
"Update": "Update",
|
||||
"Update {{credential}}": "Update {{credential}}",
|
||||
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
|
||||
"Restrict API key creation to admins": "Restrict API key creation to admins",
|
||||
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
|
||||
@@ -880,5 +876,29 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"What can I help you with?": "What can I help you with?"
|
||||
"What can I help you with?": "What can I help you with?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
|
||||
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
|
||||
"Create {{credential}}": "Create {{credential}}",
|
||||
"{{credential}} created": "{{credential}} created",
|
||||
"{{credential}} created successfully": "{{credential}} created successfully",
|
||||
"Created by": "Created by",
|
||||
"Custom": "Custom",
|
||||
"Enable SCIM": "Enable SCIM",
|
||||
"Enter a descriptive name": "Enter a descriptive name",
|
||||
"I've saved my {{credential}}": "I've saved my {{credential}}",
|
||||
"Important": "Important",
|
||||
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
|
||||
"Never": "Never",
|
||||
"Revoke {{credential}}": "Revoke {{credential}}",
|
||||
"SCIM endpoint URL": "SCIM endpoint URL",
|
||||
"SCIM provisioning": "SCIM provisioning",
|
||||
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
|
||||
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
|
||||
"SCIM token": "SCIM token",
|
||||
"SCIM tokens": "SCIM tokens",
|
||||
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
|
||||
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
|
||||
"Token": "Token"
|
||||
}
|
||||
|
||||
@@ -116,7 +116,9 @@ export default function GlobalAppShell({
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={900}>{children}</Container>
|
||||
<Container size={900} pb={80}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
||||
import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
|
||||
queryFn: () => getVerificationList(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchScimTokens = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
prefetchBilling,
|
||||
prefetchGroups,
|
||||
prefetchLicense,
|
||||
prefetchScimTokens,
|
||||
prefetchShares,
|
||||
prefetchSpaces,
|
||||
prefetchSsoProviders,
|
||||
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
|
||||
}
|
||||
break;
|
||||
case "Security & SSO":
|
||||
prefetchHandler = prefetchSsoProviders;
|
||||
prefetchHandler = () => {
|
||||
prefetchSsoProviders();
|
||||
prefetchScimTokens();
|
||||
};
|
||||
break;
|
||||
case "Public sharing":
|
||||
prefetchHandler = prefetchShares;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("API key created")}
|
||||
title={t("{{credential}} created", { credential: t("API key") })}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
|
||||
color="red"
|
||||
>
|
||||
{t(
|
||||
"Make sure to copy your API key now. You won't be able to see it again!",
|
||||
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
|
||||
{ credential: t("API key") },
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={onClose} mt="md">
|
||||
{t("I've saved my API key")}
|
||||
{t("I've saved my {{credential}}", { credential: t("API key") })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create API Key")}
|
||||
title={t("Create {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
|
||||
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Revoke API key")}
|
||||
title={t("Revoke {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("Are you sure you want to revoke this API key")}{" "}
|
||||
{t("Are you sure you want to revoke this {{credential}}", {
|
||||
credential: t("API key"),
|
||||
})}{" "}
|
||||
<strong>{apiKey?.name}</strong>?
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Update API key")}
|
||||
title={t("Update {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
|
||||
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
|
||||
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
|
||||
mutationFn: (data) => createApiKey(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("API key created successfully") });
|
||||
notifications.show({
|
||||
message: t("{{credential}} created successfully", {
|
||||
credential: t("API key"),
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["api-key-list"].includes(item.queryKey[0] as string),
|
||||
|
||||
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"api_key.updated": "Updated API key",
|
||||
"api_key.deleted": "Deleted API key",
|
||||
|
||||
"scim_token.created": "Created SCIM token",
|
||||
"scim_token.updated": "Updated SCIM token",
|
||||
"scim_token.deleted": "Deleted SCIM token",
|
||||
|
||||
"space.created": "Created space",
|
||||
"space.updated": "Updated space",
|
||||
"space.deleted": "Deleted space",
|
||||
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "api_key.deleted", label: "Deleted API key" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SCIM token",
|
||||
items: [
|
||||
{ value: "scim_token.created", label: "Created SCIM token" },
|
||||
{ value: "scim_token.updated", label: "Updated SCIM token" },
|
||||
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "License",
|
||||
items: [
|
||||
|
||||
@@ -8,6 +8,7 @@ export const Feature = {
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
PDF_IMPORT: 'import:pdf',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
|
||||
@@ -140,7 +140,7 @@ export function PagePermissionList({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<ScrollArea mah={250} viewportRef={viewportRef}>
|
||||
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
|
||||
{sortedMembers.map((member) => (
|
||||
<PagePermissionItem
|
||||
key={`${member.type}-${member.id}`}
|
||||
@@ -158,7 +158,7 @@ export function PagePermissionList({
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
interface CreateScimTokenModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (response: IScimToken) => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateScimTokenModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateScimTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const createMutation = useCreateScimTokenMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: { name: "" },
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: FormValues) => {
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({ name: data.name });
|
||||
onSuccess(created);
|
||||
form.reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create {{credential}}", { credential: t("SCIM token") })}
|
||||
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")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={createMutation.isPending}>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
export default function EnableScim() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
|
||||
const hasAccess = useHasFeature(Feature.SCIM);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enable SCIM")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Automatically provision users and groups from your identity provider via SCIM.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle SCIM provisioning")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
interface RevokeScimTokenModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scimToken: IScimToken | null;
|
||||
}
|
||||
|
||||
export function RevokeScimTokenModal({
|
||||
opened,
|
||||
onClose,
|
||||
scimToken,
|
||||
}: RevokeScimTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const revokeMutation = useRevokeScimTokenMutation();
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!scimToken) return;
|
||||
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("Are you sure you want to revoke this {{credential}}", {
|
||||
credential: t("SCIM token"),
|
||||
})}{" "}
|
||||
<strong>{scimToken?.name}</strong>?
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"This action cannot be undone. Your identity provider will stop syncing immediately.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleRevoke}
|
||||
loading={revokeMutation.isPending}
|
||||
>
|
||||
{t("Revoke")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Stack,
|
||||
Alert,
|
||||
Group,
|
||||
Button,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
interface ScimTokenCreatedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scimToken: IScimToken | null;
|
||||
}
|
||||
|
||||
export function ScimTokenCreatedModal({
|
||||
opened,
|
||||
onClose,
|
||||
scimToken,
|
||||
}: ScimTokenCreatedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!scimToken) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("{{credential}} created", { credential: t("SCIM token") })}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("Important")}
|
||||
color="red"
|
||||
>
|
||||
{t(
|
||||
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
|
||||
{ credential: t("SCIM token") },
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t("SCIM token")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
style={{ flex: 1 }}
|
||||
value={scimToken.token}
|
||||
readOnly
|
||||
/>
|
||||
<CopyTextButton text={scimToken.token} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={onClose} mt="md">
|
||||
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
interface ScimTokenTableProps {
|
||||
tokens: IScimToken[];
|
||||
isLoading?: boolean;
|
||||
onUpdate?: (token: IScimToken) => void;
|
||||
onRevoke?: (token: IScimToken) => void;
|
||||
}
|
||||
|
||||
export function ScimTokenTable({
|
||||
tokens,
|
||||
isLoading,
|
||||
onUpdate,
|
||||
onRevoke,
|
||||
}: ScimTokenTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return t("Never");
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
};
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Token")}</Table.Th>
|
||||
<Table.Th>{t("Created by")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{tokens && tokens.length > 0 ? (
|
||||
tokens.map((token) => (
|
||||
<Table.Tr key={token.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{token.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" ff="monospace" c="dimmed">
|
||||
••••{token.tokenLastFour}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
{token.creator ? (
|
||||
<Table.Td>
|
||||
<Group gap="4" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={token.creator?.avatarUrl}
|
||||
name={token.creator.name}
|
||||
size="sm"
|
||||
/>
|
||||
<Text fz="sm" lineClamp={1}>
|
||||
{token.creator.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
) : (
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(token.lastUsedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(token.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(token)}
|
||||
>
|
||||
{t("Rename")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onRevoke && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={() => onRevoke(token)}
|
||||
>
|
||||
{t("Revoke")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={6} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
export function ScimUrlPanel() {
|
||||
const { t } = useTranslation();
|
||||
const scimUrl = `${window.location.origin}/api/scim/v2`;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("SCIM endpoint URL")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"Configure your identity provider with this URL to provision users and groups.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
style={{ flex: 1 }}
|
||||
value={scimUrl}
|
||||
readOnly
|
||||
/>
|
||||
<CopyTextButton text={scimUrl} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { z } from "zod/v4";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface UpdateScimTokenModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scimToken: IScimToken | null;
|
||||
}
|
||||
|
||||
export function UpdateScimTokenModal({
|
||||
opened,
|
||||
onClose,
|
||||
scimToken,
|
||||
}: UpdateScimTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateMutation = useUpdateScimTokenMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: { name: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && scimToken) {
|
||||
form.setValues({ name: scimToken.name });
|
||||
}
|
||||
}, [opened, scimToken]);
|
||||
|
||||
const handleSubmit = async (data: FormValues) => {
|
||||
if (!scimToken) return;
|
||||
await updateMutation.mutateAsync({
|
||||
tokenId: scimToken.id,
|
||||
name: data.name,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Update {{credential}}", { credential: t("SCIM token") })}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("Enter a descriptive name")}
|
||||
required
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={updateMutation.isPending}>
|
||||
{t("Update")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types/scim-token.types";
|
||||
export * from "./services/scim-token-service";
|
||||
@@ -0,0 +1,96 @@
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createScimToken,
|
||||
getScimTokens,
|
||||
revokeScimToken,
|
||||
updateScimToken,
|
||||
} from "@/ee/scim/services/scim-token-service";
|
||||
import {
|
||||
IScimToken,
|
||||
ICreateScimTokenRequest,
|
||||
IRevokeScimTokenRequest,
|
||||
IUpdateScimTokenRequest,
|
||||
} from "@/ee/scim/types/scim-token.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useGetScimTokensQuery(
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<IScimToken>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["scim-token-list", params],
|
||||
queryFn: () => getScimTokens(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateScimTokenMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
|
||||
mutationFn: (data) => createScimToken(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: t("{{credential}} created successfully", {
|
||||
credential: t("SCIM token"),
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["scim-token-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateScimTokenMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, IUpdateScimTokenRequest>({
|
||||
mutationFn: (data) => updateScimToken(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["scim-token-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeScimTokenMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, IRevokeScimTokenRequest>({
|
||||
mutationFn: (data) => revokeScimToken(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Revoked successfully") });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["scim-token-list"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IScimToken,
|
||||
ICreateScimTokenRequest,
|
||||
IRevokeScimTokenRequest,
|
||||
IUpdateScimTokenRequest,
|
||||
} from "@/ee/scim/types/scim-token.types";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export async function getScimTokens(
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IScimToken>> {
|
||||
const req = await api.post("/scim-tokens", { ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createScimToken(
|
||||
data: ICreateScimTokenRequest,
|
||||
): Promise<IScimToken> {
|
||||
const req = await api.post<IScimToken>("/scim-tokens/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateScimToken(
|
||||
data: IUpdateScimTokenRequest,
|
||||
): Promise<void> {
|
||||
await api.post("/scim-tokens/update", data);
|
||||
}
|
||||
|
||||
export async function revokeScimToken(
|
||||
data: IRevokeScimTokenRequest,
|
||||
): Promise<void> {
|
||||
await api.post("/scim-tokens/revoke", data);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
|
||||
export interface IScimToken {
|
||||
id: string;
|
||||
name: string;
|
||||
token?: string;
|
||||
tokenLastFour: string;
|
||||
isEnabled: boolean;
|
||||
creatorId: string;
|
||||
workspaceId: string;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
creator?: Partial<IUser>;
|
||||
}
|
||||
|
||||
export interface ICreateScimTokenRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IUpdateScimTokenRequest {
|
||||
tokenId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IRevokeScimTokenRequest {
|
||||
tokenId: string;
|
||||
}
|
||||
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
|
||||
return (
|
||||
<>
|
||||
<Card shadow="sm" radius="sm">
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.ScrollContainer minWidth={600} maxHeight={400}>
|
||||
<Table verticalSpacing="sm" stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import { Divider, Title } from "@mantine/core";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Space,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
|
||||
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
|
||||
@@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
||||
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
|
||||
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
|
||||
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
|
||||
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
|
||||
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
|
||||
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
|
||||
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
|
||||
import EnableScim from "@/ee/scim/components/enable-scim";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
import { IScimToken } from "@/ee/scim/types/scim-token.types";
|
||||
|
||||
const SCIM_TOKEN_LIMIT = 5;
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
|
||||
const hasRetention = useHasFeature(Feature.RETENTION);
|
||||
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
|
||||
const hasScim = useHasFeature(Feature.SCIM);
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const isScimEnabled = workspace?.isScimEnabled ?? false;
|
||||
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
|
||||
hasScim && isScimEnabled ? { cursor } : undefined,
|
||||
);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
|
||||
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@@ -45,7 +80,7 @@ export default function Security() {
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
{t("Single sign-on (SSO)")}
|
||||
</Title>
|
||||
|
||||
<EnforceSso />
|
||||
@@ -66,6 +101,102 @@ export default function Security() {
|
||||
)}
|
||||
|
||||
<SsoProviderList />
|
||||
|
||||
{hasScim && (
|
||||
<>
|
||||
<Divider my="xl" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
{t("SCIM provisioning")}
|
||||
</Title>
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
mb="md"
|
||||
>
|
||||
{t("SCIM takes precedence over SSO group sync while enabled.")}
|
||||
</Alert>
|
||||
|
||||
<EnableScim />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<ScimUrlPanel />
|
||||
|
||||
{isScimEnabled && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t("SCIM tokens")}</Title>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
|
||||
{ max: SCIM_TOKEN_LIMIT },
|
||||
)}
|
||||
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
|
||||
>
|
||||
{t("Create {{credential}}", {
|
||||
credential: t("SCIM token"),
|
||||
})}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Card shadow="sm" radius="sm">
|
||||
<ScimTokenTable
|
||||
tokens={scimData?.items}
|
||||
isLoading={scimLoading}
|
||||
onUpdate={setUpdateTarget}
|
||||
onRevoke={setRevokeTarget}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{scimData?.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={scimData?.meta?.hasPrevPage}
|
||||
hasNextPage={scimData?.meta?.hasNextPage}
|
||||
onNext={() => goNext(scimData?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateScimTokenModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={setCreatedToken}
|
||||
/>
|
||||
|
||||
<ScimTokenCreatedModal
|
||||
opened={!!createdToken}
|
||||
onClose={() => setCreatedToken(null)}
|
||||
scimToken={createdToken}
|
||||
/>
|
||||
|
||||
<UpdateScimTokenModal
|
||||
opened={!!updateTarget}
|
||||
onClose={() => setUpdateTarget(null)}
|
||||
scimToken={updateTarget}
|
||||
/>
|
||||
|
||||
<RevokeScimTokenModal
|
||||
opened={!!revokeTarget}
|
||||
onClose={() => setRevokeTarget(null)}
|
||||
scimToken={revokeTarget}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
@@ -83,7 +84,7 @@ const CommentEditor = forwardRef(
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
if (platformModifierKey(event) && event.code === "Enter") {
|
||||
event.preventDefault();
|
||||
if (onSave) onSave();
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
},
|
||||
validateFn: (file, allowMedia: boolean) => {
|
||||
if (
|
||||
(file.type.includes("image/") || file.type.includes("video/")) &&
|
||||
(file.type.includes("image/") ||
|
||||
file.type.includes("video/") ||
|
||||
file.type === "application/pdf") &&
|
||||
!allowMedia
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -80,10 +80,12 @@ export const MarkdownClipboard = Extension.create({
|
||||
const { from, to } = view.state.selection;
|
||||
|
||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
||||
const body = elementFromString(parsed);
|
||||
normalizeTableColumnWidths(body);
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(elementFromString(parsed), {
|
||||
).parseSlice(body, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
@@ -137,3 +139,92 @@ function elementFromString(value) {
|
||||
|
||||
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
|
||||
}
|
||||
|
||||
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
|
||||
|
||||
function parsePixelWidth(el: Element): number | null {
|
||||
const attr = el.getAttribute("width");
|
||||
if (attr) {
|
||||
const n = parseInt(attr, 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
const style = el.getAttribute("style") || "";
|
||||
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFirstRow(table: Element): Element | null {
|
||||
const tbodyRow = table.querySelector(":scope > tbody > tr");
|
||||
if (tbodyRow) return tbodyRow;
|
||||
const theadRow = table.querySelector(":scope > thead > tr");
|
||||
if (theadRow) return theadRow;
|
||||
return table.querySelector(":scope > tr");
|
||||
}
|
||||
|
||||
function deriveColumnWidths(table: Element): (number | null)[] | null {
|
||||
const cols = table.querySelectorAll(":scope > colgroup > col");
|
||||
if (cols.length > 0) {
|
||||
const widths: (number | null)[] = [];
|
||||
cols.forEach((col) => widths.push(parsePixelWidth(col)));
|
||||
if (widths.some((w) => w !== null)) return widths;
|
||||
}
|
||||
|
||||
const firstRow = getFirstRow(table);
|
||||
if (!firstRow) return null;
|
||||
|
||||
const widths: (number | null)[] = [];
|
||||
Array.from(firstRow.children)
|
||||
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
|
||||
.forEach((cell) => {
|
||||
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
|
||||
const w = parsePixelWidth(cell);
|
||||
for (let i = 0; i < colspan; i++) {
|
||||
widths.push(w !== null ? Math.round(w / colspan) : null);
|
||||
}
|
||||
});
|
||||
if (widths.length === 0 || widths.every((w) => w === null)) return null;
|
||||
return widths;
|
||||
}
|
||||
|
||||
// Mirror of server normalizeTableColumnWidths (see import/utils/table-utils.ts):
|
||||
// markdown source has no widths, so without this every pasted table renders
|
||||
// at table-layout:fixed/100% and squashes columns to fit the editor instead of
|
||||
// letting .tableWrapper's overflow-x: auto scroll.
|
||||
export function normalizeTableColumnWidths(root: Element): void {
|
||||
root.querySelectorAll("table").forEach((table) => {
|
||||
const firstRow = getFirstRow(table);
|
||||
if (!firstRow) return;
|
||||
|
||||
let colWidths = deriveColumnWidths(table);
|
||||
if (!colWidths) {
|
||||
let count = 0;
|
||||
Array.from(firstRow.children)
|
||||
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
|
||||
.forEach((cell) => {
|
||||
count += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
|
||||
});
|
||||
if (count === 0) return;
|
||||
colWidths = new Array(count).fill(DEFAULT_PASTE_COL_WIDTH_PX);
|
||||
}
|
||||
|
||||
let col = 0;
|
||||
Array.from(firstRow.children)
|
||||
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
|
||||
.forEach((cell) => {
|
||||
if (cell.getAttribute("colwidth")) {
|
||||
col += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
|
||||
return;
|
||||
}
|
||||
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
|
||||
const slice = colWidths!.slice(col, col + colspan);
|
||||
col += colspan;
|
||||
if (slice.length === 0 || slice.every((w) => w === null)) return;
|
||||
const values = slice.map((w) => (w == null ? 100 : w));
|
||||
cell.setAttribute("colwidth", values.join(","));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
@@ -232,11 +232,19 @@ export default function PageEditor({
|
||||
scrollMargin: 80,
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||
if (event.key === "Tab") {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return false;
|
||||
event.preventDefault();
|
||||
return editor.view.someProp("handleKeyDown", (f) =>
|
||||
f(editor.view, event)
|
||||
);
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -90,11 +91,11 @@ export function TitleEditor({
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypePdf,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
@@ -90,12 +91,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const markdownFileRef = useRef<() => void>(null);
|
||||
const htmlFileRef = useRef<() => void>(null);
|
||||
const docxFileRef = useRef<() => void>(null);
|
||||
const pdfFileRef = useRef<() => void>(null);
|
||||
const notionFileRef = useRef<() => void>(null);
|
||||
const confluenceFileRef = useRef<() => void>(null);
|
||||
const zipFileRef = useRef<() => void>(null);
|
||||
|
||||
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
||||
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
|
||||
const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||
@@ -244,7 +247,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
}, 3000);
|
||||
}, [fileTaskId]);
|
||||
|
||||
const maxSingleFileSize = bytes("20mb");
|
||||
const maxSingleFileSize = bytes("30mb");
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
@@ -298,6 +301,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
if (markdownFileRef.current) markdownFileRef.current();
|
||||
if (htmlFileRef.current) htmlFileRef.current();
|
||||
if (docxFileRef.current) docxFileRef.current();
|
||||
if (pdfFileRef.current) pdfFileRef.current();
|
||||
|
||||
const pageCountText =
|
||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||
@@ -378,6 +382,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={handleFileUpload}
|
||||
accept=".pdf"
|
||||
multiple
|
||||
resetRef={pdfFileRef}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={canUsePdf}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUsePdf}
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileTypePdf size={18} />}
|
||||
{...props}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "notion")}
|
||||
accept="application/zip"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import classes from "./search-control.module.css";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { platformModifierLabel } from "@/lib";
|
||||
|
||||
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
||||
|
||||
@@ -27,7 +28,7 @@ export function SearchControl({ className, ...others }: SearchControlProps) {
|
||||
{t("Search")}
|
||||
</Text>
|
||||
<Text fw={700} className={classes.shortcut}>
|
||||
Ctrl + K
|
||||
{platformModifierLabel} + K
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface IWorkspace {
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
isScimEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
|
||||
@@ -100,6 +100,15 @@ export const normalizeUrl = (url: string): string => {
|
||||
return `https://${url}`;
|
||||
};
|
||||
|
||||
const _isApple = /mac|iphone|ipad|ipod/i.test(navigator.platform ?? "");
|
||||
|
||||
/// Cmd key on Apple devices, Ctrl key everywhere else
|
||||
export function platformModifierKey(event: KeyboardEvent): boolean {
|
||||
return _isApple ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
export const platformModifierLabel = _isApple ? "⌘" : "Ctrl";
|
||||
|
||||
export function castToBoolean(value: unknown): boolean {
|
||||
if (value == null) {
|
||||
return false;
|
||||
|
||||
+23
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.80.0",
|
||||
"version": "0.80.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -33,13 +33,14 @@
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||
"@aws-sdk/client-s3": "3.1014.0",
|
||||
"@aws-sdk/lib-storage": "3.1014.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1014.0",
|
||||
"@aws-sdk/client-s3": "3.1040.0",
|
||||
"@aws-sdk/lib-storage": "3.1040.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1040.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/pdf-inspector": "1.9.4",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@langchain/core": "1.1.39",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
@@ -48,19 +49,19 @@
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "1.0.10",
|
||||
"@react-email/render": "2.0.4",
|
||||
@@ -69,7 +70,7 @@
|
||||
"ai-sdk-ollama": "^3.8.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bowser": "^2.14.1",
|
||||
"bullmq": "^5.71.0",
|
||||
"bullmq": "^5.76.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -100,7 +101,6 @@
|
||||
"p-limit": "^7.3.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"pino-http": "^11.0.0",
|
||||
@@ -110,22 +110,24 @@
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"scimmy": "1.3.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tlds": "^1.261.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^3.0.5",
|
||||
"undici": "7.24.0",
|
||||
"ws": "^8.20.0",
|
||||
"yauzl": "^3.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@nestjs/cli": "^11.0.18",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -165,6 +167,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -23,6 +23,11 @@ export const AuditEvent = {
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_DELETED: 'api_key.deleted',
|
||||
|
||||
// SCIM Tokens
|
||||
SCIM_TOKEN_CREATED: 'scim_token.created',
|
||||
SCIM_TOKEN_UPDATED: 'scim_token.updated',
|
||||
SCIM_TOKEN_DELETED: 'scim_token.deleted',
|
||||
|
||||
// Space
|
||||
SPACE_CREATED: 'space.created',
|
||||
SPACE_UPDATED: 'space.updated',
|
||||
@@ -119,6 +124,7 @@ export const AuditResource = {
|
||||
COMMENT: 'comment',
|
||||
SHARE: 'share',
|
||||
API_KEY: 'api_key',
|
||||
SCIM_TOKEN: 'scim_token',
|
||||
SSO_PROVIDER: 'sso_provider',
|
||||
WORKSPACE_INVITATION: 'workspace_invitation',
|
||||
ATTACHMENT: 'attachment',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const Feature = {
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
PDF_IMPORT: 'import:pdf',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import sanitize = require('sanitize-filename');
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Readable, Transform } from 'stream';
|
||||
|
||||
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName)
|
||||
.replace(/ /g, '_')
|
||||
.replace(/#/g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
export type SanitizeFileNameOptions = {
|
||||
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
|
||||
* download filenames where readability matters. Defaults to false. */
|
||||
preserveSpaces?: boolean;
|
||||
};
|
||||
|
||||
export function sanitizeFileName(
|
||||
fileName: string,
|
||||
options: SanitizeFileNameOptions = {},
|
||||
): string {
|
||||
// Decode percent-encoded sequences so that bypasses like "..%2F" reach
|
||||
// sanitize() as literal "../" and get stripped. sanitize-filename only
|
||||
// strips literal characters and won't catch encoded path separators
|
||||
// on its own.
|
||||
const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
|
||||
try {
|
||||
return decodeURIComponent(m);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = sanitize(decoded);
|
||||
if (options.preserveSpaces) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.replace(/ /g, '_').replace(/#/g, '_');
|
||||
}
|
||||
|
||||
export function removeAccent(str: string): string {
|
||||
@@ -88,7 +110,7 @@ export function extractBearerTokenFromHeader(
|
||||
request: FastifyRequest,
|
||||
): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
return type?.toLowerCase() === 'bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -356,9 +356,19 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
|
||||
if (!isValidUUID(filenameWithoutExt)) {
|
||||
throw new BadRequestException('Invalid file id');
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const ext = path.extname(fileName);
|
||||
const filenameWithoutExt = path.basename(fileName, ext);
|
||||
|
||||
if (
|
||||
!ext ||
|
||||
!isValidUUID(filenameWithoutExt) ||
|
||||
`${filenameWithoutExt}${ext}` !== fileName
|
||||
) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { GroupService } from './group.service';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
@@ -54,17 +55,23 @@ export class GroupUserService {
|
||||
userIds: string[],
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.groupService.findAndValidateGroup(groupId, workspaceId);
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await this.groupService.findAndValidateGroup(groupId, workspaceId, trx);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
// make sure we have valid workspace users
|
||||
const validUsers = await this.db
|
||||
const validUsers = await db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('users.id', 'in', userIds)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (validUsers.length === 0) return;
|
||||
|
||||
// prepare users to add to group
|
||||
const groupUsersToInsert = [];
|
||||
for (const user of validUsers) {
|
||||
@@ -75,7 +82,7 @@ export class GroupUserService {
|
||||
}
|
||||
|
||||
// batch insert new group users
|
||||
await this.db
|
||||
await db
|
||||
.insertInto('groupUsers')
|
||||
.values(groupUsersToInsert)
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
|
||||
@@ -216,8 +216,11 @@ export class GroupService {
|
||||
async findAndValidateGroup(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Group> {
|
||||
const group = await this.groupRepo.findById(groupId, workspaceId);
|
||||
const group = await this.groupRepo.findById(groupId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||
export class UpdateUserDto extends PartialType(
|
||||
OmitType(CreateUserDto, ['password'] as const),
|
||||
) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fullPageWidth: boolean;
|
||||
|
||||
@@ -110,10 +110,6 @@ export class UserService {
|
||||
user.email = updateUserDto.email;
|
||||
}
|
||||
|
||||
if (updateUserDto.avatarUrl) {
|
||||
user.avatarUrl = updateUserDto.avatarUrl;
|
||||
}
|
||||
|
||||
if (updateUserDto.locale) {
|
||||
user.locale = updateUserDto.locale;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,10 @@ import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
emailDomains: string[];
|
||||
@@ -46,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
mcpEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isScimEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiChat: boolean;
|
||||
|
||||
@@ -331,7 +331,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -351,6 +352,14 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.isScimEnabled !== 'undefined') {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SCIM, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
@@ -535,6 +544,7 @@ export class WorkspaceService {
|
||||
'enforceSso',
|
||||
'enforceMfa',
|
||||
'emailDomains',
|
||||
'isScimEnabled',
|
||||
],
|
||||
updateWorkspaceDto,
|
||||
workspaceBefore,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('scim_tokens')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_hash', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_last_four', 'varchar(4)', (col) => col.notNull())
|
||||
.addColumn('last_used_at', 'timestamptz')
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_scim_tokens_token_hash')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('token_hash')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_scim_tokens_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_users_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_groups_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('groups')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('is_external', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled
|
||||
await sql`
|
||||
UPDATE groups SET is_external = true
|
||||
WHERE is_default = false
|
||||
AND workspace_id IN (
|
||||
SELECT workspace_id FROM auth_providers WHERE group_sync = true
|
||||
)
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('is_scim_enabled', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('scim_tokens').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('users').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('groups').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.alterTable('groups').dropColumn('is_external').execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.dropColumn('is_scim_enabled')
|
||||
.execute();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { DB, Groups } from '@docmost/db/types/db';
|
||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
export class GroupRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof Groups> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'isDefault',
|
||||
'isExternal',
|
||||
'creatorId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
];
|
||||
|
||||
async findById(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', groupId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -35,13 +53,18 @@ export class GroupRepo {
|
||||
async findByName(
|
||||
groupName: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -51,8 +74,11 @@ export class GroupRepo {
|
||||
updatableGroup: UpdatableGroup,
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.updateTable('groups')
|
||||
.set({ ...updatableGroup, updatedAt: new Date() })
|
||||
.where('id', '=', groupId)
|
||||
@@ -68,7 +94,7 @@ export class GroupRepo {
|
||||
return db
|
||||
.insertInto('groups')
|
||||
.values(insertableGroup)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -80,7 +106,7 @@ export class GroupRepo {
|
||||
return (
|
||||
db
|
||||
.selectFrom('groups')
|
||||
.selectAll()
|
||||
.select(this.baseFields)
|
||||
// .select((eb) => this.withMemberCount(eb))
|
||||
.where('isDefault', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
@@ -106,7 +132,7 @@ export class GroupRepo {
|
||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||
let baseQuery = this.db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withMemberCount(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -53,6 +54,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -64,6 +66,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -73,6 +76,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
'isScimEnabled',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
|
||||
+19
@@ -213,7 +213,9 @@ export interface Groups {
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isDefault: boolean;
|
||||
isExternal: Generated<boolean>;
|
||||
name: string;
|
||||
scimExternalId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
@@ -338,6 +340,7 @@ export interface Users {
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
role: string | null;
|
||||
scimExternalId: string | null;
|
||||
settings: Json | null;
|
||||
timezone: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
@@ -381,6 +384,7 @@ export interface Workspaces {
|
||||
enforceMfa: Generated<boolean | null>;
|
||||
enforceSso: Generated<boolean>;
|
||||
hostname: string | null;
|
||||
isScimEnabled: Generated<boolean>;
|
||||
id: Generated<string>;
|
||||
licenseKey: string | null;
|
||||
logo: string | null;
|
||||
@@ -410,6 +414,20 @@ export interface Notifications {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface ScimTokens {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isEnabled: Generated<boolean>;
|
||||
lastUsedAt: Timestamp | null;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenLastFour: string;
|
||||
creatorId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Watchers {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
@@ -558,6 +576,7 @@ export interface DB {
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
ScimTokens,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
@@ -159,6 +160,11 @@ export type ApiKey = Selectable<ApiKeys>;
|
||||
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||
|
||||
// Scim Tokens
|
||||
export type ScimToken = Selectable<ScimTokens>;
|
||||
export type InsertableScimToken = Insertable<ScimTokens>;
|
||||
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
|
||||
|
||||
// Page Embedding
|
||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: e703b8bf47...211783940c
@@ -304,4 +304,11 @@ export class EnvironmentService {
|
||||
getClickHouseUrl(): string {
|
||||
return this.configService.get<string>('CLICKHOUSE_URL');
|
||||
}
|
||||
|
||||
getSamlDisableRequestedAuthnContext(): boolean {
|
||||
const disabled = this.configService
|
||||
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
|
||||
.toLowerCase();
|
||||
return disabled === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType, getPageTitle } from '../../common/helpers';
|
||||
import {
|
||||
getMimeType,
|
||||
getPageTitle,
|
||||
sanitizeFileName,
|
||||
} from '../../common/helpers';
|
||||
import * as path from 'path';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
@@ -85,7 +88,9 @@ export class ExportController {
|
||||
|
||||
if (result.type === 'file') {
|
||||
const ext = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'untitled') + ext;
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
ext;
|
||||
const contentType = getMimeType(path.extname(fileName));
|
||||
|
||||
res.headers({
|
||||
@@ -96,7 +101,9 @@ export class ExportController {
|
||||
|
||||
res.send(result.content);
|
||||
} else {
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
'.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
@@ -144,7 +151,9 @@ export class ExportController {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' +
|
||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||
encodeURIComponent(
|
||||
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
|
||||
) +
|
||||
'"',
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||
|
||||
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
@@ -272,6 +274,12 @@ export class ExportService {
|
||||
|
||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||
|
||||
// Batch resolve attachments once for the whole export so we only run the
|
||||
// owning-page view check a single time, regardless of page count.
|
||||
const allowedAttachments = includeAttachments
|
||||
? await this.resolveAccessibleAttachments(tree, userId, ignorePermissions)
|
||||
: new Map<string, AllowedAttachment>();
|
||||
|
||||
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
||||
{ folder: zip, parentPageId: null },
|
||||
];
|
||||
@@ -301,7 +309,7 @@ export class ExportService {
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
|
||||
updatedJsonContent =
|
||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
}
|
||||
@@ -347,31 +355,80 @@ export class ExportService {
|
||||
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||
async zipAttachments(
|
||||
prosemirrorJson: any,
|
||||
zip: JSZip,
|
||||
allowed: Map<string, AllowedAttachment>,
|
||||
) {
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath'])
|
||||
.where('id', 'in', attachmentIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = allowed.get(id);
|
||||
if (!attachment) return;
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
private async resolveAccessibleAttachments(
|
||||
tree: PageExportTree,
|
||||
userId: string | undefined,
|
||||
ignorePermissions: boolean,
|
||||
): Promise<Map<string, AllowedAttachment>> {
|
||||
const allAttachmentIds = new Set<string>();
|
||||
let spaceId: string | undefined;
|
||||
for (const siblings of Object.values(tree)) {
|
||||
for (const page of siblings) {
|
||||
if (!spaceId) spaceId = page.spaceId;
|
||||
for (const id of getAttachmentIds(getProsemirrorContent(page.content))) {
|
||||
allAttachmentIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allAttachmentIds.size === 0 || !spaceId) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath', 'pageId'])
|
||||
.where('id', 'in', [...allAttachmentIds])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
let visible = attachments;
|
||||
if (!ignorePermissions && userId) {
|
||||
const ownerPageIds = [
|
||||
...new Set(
|
||||
attachments
|
||||
.map((a) => a.pageId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const accessible = ownerPageIds.length
|
||||
? await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: ownerPageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
})
|
||||
: [];
|
||||
const accessibleSet = new Set(accessible);
|
||||
visible = attachments.filter(
|
||||
(a) => a.pageId && accessibleSet.has(a.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return new Map(visible.map((a) => [a.id, a]));
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(
|
||||
|
||||
@@ -51,9 +51,9 @@ export class ImportController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const validFileExtensions = ['.md', '.html', '.docx'];
|
||||
const validFileExtensions = ['.md', '.html', '.docx', '.pdf'];
|
||||
|
||||
const maxFileSize = bytes('20mb');
|
||||
const maxFileSize = bytes('30mb');
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
@@ -102,6 +102,7 @@ export class ImportController {
|
||||
'.md': 'markdown',
|
||||
'.html': 'html',
|
||||
'.docx': 'docx',
|
||||
'.pdf': 'pdf',
|
||||
};
|
||||
|
||||
if (createdPage) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
htmlToJson,
|
||||
@@ -30,6 +29,8 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../queue/constants';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { load } from 'cheerio';
|
||||
import { normalizeImportHtml } from '../utils/import-formatter';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
@@ -53,8 +54,8 @@ export class ImportService {
|
||||
const file = await filePromise;
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileName = sanitize(
|
||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
||||
const fileName = sanitizeFileName(
|
||||
path.basename(file.filename, fileExtension),
|
||||
);
|
||||
const fileContent = fileBuffer.toString();
|
||||
|
||||
@@ -62,7 +63,10 @@ export class ImportService {
|
||||
let createdPage = null;
|
||||
|
||||
// For DOCX, we need the page ID upfront so images can reference it
|
||||
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
|
||||
const pageId =
|
||||
fileExtension === '.docx' || fileExtension === '.pdf'
|
||||
? uuid7()
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (fileExtension.endsWith('.md')) {
|
||||
@@ -77,6 +81,14 @@ export class ImportService {
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
} else if (fileExtension.endsWith('.pdf')) {
|
||||
prosemirrorState = await this.processPdf(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = 'Error processing file content';
|
||||
@@ -137,7 +149,9 @@ export class ImportService {
|
||||
|
||||
async processHTML(htmlInput: string): Promise<any> {
|
||||
try {
|
||||
return htmlToJson(htmlInput);
|
||||
const $ = load(htmlInput);
|
||||
normalizeImportHtml($, $.root());
|
||||
return htmlToJson($.html() || '');
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -153,7 +167,7 @@ export class ImportService {
|
||||
let DocxImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
|
||||
DocxImportModule = require('./../../../ee/document-import/docx-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'DOCX import requested but EE module not bundled in this build',
|
||||
@@ -179,6 +193,42 @@ export class ImportService {
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async processPdf(
|
||||
fileBuffer: Buffer,
|
||||
workspaceId: string,
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<any> {
|
||||
let PdfImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
PdfImportModule = require('./../../../ee/document-import/pdf-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'PDF import requested but EE module not bundled in this build',
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'This feature requires a valid enterprise license.',
|
||||
);
|
||||
}
|
||||
|
||||
const pdfImportService = this.moduleRef.get(
|
||||
PdfImportModule.PdfImportService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
const html = await pdfImportService.convertPdfToHtml(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||
if (prosemirrorJson) {
|
||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { normalizeTableColumnWidths } from './table-utils';
|
||||
|
||||
// Check if text contains Unicode characters (for emojis/icons)
|
||||
function isUnicodeCharacter(text: string): boolean {
|
||||
@@ -51,9 +52,7 @@ export async function formatImportHtml(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
notionFormatter($, $root);
|
||||
xwikiFormatter($, $root);
|
||||
defaultHtmlFormatter($, $root);
|
||||
normalizeImportHtml($, $root);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
@@ -73,6 +72,23 @@ export async function formatImportHtml(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contextless HTML cleanup shared by every import path.
|
||||
* - notionFormatter: no-op on non-Notion HTML (class-selector-based).
|
||||
* - xwikiFormatter: no-op on non-XWiki HTML (looks for #xwikicontent).
|
||||
* - defaultHtmlFormatter: table column widths + provider auto-embeds.
|
||||
*
|
||||
* Does NOT run rewriteInternalLinksToMentionHtml — that requires zip context.
|
||||
*/
|
||||
export function normalizeImportHtml(
|
||||
$: CheerioAPI,
|
||||
$root: Cheerio<any>,
|
||||
): void {
|
||||
notionFormatter($, $root);
|
||||
xwikiFormatter($, $root);
|
||||
defaultHtmlFormatter($, $root);
|
||||
}
|
||||
|
||||
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
const $content = $root.find('#xwikicontent');
|
||||
if ($content.length) {
|
||||
@@ -82,6 +98,8 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
}
|
||||
|
||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
normalizeTableColumnWidths($, $root);
|
||||
|
||||
$root.find('a[href]').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const url = $el.attr('href')!;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheerioAPI, Cheerio } from 'cheerio';
|
||||
|
||||
const DEFAULT_IMPORT_COL_WIDTH_PX = 150;
|
||||
|
||||
/**
|
||||
* Extracts a pixel-integer width from either the `width` attribute or
|
||||
* `style="width: Npx"` on a <col>/<td>/<th>. Returns null when absent,
|
||||
* non-numeric, or a non-px unit (em, %).
|
||||
*/
|
||||
function parsePixelWidth(el: Cheerio<any>): number | null {
|
||||
const attr = el.attr('width');
|
||||
if (attr) {
|
||||
const n = parseInt(attr, 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
const style = el.attr('style') || '';
|
||||
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives per-column widths for a table, in visual column order.
|
||||
* Priority: <colgroup><col> → first-row cells' own width style.
|
||||
* Returns an array of length = number of columns, with null entries
|
||||
* for columns whose width couldn't be determined.
|
||||
*/
|
||||
function deriveColumnWidths(
|
||||
$: CheerioAPI,
|
||||
table: Cheerio<any>,
|
||||
): (number | null)[] | null {
|
||||
const cols = table.find('> colgroup > col');
|
||||
if (cols.length > 0) {
|
||||
const widths: (number | null)[] = [];
|
||||
cols.each(function () {
|
||||
widths.push(parsePixelWidth($(this)));
|
||||
});
|
||||
if (widths.some((w) => w !== null)) return widths;
|
||||
}
|
||||
|
||||
// Fallback: first row's cells.
|
||||
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
|
||||
if (!firstRow.length) return null;
|
||||
|
||||
const widths: (number | null)[] = [];
|
||||
firstRow.children('td, th').each(function () {
|
||||
const cell = $(this);
|
||||
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
const w = parsePixelWidth(cell);
|
||||
for (let i = 0; i < colspan; i++) {
|
||||
widths.push(w !== null ? Math.round(w / colspan) : null);
|
||||
}
|
||||
});
|
||||
if (widths.every((w) => w === null)) return null;
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply colwidth attributes to the first row of each table based on
|
||||
* derived column widths. Accounts for colspan. Idempotent — re-running
|
||||
* on already-normalized markup is a no-op.
|
||||
*
|
||||
* This lives upstream of tiptap's generateJSON: tiptap reads
|
||||
* `colwidth="N[,N...]"` on <td>/<th> to build the runtime <colgroup>.
|
||||
*/
|
||||
export function normalizeTableColumnWidths(
|
||||
$: CheerioAPI,
|
||||
$root: Cheerio<any>,
|
||||
): void {
|
||||
$root.find('table').each(function () {
|
||||
const table = $(this);
|
||||
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
|
||||
if (!firstRow.length) return;
|
||||
|
||||
let colWidths = deriveColumnWidths($, table);
|
||||
if (!colWidths) {
|
||||
// No widths anywhere (e.g. markdown-sourced tables). Apply a default
|
||||
// per-column width so the table's intrinsic width can exceed the
|
||||
// editor container, letting .tableWrapper's overflow-x: auto scroll
|
||||
// instead of cramming columns into the available width.
|
||||
let count = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
count += parseInt($(this).attr('colspan') || '1', 10) || 1;
|
||||
});
|
||||
if (count === 0) return;
|
||||
colWidths = new Array(count).fill(DEFAULT_IMPORT_COL_WIDTH_PX);
|
||||
}
|
||||
|
||||
let col = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
const cell = $(this);
|
||||
if (cell.attr('colwidth')) {
|
||||
col += parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
return;
|
||||
}
|
||||
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
const slice = colWidths.slice(col, col + colspan);
|
||||
col += colspan;
|
||||
if (slice.length === 0 || slice.every((w) => w === null)) return;
|
||||
const values = slice.map((w) => (w == null ? 100 : w));
|
||||
cell.attr('colwidth', values.join(','));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve, sep } from 'path';
|
||||
import { LocalDriver } from './local.driver';
|
||||
|
||||
type FullPath = (filePath: string) => string;
|
||||
|
||||
describe('LocalDriver._fullPath', () => {
|
||||
const ROOT = resolve('/data/storage');
|
||||
const driver = new LocalDriver({ storagePath: ROOT });
|
||||
const fullPath = ((driver as any)._fullPath as FullPath).bind(driver);
|
||||
|
||||
describe('legitimate inputs (behavior preserved)', () => {
|
||||
it.each([
|
||||
['workspace-id/avatars/uuid.png', `${ROOT}${sep}workspace-id${sep}avatars${sep}uuid.png`],
|
||||
['workspace-id/files/uuid/file.pdf', `${ROOT}${sep}workspace-id${sep}files${sep}uuid${sep}file.pdf`],
|
||||
['a/b/c/d/e.bin', `${ROOT}${sep}a${sep}b${sep}c${sep}d${sep}e.bin`],
|
||||
['', ROOT],
|
||||
['.', ROOT],
|
||||
['./x/y.png', `${ROOT}${sep}x${sep}y.png`],
|
||||
['a//b', `${ROOT}${sep}a${sep}b`],
|
||||
['a/b/../c', `${ROOT}${sep}a${sep}c`],
|
||||
])('resolves %j to %j', (input, expected) => {
|
||||
expect(fullPath(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traversal rejected', () => {
|
||||
it.each([
|
||||
'../etc/passwd',
|
||||
'../../../etc/passwd',
|
||||
'workspace/../../../etc/passwd',
|
||||
'..',
|
||||
'../..',
|
||||
'a/../../..',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute path rejected', () => {
|
||||
it.each([
|
||||
'/etc/passwd',
|
||||
'/root/.ssh/id_rsa',
|
||||
sep + 'absolute',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix-confusion rejected', () => {
|
||||
it('rejects a sibling directory whose name starts with the storage root', () => {
|
||||
const siblingDriver = new LocalDriver({ storagePath: '/data/storage' });
|
||||
const siblingFullPath = ((siblingDriver as any)._fullPath as FullPath).bind(siblingDriver);
|
||||
// Attempt to reach /data/storage-evil/secret by traversal:
|
||||
// resolve('/data/storage', '../storage-evil/secret') === '/data/storage-evil/secret'
|
||||
// Without the `+ sep` guard, a startsWith check would match.
|
||||
expect(() => siblingFullPath('../storage-evil/secret')).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage root itself', () => {
|
||||
it('accepts the root when input resolves to it', () => {
|
||||
expect(fullPath('')).toBe(ROOT);
|
||||
expect(fullPath('.')).toBe(ROOT);
|
||||
expect(fullPath('a/..')).toBe(ROOT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
LocalStorageConfig,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { join, dirname } from 'path';
|
||||
import { dirname, resolve, sep } from 'path';
|
||||
import * as fs from 'fs-extra';
|
||||
import { Readable } from 'stream';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
private _fullPath(filePath: string): string {
|
||||
return join(this.config.storagePath, filePath);
|
||||
const storageRoot = resolve(this.config.storagePath);
|
||||
const fullPath = resolve(storageRoot, filePath);
|
||||
if (fullPath !== storageRoot && !fullPath.startsWith(storageRoot + sep)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
|
||||
@@ -50,6 +50,22 @@ async function bootstrap() {
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addContentTypeParser(
|
||||
'application/scim+json',
|
||||
{ parseAs: 'string' },
|
||||
(_, body, done) => {
|
||||
try {
|
||||
const json = JSON.parse(body.toString());
|
||||
done(null, json);
|
||||
} catch (err: any) {
|
||||
done(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
|
||||
+12
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.80.0",
|
||||
"version": "0.80.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@@ -62,7 +62,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "3.4.1",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"image-dimensions": "^2.5.0",
|
||||
@@ -72,7 +72,7 @@
|
||||
"ms": "3.0.0-canary.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"rfc6902": "5.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.30"
|
||||
@@ -95,16 +95,17 @@
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
|
||||
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
|
||||
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"prosemirror-changeset": "2.4.0",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"glob": "13.0.6",
|
||||
"ws": "8.20.0",
|
||||
"dompurify": "3.3.3",
|
||||
"dompurify": "3.4.1",
|
||||
"tmp": "0.2.5",
|
||||
"hono": "4.12.12",
|
||||
"hono": "4.12.14",
|
||||
"mermaid": "11.13.0",
|
||||
"nanoid@^3": "3.3.8",
|
||||
"socket.io-parser": "4.2.6",
|
||||
@@ -123,16 +124,17 @@
|
||||
"flatted": "3.4.2",
|
||||
"picomatch@<2.3.2": "2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"fastify": "5.8.3",
|
||||
"fastify": "5.8.5",
|
||||
"yaml@>=1.0.0 <1.10.3": "1.10.3",
|
||||
"yaml@>=2.0.0 <2.8.3": "2.8.3",
|
||||
"path-to-regexp@^8": "8.4.0",
|
||||
"brace-expansion@^5": "5.0.5",
|
||||
"@xmldom/xmldom": "0.8.12",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"handlebars": "4.7.9",
|
||||
"axios": "1.15.0",
|
||||
"langsmith": "0.5.18",
|
||||
"follow-redirects": "1.16.0"
|
||||
"langsmith": "0.5.19",
|
||||
"follow-redirects": "1.16.0",
|
||||
"protobufjs": "7.5.5"
|
||||
},
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -5463,7 +5463,10 @@ var ResizableNodeView = class {
|
||||
this.container.classList.remove(this.classNames.resizing);
|
||||
}
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
document.removeEventListener("keyup", this.handleKeyUp);
|
||||
};
|
||||
@@ -5593,7 +5596,10 @@ var ResizableNodeView = class {
|
||||
this.container.classList.remove(this.classNames.resizing);
|
||||
}
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
document.removeEventListener("keyup", this.handleKeyUp);
|
||||
this.isResizing = false;
|
||||
@@ -5796,6 +5802,8 @@ var ResizableNodeView = class {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("touchmove", this.handleTouchMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||
+ window.addEventListener("blur", this.handleMouseUp);
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
document.addEventListener("keyup", this.handleKeyUp);
|
||||
}
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -5330,7 +5330,10 @@ var ResizableNodeView = class {
|
||||
this.container.classList.remove(this.classNames.resizing);
|
||||
}
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
document.removeEventListener("keyup", this.handleKeyUp);
|
||||
};
|
||||
@@ -5460,7 +5463,10 @@ var ResizableNodeView = class {
|
||||
this.container.classList.remove(this.classNames.resizing);
|
||||
}
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||
document.removeEventListener("keydown", this.handleKeyDown);
|
||||
document.removeEventListener("keyup", this.handleKeyUp);
|
||||
this.isResizing = false;
|
||||
@@ -5663,6 +5669,8 @@ var ResizableNodeView = class {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("touchmove", this.handleTouchMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||
+ window.addEventListener("blur", this.handleMouseUp);
|
||||
document.addEventListener("keydown", this.handleKeyDown);
|
||||
document.addEventListener("keyup", this.handleKeyUp);
|
||||
}
|
||||
diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts
|
||||
index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644
|
||||
--- a/src/lib/ResizableNodeView.ts
|
||||
+++ b/src/lib/ResizableNodeView.ts
|
||||
@@ -523,7 +523,10 @@ export class ResizableNodeView {
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||
document.removeEventListener('keydown', this.handleKeyDown)
|
||||
document.removeEventListener('keyup', this.handleKeyUp)
|
||||
this.isResizing = false
|
||||
@@ -774,6 +777,8 @@ export class ResizableNodeView {
|
||||
document.addEventListener('mousemove', this.handleMouseMove)
|
||||
document.addEventListener('touchmove', this.handleTouchMove)
|
||||
document.addEventListener('mouseup', this.handleMouseUp)
|
||||
+ document.addEventListener('touchend', this.handleMouseUp)
|
||||
+ window.addEventListener('blur', this.handleMouseUp)
|
||||
document.addEventListener('keydown', this.handleKeyDown)
|
||||
document.addEventListener('keyup', this.handleKeyUp)
|
||||
}
|
||||
@@ -859,7 +864,10 @@ export class ResizableNodeView {
|
||||
|
||||
// Clean up document-level listeners
|
||||
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||
document.removeEventListener('keydown', this.handleKeyDown)
|
||||
document.removeEventListener('keyup', this.handleKeyUp)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
diff --git a/dist/cjs/lib/messages.cjs b/dist/cjs/lib/messages.cjs
|
||||
index e74b8f52137e3267f3d065c4210a1114c4f32dd1..5740606b18851c0ac4f55cfa333152359e0ad135 100644
|
||||
--- a/dist/cjs/lib/messages.cjs
|
||||
+++ b/dist/cjs/lib/messages.cjs
|
||||
@@ -502,10 +502,15 @@ class PatchOp {
|
||||
}
|
||||
}
|
||||
}
|
||||
-
|
||||
+
|
||||
+ /** Reason: Commented out to avoid failing patch requests when filters don't match.
|
||||
+ * Some IdPs send patch paths like `addresses[type eq "work"].country` even if no such address exists. We can't always decide what the end user IdPs send.
|
||||
+ * Since we manually control patch application, we safely ignore these cases.
|
||||
+ * example error: "noTarget","detail":"Filter 'addresses[type eq \"work\"].country' does not match any values for 'add' op of operation 5 in PatchOp request body
|
||||
+ */
|
||||
// No targets, bail out!
|
||||
- if (targets.length === 0 && op !== "remove")
|
||||
- throw new lib_types.default.Error(400, "noTarget", `Filter '${path}' does not match any values for '${op}' op of operation ${index} in PatchOp request body`);
|
||||
+ // if (targets.length === 0 && op !== "remove")
|
||||
+ // throw new lib_types.default.Error(400, "noTarget", `Filter '${path}' does not match any values for '${op}' op of operation ${index} in PatchOp request body`);
|
||||
|
||||
/**
|
||||
* @typedef {Object} PatchOpDetails
|
||||
Generated
+907
-1031
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user