enhance a11y

This commit is contained in:
Philipinho
2026-05-04 19:46:52 +01:00
69 changed files with 2625 additions and 1285 deletions
+6 -6
View File
@@ -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: [
+1
View File
@@ -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>
);
}
+2
View File
@@ -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>
+137 -6
View File
@@ -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 {
+9
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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.
+29 -7
View File
@@ -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
View File
@@ -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>;
@@ -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> {
+16
View File
@@ -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
View File
@@ -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": []
} }
-105
View File
@@ -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)
}
+23
View File
@@ -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
+907 -1031
View File
File diff suppressed because it is too large Load Diff