mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
enhance a11y
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.80.0",
|
"version": "0.80.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"highlightjs-sap-abap": "^0.3.0",
|
"highlightjs-sap-abap": "^0.3.0",
|
||||||
"i18next": "^25.10.1",
|
"i18next": "25.10.1",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "3.0.6",
|
||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"mantine-form-zod-resolver": "^1.3.0",
|
"mantine-form-zod-resolver": "^1.3.0",
|
||||||
"mermaid": "^11.13.0",
|
"mermaid": "^11.13.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "1.363.1",
|
"posthog-js": "1.372.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"react-drawio": "^1.0.7",
|
"react-drawio": "^1.0.7",
|
||||||
"react-error-boundary": "^6.1.1",
|
"react-error-boundary": "^6.1.1",
|
||||||
"react-helmet-async": "^3.0.0",
|
"react-helmet-async": "^3.0.0",
|
||||||
"react-i18next": "^16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.12",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
@@ -391,7 +391,7 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
|
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
|
||||||
"Write...": "\"Schreiben...\"",
|
"Write...": "\"Schreiben...\"",
|
||||||
"Column count": "Spaltenanzahl",
|
"Column count": "Spaltenanzahl",
|
||||||
"{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}",
|
"{{count}} Columns": "{{count}} Spalten",
|
||||||
"Equal columns": "Gleich breite Spalten",
|
"Equal columns": "Gleich breite Spalten",
|
||||||
"Left sidebar": "Linke Seitenleiste",
|
"Left sidebar": "Linke Seitenleiste",
|
||||||
"Right sidebar": "Rechte Seitenleiste",
|
"Right sidebar": "Rechte Seitenleiste",
|
||||||
|
|||||||
@@ -608,25 +608,21 @@
|
|||||||
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
|
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
|
||||||
"Image removed successfully": "Image removed successfully",
|
"Image removed successfully": "Image removed successfully",
|
||||||
"API key": "API key",
|
"API key": "API key",
|
||||||
"API key created successfully": "API key created successfully",
|
|
||||||
"API keys": "API keys",
|
"API keys": "API keys",
|
||||||
"API management": "API management",
|
"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",
|
"Custom expiration date": "Custom expiration date",
|
||||||
"Enter a descriptive token name": "Enter a descriptive token name",
|
"Enter a descriptive token name": "Enter a descriptive token name",
|
||||||
"Expiration": "Expiration",
|
"Expiration": "Expiration",
|
||||||
"Expired": "Expired",
|
"Expired": "Expired",
|
||||||
"Expires": "Expires",
|
"Expires": "Expires",
|
||||||
"I've saved my API key": "I've saved my API key",
|
|
||||||
"Last use": "Last Used",
|
"Last use": "Last Used",
|
||||||
"No API keys found": "No API keys found",
|
"No API keys found": "No API keys found",
|
||||||
"No expiration": "No expiration",
|
"No expiration": "No expiration",
|
||||||
"Revoke API key": "Revoke API key",
|
|
||||||
"Revoked successfully": "Revoked successfully",
|
"Revoked successfully": "Revoked successfully",
|
||||||
"Select expiration date": "Select expiration date",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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.",
|
||||||
@@ -881,6 +877,30 @@
|
|||||||
"Try again": "Try again",
|
"Try again": "Try again",
|
||||||
"Untitled chat": "Untitled chat",
|
"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",
|
||||||
"Page menu": "Page menu",
|
"Page menu": "Page menu",
|
||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Collapse": "Collapse",
|
"Collapse": "Collapse",
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ export default function GlobalAppShell({
|
|||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
<AppShell.Main id="main-content">
|
<AppShell.Main id="main-content">
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={900}>{children}</Container>
|
<Container size={900} pb={80}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
|||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||||
|
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params: QueryParams = { limit: 100, query: "" };
|
||||||
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
|
|||||||
queryFn: () => getVerificationList(params),
|
queryFn: () => getVerificationList(params),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const prefetchScimTokens = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||||
|
queryFn: () => getScimTokens({}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
|
prefetchScimTokens,
|
||||||
prefetchShares,
|
prefetchShares,
|
||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchSsoProviders,
|
prefetchSsoProviders,
|
||||||
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "Security & SSO":
|
case "Security & SSO":
|
||||||
prefetchHandler = prefetchSsoProviders;
|
prefetchHandler = () => {
|
||||||
|
prefetchSsoProviders();
|
||||||
|
prefetchScimTokens();
|
||||||
|
};
|
||||||
break;
|
break;
|
||||||
case "Public sharing":
|
case "Public sharing":
|
||||||
prefetchHandler = prefetchShares;
|
prefetchHandler = prefetchShares;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t("API key created")}
|
title={t("{{credential}} created", { credential: t("API key") })}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
|
|||||||
color="red"
|
color="red"
|
||||||
>
|
>
|
||||||
{t(
|
{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>
|
</Alert>
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button fullWidth onClick={onClose} mt="md">
|
<Button fullWidth onClick={onClose} mt="md">
|
||||||
{t("I've saved my API key")}
|
{t("I've saved my {{credential}}", { credential: t("API key") })}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title={t("Create API Key")}
|
title={t("Create {{credential}}", { credential: t("API key") })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t("Revoke API key")}
|
title={t("Revoke {{credential}}", { credential: t("API key") })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text>
|
<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>?
|
<strong>{apiKey?.name}</strong>?
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t("Update API key")}
|
title={t("Update {{credential}}", { credential: t("API key") })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
|
|||||||
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
|
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
|
||||||
mutationFn: (data) => createApiKey(data),
|
mutationFn: (data) => createApiKey(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notifications.show({ message: t("API key created successfully") });
|
notifications.show({
|
||||||
|
message: t("{{credential}} created successfully", {
|
||||||
|
credential: t("API key"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: (item) =>
|
predicate: (item) =>
|
||||||
["api-key-list"].includes(item.queryKey[0] as string),
|
["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.updated": "Updated API key",
|
||||||
"api_key.deleted": "Deleted 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.created": "Created space",
|
||||||
"space.updated": "Updated space",
|
"space.updated": "Updated space",
|
||||||
"space.deleted": "Deleted space",
|
"space.deleted": "Deleted space",
|
||||||
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
|
|||||||
{ value: "api_key.deleted", label: "Deleted API key" },
|
{ 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",
|
group: "License",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const Feature = {
|
|||||||
AI: 'ai',
|
AI: 'ai',
|
||||||
CONFLUENCE_IMPORT: 'import:confluence',
|
CONFLUENCE_IMPORT: 'import:confluence',
|
||||||
DOCX_IMPORT: 'import:docx',
|
DOCX_IMPORT: 'import:docx',
|
||||||
|
PDF_IMPORT: 'import:pdf',
|
||||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||||
SECURITY_SETTINGS: 'security:settings',
|
SECURITY_SETTINGS: 'security:settings',
|
||||||
MCP: 'mcp',
|
MCP: 'mcp',
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function PagePermissionList({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea mah={250} viewportRef={viewportRef}>
|
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
|
||||||
{sortedMembers.map((member) => (
|
{sortedMembers.map((member) => (
|
||||||
<PagePermissionItem
|
<PagePermissionItem
|
||||||
key={`${member.type}-${member.id}`}
|
key={`${member.type}-${member.id}`}
|
||||||
@@ -158,7 +158,7 @@ export function PagePermissionList({
|
|||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
</Center>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card shadow="sm" radius="sm">
|
<Card shadow="sm" radius="sm">
|
||||||
<Table.ScrollContainer minWidth={600}>
|
<Table.ScrollContainer minWidth={600} maxHeight={400}>
|
||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm" stickyHeader>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>{t("Name")}</Table.Th>
|
<Table.Th>{t("Name")}</Table.Th>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { getAppName, isCloud } from "@/lib/config.ts";
|
import { getAppName, isCloud } from "@/lib/config.ts";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import { Divider, Title } from "@mantine/core";
|
import {
|
||||||
import React from "react";
|
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 useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
|
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
|
||||||
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.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 EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
||||||
import TrashRetention from "@/ee/security/components/trash-retention.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 { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
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() {
|
export default function Security() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
|
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
|
||||||
const hasRetention = useHasFeature(Feature.RETENTION);
|
const hasScim = useHasFeature(Feature.SCIM);
|
||||||
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
|
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) {
|
if (!isAdmin) {
|
||||||
return null;
|
return null;
|
||||||
@@ -45,7 +80,7 @@ export default function Security() {
|
|||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|
||||||
<Title order={4} my="lg">
|
<Title order={4} my="lg">
|
||||||
Single sign-on (SSO)
|
{t("Single sign-on (SSO)")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<EnforceSso />
|
<EnforceSso />
|
||||||
@@ -66,6 +101,102 @@ export default function Security() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SsoProviderList />
|
<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 EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||||
|
import { platformModifierKey } from "@/lib";
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
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();
|
event.preventDefault();
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
|
|||||||
},
|
},
|
||||||
validateFn: (file, allowMedia: boolean) => {
|
validateFn: (file, allowMedia: boolean) => {
|
||||||
if (
|
if (
|
||||||
(file.type.includes("image/") || file.type.includes("video/")) &&
|
(file.type.includes("image/") ||
|
||||||
|
file.type.includes("video/") ||
|
||||||
|
file.type === "application/pdf") &&
|
||||||
!allowMedia
|
!allowMedia
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
const { from, to } = view.state.selection;
|
const { from, to } = view.state.selection;
|
||||||
|
|
||||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
||||||
|
const body = elementFromString(parsed);
|
||||||
|
normalizeTableColumnWidths(body);
|
||||||
|
|
||||||
const contentNodes = DOMParser.fromSchema(
|
const contentNodes = DOMParser.fromSchema(
|
||||||
this.editor.schema,
|
this.editor.schema,
|
||||||
).parseSlice(elementFromString(parsed), {
|
).parseSlice(body, {
|
||||||
preserveWhitespace: true,
|
preserveWhitespace: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,3 +139,92 @@ function elementFromString(value) {
|
|||||||
|
|
||||||
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
|
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 { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
@@ -232,11 +232,19 @@ export default function PageEditor({
|
|||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
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();
|
searchSpotlight.open();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import localEmitter from "@/lib/local-emitter.ts";
|
|||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
|
import { platformModifierKey } from "@/lib";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -90,11 +91,11 @@ export function TitleEditor({
|
|||||||
editorProps: {
|
editorProps: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||||
searchSpotlight.open();
|
searchSpotlight.open();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
IconFileTypeDocx,
|
IconFileTypeDocx,
|
||||||
|
IconFileTypePdf,
|
||||||
IconFileTypeZip,
|
IconFileTypeZip,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
@@ -90,12 +91,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
const markdownFileRef = useRef<() => void>(null);
|
const markdownFileRef = useRef<() => void>(null);
|
||||||
const htmlFileRef = useRef<() => void>(null);
|
const htmlFileRef = useRef<() => void>(null);
|
||||||
const docxFileRef = useRef<() => void>(null);
|
const docxFileRef = useRef<() => void>(null);
|
||||||
|
const pdfFileRef = useRef<() => void>(null);
|
||||||
const notionFileRef = useRef<() => void>(null);
|
const notionFileRef = useRef<() => void>(null);
|
||||||
const confluenceFileRef = useRef<() => void>(null);
|
const confluenceFileRef = useRef<() => void>(null);
|
||||||
const zipFileRef = useRef<() => void>(null);
|
const zipFileRef = useRef<() => void>(null);
|
||||||
|
|
||||||
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
||||||
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
|
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
|
||||||
|
const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
|
||||||
const upgradeLabel = useUpgradeLabel();
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||||
@@ -244,7 +247,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}, [fileTaskId]);
|
}, [fileTaskId]);
|
||||||
|
|
||||||
const maxSingleFileSize = bytes("20mb");
|
const maxSingleFileSize = bytes("30mb");
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
if (!selectedFiles) {
|
if (!selectedFiles) {
|
||||||
@@ -298,6 +301,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
if (markdownFileRef.current) markdownFileRef.current();
|
if (markdownFileRef.current) markdownFileRef.current();
|
||||||
if (htmlFileRef.current) htmlFileRef.current();
|
if (htmlFileRef.current) htmlFileRef.current();
|
||||||
if (docxFileRef.current) docxFileRef.current();
|
if (docxFileRef.current) docxFileRef.current();
|
||||||
|
if (pdfFileRef.current) pdfFileRef.current();
|
||||||
|
|
||||||
const pageCountText =
|
const pageCountText =
|
||||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||||
@@ -378,6 +382,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
)}
|
)}
|
||||||
</FileButton>
|
</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
|
<FileButton
|
||||||
onChange={(file) => handleZipUpload(file, "notion")}
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
accept="application/zip"
|
accept="application/zip"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import classes from "./search-control.module.css";
|
import classes from "./search-control.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { platformModifierLabel } from "@/lib";
|
||||||
|
|
||||||
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export function SearchControl({ className, ...others }: SearchControlProps) {
|
|||||||
{t("Search")}
|
{t("Search")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} className={classes.shortcut}>
|
<Text fw={700} className={classes.shortcut}>
|
||||||
Ctrl + K
|
{platformModifierLabel} + K
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface IWorkspace {
|
|||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
|
isScimEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSettings {
|
export interface IWorkspaceSettings {
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ export const normalizeUrl = (url: string): string => {
|
|||||||
return `https://${url}`;
|
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 {
|
export function castToBoolean(value: unknown): boolean {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+23
-18
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.80.0",
|
"version": "0.80.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -33,13 +33,14 @@
|
|||||||
"@ai-sdk/google": "^3.0.52",
|
"@ai-sdk/google": "^3.0.52",
|
||||||
"@ai-sdk/openai": "^3.0.47",
|
"@ai-sdk/openai": "^3.0.47",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||||
"@aws-sdk/client-s3": "3.1014.0",
|
"@aws-sdk/client-s3": "3.1040.0",
|
||||||
"@aws-sdk/lib-storage": "3.1014.0",
|
"@aws-sdk/lib-storage": "3.1040.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.1014.0",
|
"@aws-sdk/s3-request-presigner": "3.1040.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/pdf-inspector": "1.9.4",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.3",
|
||||||
"@keyv/redis": "^5.1.6",
|
"@keyv/redis": "^5.1.6",
|
||||||
"@langchain/core": "1.1.39",
|
"@langchain/core": "1.1.39",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
@@ -48,19 +49,19 @@
|
|||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
"@nestjs/common": "^11.1.18",
|
"@nestjs/common": "^11.1.19",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.18",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "11.0.2",
|
"@nestjs/jwt": "11.0.2",
|
||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.18",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.18",
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.18",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "1.0.10",
|
"@react-email/components": "1.0.10",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/render": "2.0.4",
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"ai-sdk-ollama": "^3.8.1",
|
"ai-sdk-ollama": "^3.8.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bowser": "^2.14.1",
|
"bowser": "^2.14.1",
|
||||||
"bullmq": "^5.71.0",
|
"bullmq": "^5.76.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -100,7 +101,6 @@
|
|||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
"pino-http": "^11.0.0",
|
"pino-http": "^11.0.0",
|
||||||
@@ -110,22 +110,24 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.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",
|
"socket.io": "^4.8.3",
|
||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"tlds": "^1.261.0",
|
"tlds": "^1.261.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
"tseep": "^1.3.1",
|
"tseep": "^1.3.1",
|
||||||
"typesense": "^3.0.5",
|
"typesense": "^3.0.5",
|
||||||
|
"undici": "7.24.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"yauzl": "^3.2.1",
|
"yauzl": "^3.2.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.28.0",
|
"@eslint/js": "^9.28.0",
|
||||||
"@nestjs/cli": "^11.0.18",
|
"@nestjs/cli": "^11.0.21",
|
||||||
"@nestjs/schematics": "^11.0.10",
|
"@nestjs/schematics": "^11.0.10",
|
||||||
"@nestjs/testing": "^11.1.18",
|
"@nestjs/testing": "^11.1.19",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/debounce": "^1.2.4",
|
"@types/debounce": "^1.2.4",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
@@ -165,6 +167,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
|
||||||
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const AuditEvent = {
|
|||||||
API_KEY_UPDATED: 'api_key.updated',
|
API_KEY_UPDATED: 'api_key.updated',
|
||||||
API_KEY_DELETED: 'api_key.deleted',
|
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
|
||||||
SPACE_CREATED: 'space.created',
|
SPACE_CREATED: 'space.created',
|
||||||
SPACE_UPDATED: 'space.updated',
|
SPACE_UPDATED: 'space.updated',
|
||||||
@@ -119,6 +124,7 @@ export const AuditResource = {
|
|||||||
COMMENT: 'comment',
|
COMMENT: 'comment',
|
||||||
SHARE: 'share',
|
SHARE: 'share',
|
||||||
API_KEY: 'api_key',
|
API_KEY: 'api_key',
|
||||||
|
SCIM_TOKEN: 'scim_token',
|
||||||
SSO_PROVIDER: 'sso_provider',
|
SSO_PROVIDER: 'sso_provider',
|
||||||
WORKSPACE_INVITATION: 'workspace_invitation',
|
WORKSPACE_INVITATION: 'workspace_invitation',
|
||||||
ATTACHMENT: 'attachment',
|
ATTACHMENT: 'attachment',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const Feature = {
|
|||||||
AI: 'ai',
|
AI: 'ai',
|
||||||
CONFLUENCE_IMPORT: 'import:confluence',
|
CONFLUENCE_IMPORT: 'import:confluence',
|
||||||
DOCX_IMPORT: 'import:docx',
|
DOCX_IMPORT: 'import:docx',
|
||||||
|
PDF_IMPORT: 'import:pdf',
|
||||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||||
SECURITY_SETTINGS: 'security:settings',
|
SECURITY_SETTINGS: 'security:settings',
|
||||||
MCP: 'mcp',
|
MCP: 'mcp',
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
import sanitize = require('sanitize-filename');
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { Readable, Transform } from 'stream';
|
import { Readable, Transform } from 'stream';
|
||||||
|
|
||||||
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
|
|||||||
return new Date(timestamp);
|
return new Date(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeFileName(fileName: string): string {
|
export type SanitizeFileNameOptions = {
|
||||||
const sanitizedFilename = sanitize(fileName)
|
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
|
||||||
.replace(/ /g, '_')
|
* download filenames where readability matters. Defaults to false. */
|
||||||
.replace(/#/g, '_');
|
preserveSpaces?: boolean;
|
||||||
return sanitizedFilename.slice(0, 255);
|
};
|
||||||
|
|
||||||
|
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 {
|
export function removeAccent(str: string): string {
|
||||||
@@ -88,7 +110,7 @@ export function extractBearerTokenFromHeader(
|
|||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
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');
|
throw new BadRequestException('Invalid image attachment type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
|
if (!fileName) {
|
||||||
if (!isValidUUID(filenameWithoutExt)) {
|
throw new BadRequestException('Invalid file name');
|
||||||
throw new BadRequestException('Invalid file id');
|
}
|
||||||
|
|
||||||
|
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}`;
|
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { GroupService } from './group.service';
|
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 { InjectKysely } from 'nestjs-kysely';
|
||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AUDIT_SERVICE,
|
AUDIT_SERVICE,
|
||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../../integrations/audit/audit.service';
|
} from '../../../integrations/audit/audit.service';
|
||||||
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupUserService {
|
export class GroupUserService {
|
||||||
@@ -54,17 +55,23 @@ export class GroupUserService {
|
|||||||
userIds: string[],
|
userIds: string[],
|
||||||
groupId: string,
|
groupId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
): Promise<void> {
|
): 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
|
// make sure we have valid workspace users
|
||||||
const validUsers = await this.db
|
const validUsers = await db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(['id', 'name'])
|
.select(['id', 'name'])
|
||||||
.where('users.id', 'in', userIds)
|
.where('users.id', 'in', userIds)
|
||||||
.where('users.workspaceId', '=', workspaceId)
|
.where('users.workspaceId', '=', workspaceId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
if (validUsers.length === 0) return;
|
||||||
|
|
||||||
// prepare users to add to group
|
// prepare users to add to group
|
||||||
const groupUsersToInsert = [];
|
const groupUsersToInsert = [];
|
||||||
for (const user of validUsers) {
|
for (const user of validUsers) {
|
||||||
@@ -75,7 +82,7 @@ export class GroupUserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// batch insert new group users
|
// batch insert new group users
|
||||||
await this.db
|
await db
|
||||||
.insertInto('groupUsers')
|
.insertInto('groupUsers')
|
||||||
.values(groupUsersToInsert)
|
.values(groupUsersToInsert)
|
||||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||||
|
|||||||
@@ -216,8 +216,11 @@ export class GroupService {
|
|||||||
async findAndValidateGroup(
|
async findAndValidateGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
): Promise<Group> {
|
): Promise<Group> {
|
||||||
const group = await this.groupRepo.findById(groupId, workspaceId);
|
const group = await this.groupRepo.findById(groupId, workspaceId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new NotFoundException('Group not found');
|
throw new NotFoundException('Group not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
|||||||
export class UpdateUserDto extends PartialType(
|
export class UpdateUserDto extends PartialType(
|
||||||
OmitType(CreateUserDto, ['password'] as const),
|
OmitType(CreateUserDto, ['password'] as const),
|
||||||
) {
|
) {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
avatarUrl: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
fullPageWidth: boolean;
|
fullPageWidth: boolean;
|
||||||
|
|||||||
@@ -110,10 +110,6 @@ export class UserService {
|
|||||||
user.email = updateUserDto.email;
|
user.email = updateUserDto.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateUserDto.avatarUrl) {
|
|
||||||
user.avatarUrl = updateUserDto.avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateUserDto.locale) {
|
if (updateUserDto.locale) {
|
||||||
user.locale = updateUserDto.locale;
|
user.locale = updateUserDto.locale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,10 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
logo: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
emailDomains: string[];
|
emailDomains: string[];
|
||||||
@@ -46,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
mcpEnabled: boolean;
|
mcpEnabled: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isScimEnabled: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
aiChat: boolean;
|
aiChat: boolean;
|
||||||
|
|||||||
@@ -331,7 +331,8 @@ export class WorkspaceService {
|
|||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
||||||
) {
|
) {
|
||||||
const ws = await this.db
|
const ws = await this.db
|
||||||
.selectFrom('workspaces')
|
.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 (
|
if (
|
||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
@@ -535,6 +544,7 @@ export class WorkspaceService {
|
|||||||
'enforceSso',
|
'enforceSso',
|
||||||
'enforceMfa',
|
'enforceMfa',
|
||||||
'emailDomains',
|
'emailDomains',
|
||||||
|
'isScimEnabled',
|
||||||
],
|
],
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
workspaceBefore,
|
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';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
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 { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
|
|
||||||
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
|||||||
export class GroupRepo {
|
export class GroupRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
private baseFields: Array<keyof Groups> = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'isDefault',
|
||||||
|
'isExternal',
|
||||||
|
'creatorId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'deletedAt',
|
||||||
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
opts?: {
|
||||||
|
includeMemberCount?: boolean;
|
||||||
|
includeScimExternalId?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<Group> {
|
): Promise<Group> {
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
return db
|
return db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll('groups')
|
.select(this.baseFields)
|
||||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||||
|
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||||
.where('id', '=', groupId)
|
.where('id', '=', groupId)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -35,13 +53,18 @@ export class GroupRepo {
|
|||||||
async findByName(
|
async findByName(
|
||||||
groupName: string,
|
groupName: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
opts?: {
|
||||||
|
includeMemberCount?: boolean;
|
||||||
|
includeScimExternalId?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<Group> {
|
): Promise<Group> {
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
return db
|
return db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll('groups')
|
.select(this.baseFields)
|
||||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||||
|
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||||
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
|
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -51,8 +74,11 @@ export class GroupRepo {
|
|||||||
updatableGroup: UpdatableGroup,
|
updatableGroup: UpdatableGroup,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.db
|
const db = dbOrTx(this.db, trx);
|
||||||
|
|
||||||
|
await db
|
||||||
.updateTable('groups')
|
.updateTable('groups')
|
||||||
.set({ ...updatableGroup, updatedAt: new Date() })
|
.set({ ...updatableGroup, updatedAt: new Date() })
|
||||||
.where('id', '=', groupId)
|
.where('id', '=', groupId)
|
||||||
@@ -68,7 +94,7 @@ export class GroupRepo {
|
|||||||
return db
|
return db
|
||||||
.insertInto('groups')
|
.insertInto('groups')
|
||||||
.values(insertableGroup)
|
.values(insertableGroup)
|
||||||
.returningAll()
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +106,7 @@ export class GroupRepo {
|
|||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
// .select((eb) => this.withMemberCount(eb))
|
// .select((eb) => this.withMemberCount(eb))
|
||||||
.where('isDefault', '=', true)
|
.where('isDefault', '=', true)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
@@ -106,7 +132,7 @@ export class GroupRepo {
|
|||||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||||
let baseQuery = this.db
|
let baseQuery = this.db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll('groups')
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withMemberCount(eb))
|
.select((eb) => this.withMemberCount(eb))
|
||||||
.where('workspaceId', '=', workspaceId);
|
.where('workspaceId', '=', workspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class UserRepo {
|
|||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
includeUserMfa?: boolean;
|
includeUserMfa?: boolean;
|
||||||
|
includeScimExternalId?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@@ -53,6 +54,7 @@ export class UserRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||||
|
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||||
.where('id', '=', userId)
|
.where('id', '=', userId)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -64,6 +66,7 @@ export class UserRepo {
|
|||||||
opts?: {
|
opts?: {
|
||||||
includePassword?: boolean;
|
includePassword?: boolean;
|
||||||
includeUserMfa?: boolean;
|
includeUserMfa?: boolean;
|
||||||
|
includeScimExternalId?: boolean;
|
||||||
trx?: KyselyTransaction;
|
trx?: KyselyTransaction;
|
||||||
},
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@@ -73,6 +76,7 @@ export class UserRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||||
|
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
|
|||||||
'plan',
|
'plan',
|
||||||
'enforceMfa',
|
'enforceMfa',
|
||||||
'trashRetentionDays',
|
'trashRetentionDays',
|
||||||
|
'isScimEnabled',
|
||||||
];
|
];
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
|||||||
+19
@@ -213,7 +213,9 @@ export interface Groups {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
isExternal: Generated<boolean>;
|
||||||
name: string;
|
name: string;
|
||||||
|
scimExternalId: string | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,7 @@ export interface Users {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
|
scimExternalId: string | null;
|
||||||
settings: Json | null;
|
settings: Json | null;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
@@ -381,6 +384,7 @@ export interface Workspaces {
|
|||||||
enforceMfa: Generated<boolean | null>;
|
enforceMfa: Generated<boolean | null>;
|
||||||
enforceSso: Generated<boolean>;
|
enforceSso: Generated<boolean>;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
|
isScimEnabled: Generated<boolean>;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
licenseKey: string | null;
|
licenseKey: string | null;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
@@ -410,6 +414,20 @@ export interface Notifications {
|
|||||||
createdAt: Generated<Timestamp>;
|
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 {
|
export interface Watchers {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -558,6 +576,7 @@ export interface DB {
|
|||||||
pageVerifications: PageVerifications;
|
pageVerifications: PageVerifications;
|
||||||
pageVerifiers: PageVerifiers;
|
pageVerifiers: PageVerifiers;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
|
scimTokens: ScimTokens;
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
UserMfa as _UserMFA,
|
UserMfa as _UserMFA,
|
||||||
UserSessions,
|
UserSessions,
|
||||||
ApiKeys,
|
ApiKeys,
|
||||||
|
ScimTokens,
|
||||||
Watchers,
|
Watchers,
|
||||||
Audit as _Audit,
|
Audit as _Audit,
|
||||||
Templates,
|
Templates,
|
||||||
@@ -159,6 +160,11 @@ export type ApiKey = Selectable<ApiKeys>;
|
|||||||
export type InsertableApiKey = Insertable<ApiKeys>;
|
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||||
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
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
|
// Page Embedding
|
||||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||||
export type InsertablePageEmbedding = Insertable<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 {
|
getClickHouseUrl(): string {
|
||||||
return this.configService.get<string>('CLICKHOUSE_URL');
|
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,
|
SpaceCaslSubject,
|
||||||
} from '../../core/casl/interfaces/space-ability.type';
|
} from '../../core/casl/interfaces/space-ability.type';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
|
||||||
import { getExportExtension } from './utils';
|
import { getExportExtension } from './utils';
|
||||||
import { getMimeType, getPageTitle } from '../../common/helpers';
|
import {
|
||||||
|
getMimeType,
|
||||||
|
getPageTitle,
|
||||||
|
sanitizeFileName,
|
||||||
|
} from '../../common/helpers';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +88,9 @@ export class ExportController {
|
|||||||
|
|
||||||
if (result.type === 'file') {
|
if (result.type === 'file') {
|
||||||
const ext = getExportExtension(dto.format);
|
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));
|
const contentType = getMimeType(path.extname(fileName));
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
@@ -96,7 +101,9 @@ export class ExportController {
|
|||||||
|
|
||||||
res.send(result.content);
|
res.send(result.content);
|
||||||
} else {
|
} else {
|
||||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
const fileName =
|
||||||
|
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||||
|
'.zip';
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
@@ -144,7 +151,9 @@ export class ExportController {
|
|||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition':
|
'Content-Disposition':
|
||||||
'attachment; filename="' +
|
'attachment; filename="' +
|
||||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
encodeURIComponent(
|
||||||
|
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
|
||||||
|
) +
|
||||||
'"',
|
'"',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||||
|
|
||||||
|
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
private readonly logger = new Logger(ExportService.name);
|
private readonly logger = new Logger(ExportService.name);
|
||||||
@@ -272,6 +274,12 @@ export class ExportService {
|
|||||||
|
|
||||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
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 }[] = [
|
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
||||||
{ folder: zip, parentPageId: null },
|
{ folder: zip, parentPageId: null },
|
||||||
];
|
];
|
||||||
@@ -301,7 +309,7 @@ export class ExportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (includeAttachments) {
|
if (includeAttachments) {
|
||||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
|
||||||
updatedJsonContent =
|
updatedJsonContent =
|
||||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||||
}
|
}
|
||||||
@@ -347,31 +355,80 @@ export class ExportService {
|
|||||||
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
|
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);
|
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||||
|
|
||||||
if (attachmentIds.length > 0) {
|
await Promise.all(
|
||||||
const attachments = await this.db
|
attachmentIds.map(async (id) => {
|
||||||
.selectFrom('attachments')
|
const attachment = allowed.get(id);
|
||||||
.select(['id', 'fileName', 'filePath'])
|
if (!attachment) return;
|
||||||
.where('id', 'in', attachmentIds)
|
try {
|
||||||
.where('spaceId', '=', spaceId)
|
const fileBuffer = await this.storageService.read(
|
||||||
.execute();
|
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(
|
private async resolveAccessibleAttachments(
|
||||||
attachments.map(async (attachment) => {
|
tree: PageExportTree,
|
||||||
try {
|
userId: string | undefined,
|
||||||
const fileBuffer = await this.storageService.read(
|
ignorePermissions: boolean,
|
||||||
attachment.filePath,
|
): Promise<Map<string, AllowedAttachment>> {
|
||||||
);
|
const allAttachmentIds = new Set<string>();
|
||||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
let spaceId: string | undefined;
|
||||||
zip.file(filePath, fileBuffer);
|
for (const siblings of Object.values(tree)) {
|
||||||
} catch (err) {
|
for (const page of siblings) {
|
||||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
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(
|
async turnPageMentionsToLinks(
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export class ImportController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@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;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +102,7 @@ export class ImportController {
|
|||||||
'.md': 'markdown',
|
'.md': 'markdown',
|
||||||
'.html': 'html',
|
'.html': 'html',
|
||||||
'.docx': 'docx',
|
'.docx': 'docx',
|
||||||
|
'.pdf': 'pdf',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (createdPage) {
|
if (createdPage) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
htmlToJson,
|
htmlToJson,
|
||||||
@@ -30,6 +29,8 @@ import { InjectQueue } from '@nestjs/bullmq';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../queue/constants';
|
import { QueueJob, QueueName } from '../../queue/constants';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import { load } from 'cheerio';
|
||||||
|
import { normalizeImportHtml } from '../utils/import-formatter';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
@@ -53,8 +54,8 @@ export class ImportService {
|
|||||||
const file = await filePromise;
|
const file = await filePromise;
|
||||||
const fileBuffer = await file.toBuffer();
|
const fileBuffer = await file.toBuffer();
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
const fileName = sanitize(
|
const fileName = sanitizeFileName(
|
||||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
path.basename(file.filename, fileExtension),
|
||||||
);
|
);
|
||||||
const fileContent = fileBuffer.toString();
|
const fileContent = fileBuffer.toString();
|
||||||
|
|
||||||
@@ -62,7 +63,10 @@ export class ImportService {
|
|||||||
let createdPage = null;
|
let createdPage = null;
|
||||||
|
|
||||||
// For DOCX, we need the page ID upfront so images can reference it
|
// 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 {
|
try {
|
||||||
if (fileExtension.endsWith('.md')) {
|
if (fileExtension.endsWith('.md')) {
|
||||||
@@ -77,6 +81,14 @@ export class ImportService {
|
|||||||
pageId,
|
pageId,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
} else if (fileExtension.endsWith('.pdf')) {
|
||||||
|
prosemirrorState = await this.processPdf(
|
||||||
|
fileBuffer,
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = 'Error processing file content';
|
const message = 'Error processing file content';
|
||||||
@@ -137,7 +149,9 @@ export class ImportService {
|
|||||||
|
|
||||||
async processHTML(htmlInput: string): Promise<any> {
|
async processHTML(htmlInput: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
return htmlToJson(htmlInput);
|
const $ = load(htmlInput);
|
||||||
|
normalizeImportHtml($, $.root());
|
||||||
|
return htmlToJson($.html() || '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -153,7 +167,7 @@ export class ImportService {
|
|||||||
let DocxImportModule: any;
|
let DocxImportModule: any;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// 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) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
'DOCX import requested but EE module not bundled in this build',
|
'DOCX import requested but EE module not bundled in this build',
|
||||||
@@ -179,6 +193,42 @@ export class ImportService {
|
|||||||
return this.processHTML(html);
|
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> {
|
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||||
if (prosemirrorJson) {
|
if (prosemirrorJson) {
|
||||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
// 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 { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||||
import slugify from '@sindresorhus/slugify';
|
import slugify from '@sindresorhus/slugify';
|
||||||
|
import { normalizeTableColumnWidths } from './table-utils';
|
||||||
|
|
||||||
// Check if text contains Unicode characters (for emojis/icons)
|
// Check if text contains Unicode characters (for emojis/icons)
|
||||||
function isUnicodeCharacter(text: string): boolean {
|
function isUnicodeCharacter(text: string): boolean {
|
||||||
@@ -51,9 +52,7 @@ export async function formatImportHtml(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notionFormatter($, $root);
|
normalizeImportHtml($, $root);
|
||||||
xwikiFormatter($, $root);
|
|
||||||
defaultHtmlFormatter($, $root);
|
|
||||||
|
|
||||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
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>) {
|
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
const $content = $root.find('#xwikicontent');
|
const $content = $root.find('#xwikicontent');
|
||||||
if ($content.length) {
|
if ($content.length) {
|
||||||
@@ -82,6 +98,8 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
|
normalizeTableColumnWidths($, $root);
|
||||||
|
|
||||||
$root.find('a[href]').each((_, el) => {
|
$root.find('a[href]').each((_, el) => {
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const url = $el.attr('href')!;
|
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,
|
LocalStorageConfig,
|
||||||
StorageOption,
|
StorageOption,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
import { join, dirname } from 'path';
|
import { dirname, resolve, sep } from 'path';
|
||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { createReadStream, createWriteStream } from 'node:fs';
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _fullPath(filePath: string): string {
|
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> {
|
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ async function bootstrap() {
|
|||||||
await app.register(fastifyMultipart);
|
await app.register(fastifyMultipart);
|
||||||
await app.register(fastifyCookie);
|
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
|
app
|
||||||
.getHttpAdapter()
|
.getHttpAdapter()
|
||||||
.getInstance()
|
.getInstance()
|
||||||
|
|||||||
+12
-10
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.80.0",
|
"version": "0.80.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"diff": "8.0.3",
|
"diff": "8.0.3",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "3.4.1",
|
||||||
"fractional-indexing-jittered": "^1.0.0",
|
"fractional-indexing-jittered": "^1.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"image-dimensions": "^2.5.0",
|
"image-dimensions": "^2.5.0",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rfc6902": "5.2.0",
|
"rfc6902": "5.2.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^14.0.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.30"
|
"yjs": "^13.6.30"
|
||||||
@@ -95,16 +95,17 @@
|
|||||||
"packageManager": "pnpm@10.4.0",
|
"packageManager": "pnpm@10.4.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"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": {
|
"overrides": {
|
||||||
"prosemirror-changeset": "2.4.0",
|
"prosemirror-changeset": "2.4.0",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"dompurify": "3.3.3",
|
"dompurify": "3.4.1",
|
||||||
"tmp": "0.2.5",
|
"tmp": "0.2.5",
|
||||||
"hono": "4.12.12",
|
"hono": "4.12.14",
|
||||||
"mermaid": "11.13.0",
|
"mermaid": "11.13.0",
|
||||||
"nanoid@^3": "3.3.8",
|
"nanoid@^3": "3.3.8",
|
||||||
"socket.io-parser": "4.2.6",
|
"socket.io-parser": "4.2.6",
|
||||||
@@ -123,16 +124,17 @@
|
|||||||
"flatted": "3.4.2",
|
"flatted": "3.4.2",
|
||||||
"picomatch@<2.3.2": "2.3.2",
|
"picomatch@<2.3.2": "2.3.2",
|
||||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
"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@>=1.0.0 <1.10.3": "1.10.3",
|
||||||
"yaml@>=2.0.0 <2.8.3": "2.8.3",
|
"yaml@>=2.0.0 <2.8.3": "2.8.3",
|
||||||
"path-to-regexp@^8": "8.4.0",
|
"path-to-regexp@^8": "8.4.0",
|
||||||
"brace-expansion@^5": "5.0.5",
|
"brace-expansion@^5": "5.0.5",
|
||||||
"@xmldom/xmldom": "0.8.12",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"handlebars": "4.7.9",
|
"handlebars": "4.7.9",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"langsmith": "0.5.18",
|
"langsmith": "0.5.19",
|
||||||
"follow-redirects": "1.16.0"
|
"follow-redirects": "1.16.0",
|
||||||
|
"protobufjs": "7.5.5"
|
||||||
},
|
},
|
||||||
"neverBuiltDependencies": []
|
"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