Compare commits

..
Author SHA1 Message Date
Philipinho 0501b334b5 feat(ee): space control to hide comments from viewers role 2026-08-13 01:28:02 +01:00
119 changed files with 611 additions and 4048 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ RUN chown -R node:node /app
USER node
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage
@@ -387,8 +387,6 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -510,6 +508,11 @@
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Hide comments from viewers": "Hide comments from viewers",
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -706,8 +709,9 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.": "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
@@ -1290,47 +1294,5 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Access revoked": "Access revoked",
"Authorize application": "Authorize application",
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
"Not you? Switch account": "Not you? Switch account",
"This application will be able to:": "This application will be able to:",
"Write": "Write",
"Invalid authorization request": "Invalid authorization request",
"Authorize": "Authorize",
"Application": "Application",
"Permissions": "Permissions",
"Authorized": "Authorized",
"Revoke access": "Revoke access",
"Revoke access for {{name}}": "Revoke access for {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
"Trusted applications": "Trusted applications",
"Remove {{name}}": "Remove {{name}}",
"This origin is already trusted.": "This origin is already trusted.",
"Trusted application name": "Trusted application name",
"Trusted application origin": "Trusted application origin",
"I recognize this application and want to continue": "I recognize this application and want to continue",
"You will be redirected to": "You will be redirected to",
"View content without making changes.": "View content without making changes.",
"Create and modify content.": "Create and modify content.",
"This application is not on your workspace's trusted list. Authorize only if you recognize it.": "This application is not on your workspace's trusted list. Authorize only if you recognize it.",
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.": "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
"Enter the app's callback origin, e.g. https://app.yourcompany.com": "Enter the app's callback origin, e.g. https://app.yourcompany.com",
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Authorized apps": "Authorized apps",
"No authorized apps yet.": "No authorized apps yet."
"{{count}} selected_other": "{{count}} selected"
}
-6
View File
@@ -47,7 +47,6 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
import OAuthConsent from "@/ee/oauth/pages/oauth-consent.tsx";
export default function App() {
const { t } = useTranslation();
@@ -64,7 +63,6 @@ export default function App() {
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -118,10 +116,6 @@ export default function App() {
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const canViewComments = useCanViewComments();
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
@@ -23,12 +25,18 @@ export default function Aside() {
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
useEffect(() => {
if (isAsideOpen && tab === "comments" && !canViewComments) {
setAsideState({ tab: "", isAsideOpen: false });
}
}, [isAsideOpen, tab, canViewComments, setAsideState]);
let title: string;
let component: ReactNode;
switch (tab) {
case "comments":
component = <CommentListWithTabs />;
component = canViewComments ? <CommentListWithTabs /> : null;
title = "Comments";
break;
case "toc":
@@ -255,7 +255,6 @@ export default function ChatInput({
},
content: "",
editable: true,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
@@ -1,9 +1,7 @@
import {
Anchor,
Button,
Group,
List,
Table,
Text,
Switch,
TextInput,
@@ -17,33 +15,14 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts";
import {
IconCheck,
IconCopy,
IconInfoCircle,
IconTrash,
} from "@tabler/icons-react";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
// Mirrors the server rule: an exact https origin, tolerating only a trailing slash.
function parseTrustedOrigin(value: string): string | null {
const input = value.trim().toLowerCase();
try {
const url = new URL(input);
if (url.protocol !== "https:") return null;
if (input !== url.origin && input !== `${url.origin}/`) return null;
return url.origin;
} catch {
return null;
}
}
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
@@ -51,14 +30,7 @@ export default function McpSettings() {
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const [newClientName, setNewClientName] = useState("");
const [newClientOrigin, setNewClientOrigin] = useState("");
const mcpUrl = `${getAppUrl()}/mcp`;
const storedTrustedClients = workspace?.trustedOauthClients;
const trustedClients = Array.isArray(storedTrustedClients)
? storedTrustedClients
: [];
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -74,51 +46,6 @@ export default function McpSettings() {
}
};
const saveTrustedClients = async (next: ITrustedOAuthClient[]) => {
try {
const updatedWorkspace = await updateWorkspace({
trustedOauthClients: next,
});
setWorkspace(updatedWorkspace);
return true;
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
return false;
}
};
const handleAddTrustedClient = async () => {
const name = newClientName.trim();
const origin = parseTrustedOrigin(newClientOrigin);
if (!origin) {
notifications.show({
message: t("Enter the app's callback origin, e.g. https://app.yourcompany.com"),
color: "red",
});
return;
}
if (trustedClients.some((client) => client.origin.toLowerCase() === origin)) {
notifications.show({
message: t("This origin is already trusted."),
color: "red",
});
return;
}
if (await saveTrustedClients([...trustedClients, { origin, name }])) {
setNewClientName("");
setNewClientOrigin("");
}
};
const handleRemoveTrustedClient = (origin: string) => {
void saveTrustedClients(
trustedClients.filter((client) => client.origin !== origin),
);
};
return (
<Stack gap="lg">
{!hasAccess && (
@@ -181,7 +108,7 @@ export default function McpSettings() {
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text>
@@ -222,81 +149,6 @@ export default function McpSettings() {
</List.Item>
</List>
</div>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Trusted applications")}
</Text>
<Text size="sm" c="dimmed" mb="xs">
{t(
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
)}
</Text>
{trustedClients.length > 0 && (
<Table verticalSpacing="xs" mb="xs">
<Table.Tbody>
{trustedClients.map((client) => (
<Table.Tr key={client.origin}>
<Table.Td>
<Text size="sm" fw={500}>
{client.name}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{client.origin}
</Text>
</Table.Td>
<Table.Td w={40}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Remove {{name}}", {
name: client.name,
})}
onClick={() =>
handleRemoveTrustedClient(client.origin)
}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group gap="xs">
<TextInput
value={newClientName}
onChange={(event) =>
setNewClientName(event.currentTarget.value)
}
placeholder={t("Name")}
aria-label={t("Trusted application name")}
maxLength={64}
style={{ flex: 1 }}
/>
<TextInput
value={newClientOrigin}
onChange={(event) =>
setNewClientOrigin(event.currentTarget.value)
}
placeholder="https://app.yourcompany.com"
aria-label={t("Trusted application origin")}
style={{ flex: 2 }}
/>
<Button
variant="default"
onClick={handleAddTrustedClient}
disabled={!newClientName.trim() || !newClientOrigin.trim()}
>
{t("Add")}
</Button>
</Group>
</div>
</div>
)}
</Stack>
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
@@ -17,8 +17,6 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useLocation, useNavigate } from "react-router-dom";
import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -31,11 +29,6 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/authorized-apps")
? "authorized-apps"
: "api-keys";
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
@@ -54,27 +47,26 @@ export default function UserApiKeys() {
setRevokeModalOpened(true);
};
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return (
<>
<DocumentTitle
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<DocumentTitle title={t("API keys")} />
<SettingsTitle title={t("API keys")} />
{mcpEnabled && (
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
@@ -93,70 +85,37 @@ export default function UserApiKeys() {
</Alert>
)}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="api-keys">
{t("API keys")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="authorized-apps">
{t("Authorized apps")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="api-keys" pt="md">
<Group justify="space-between" align="center" mb="md">
<Text size="sm" c="dimmed">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{canCreate && (
<Button
onClick={() => setCreateModalOpened(true)}
style={{ flexShrink: 0 }}
>
{t("Create API Key")}
</Button>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
) : null}
{!canCreate && restrictToAdmins && (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
)}
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="authorized-apps" pt="md">
<AuthorizedAppsPanel />
</Tabs.Panel>
</Tabs>
)}
<CreateApiKeyModal
opened={createModalOpened}
+1 -1
View File
@@ -19,8 +19,8 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
OAUTH: 'oauth',
} as const;
@@ -1,19 +0,0 @@
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
export function AuthorizedAppsPanel() {
const { t } = useTranslation();
const { data, isLoading } = useOAuthGrantsQuery();
return (
<>
<Text size="sm" c="dimmed" mb="md">
{t("Applications and AI assistants you have authorized to access your account.")}
</Text>
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
</>
);
}
@@ -1,178 +0,0 @@
import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
// Callback hosts identify the app; the full URL belongs on the consent screen.
function callbackHosts(redirectUris: string[] = []): string[] {
const hosts = redirectUris.map((uri) => {
try {
const url = new URL(uri);
return url.protocol === "http:" || url.protocol === "https:"
? url.host
: url.protocol;
} catch {
return uri;
}
});
return Array.from(new Set(hosts));
}
type OAuthGrantsTableProps = {
grants: IOAuthGrant[];
isLoading?: boolean;
};
function TableSkeleton() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Group gap={4}>
<Skeleton height={20} width={50} />
<Skeleton height={20} width={55} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={28} width={28} circle />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const revokeMutation = useRevokeOAuthGrantMutation();
const scopeMeta: Record<string, { color: string; label: string }> = {
read: { color: "blue", label: t("Read") },
write: { color: "orange", label: t("Write") },
};
const formatDate = (date: string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
const openRevokeModal = (grant: IOAuthGrant) =>
modals.openConfirmModal({
title: t("Revoke access"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
{ name: grant.clientName },
)}
</Text>
),
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(grant.id),
});
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Application")}</Table.Th>
<Table.Th>{t("Permissions")}</Table.Th>
<Table.Th>{t("Authorized")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : grants.length > 0 ? (
grants.map((grant) => (
<Table.Tr key={grant.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{grant.clientName}
</Text>
<Text
fz="xs"
c="dimmed"
title={grant.redirectUris?.join("\n")}
style={{ overflowWrap: "anywhere" }}
>
{callbackHosts(grant.redirectUris).join(", ")}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{grant.scopes.map((scope) => {
const meta = scopeMeta[scope];
if (!meta) return null;
return (
<Badge
key={scope}
variant="light"
color={meta.color}
size="sm"
>
{meta.label}
</Badge>
);
})}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke access")}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke access for {{name}}", {
name: grant.clientName,
})}
onClick={() => openRevokeModal(grant)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,353 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Anchor,
Box,
Button,
Center,
Checkbox,
Container,
Divider,
Group,
Loader,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconEye,
IconInfoCircle,
IconPencil,
} from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { useLocation, useSearchParams } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
import classes from "@/features/auth/components/auth.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { UserInfo } from "@/components/common/user-info.tsx";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { logout } from "@/features/auth/services/auth-service";
import { ICurrentUser } from "@/features/user/types/user.types";
import APP_ROUTE from "@/lib/app-route.ts";
import {
approveOAuthAuthorization,
getOAuthAuthorizeInfo,
} from "@/ee/oauth/services/oauth-service";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
} from "@/ee/oauth/types/oauth.types";
function loginRedirectUrl(pathname: string, search: string): string {
return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
}
function errorStatus(error: any): number | undefined {
return error?.response?.status;
}
function errorText(error: any): string | undefined {
const data = error?.response?.data;
if (typeof data?.error_description === "string") return data.error_description;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message;
}
const OAUTH_PARAM_NAMES = [
"response_type",
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource",
];
function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
const picked: IAuthorizeParams = {};
for (const name of OAUTH_PARAM_NAMES) {
if (params[name] !== undefined) {
picked[name] = params[name];
}
}
return picked;
}
export default function OAuthConsent() {
const { t } = useTranslation();
const location = useLocation();
const [searchParams] = useSearchParams();
const params = useMemo<IAuthorizeParams>(
() => Object.fromEntries(searchParams.entries()),
[searchParams],
);
const currentUserQuery = useCurrentUser();
const infoQuery = useQuery({
queryKey: ["oauth-authorize-info", params],
queryFn: () => getOAuthAuthorizeInfo(params),
});
const isUnauthenticated =
errorStatus(currentUserQuery.error) === 401 ||
errorStatus(infoQuery.error) === 401;
useEffect(() => {
if (isUnauthenticated) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}, [isUnauthenticated, location.pathname, location.search]);
const isLoading =
isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
return (
<AuthLayout>
<DocumentTitle title={t("Authorize application")} />
<Container size={460} className={classes.container}>
<Box p="xl">
{isLoading ? (
<Center mih={200}>
<Loader />
</Center>
) : infoQuery.data && currentUserQuery.data ? (
<ConsentCard
info={infoQuery.data}
currentUser={currentUserQuery.data}
params={params}
/>
) : (
<InvalidRequestCard
description={errorText(infoQuery.error ?? currentUserQuery.error)}
/>
)}
</Box>
</Container>
</AuthLayout>
);
}
function InvalidRequestCard({ description }: { description?: string }) {
const { t } = useTranslation();
return (
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="red">
<IconAlertTriangle size={26} stroke={1.5} />
</ThemeIcon>
<Title order={3} ta="center" fw={600}>
{t("Invalid authorization request")}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
</Stack>
);
}
type ConsentCardProps = {
info: IOAuthAuthorizeInfo;
currentUser: ICurrentUser;
params: IAuthorizeParams;
};
function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
const { t } = useTranslation();
const location = useLocation();
const [, setCurrentUser] = useAtom(currentUserAtom);
const [approvedScopes, setApprovedScopes] = useState<string[]>(
info.scopes.filter((scope) => scope === "read" || scope === "write"),
);
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
const [acknowledged, setAcknowledged] = useState(false);
const scopeRows = [
{
scope: "read",
icon: <IconEye size={16} stroke={1.5} />,
label: t("Read"),
description: t("View data in your workspace without making changes."),
},
{
scope: "write",
icon: <IconPencil size={16} stroke={1.5} />,
label: t("Write"),
description: t("Create and modify data in your workspace."),
},
];
function toggleScope(scope: string, checked: boolean) {
setApprovedScopes((prev) =>
checked ? [...prev, scope] : prev.filter((item) => item !== scope),
);
}
async function submitDecision(approved: boolean) {
setSubmitting(approved ? "approve" : "deny");
const oauthParams = pickOAuthParams(params);
const payload: IApproveAuthorizationPayload = approved
? { ...oauthParams, approved: true, approvedScopes }
: { ...oauthParams, approved: false };
try {
const res = await approveOAuthAuthorization(payload);
window.location.replace(res.redirectUrl);
} catch (err) {
if (errorStatus(err) === 401) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
return;
}
setSubmitting(null);
notifications.show({
message: errorText(err) || t("Something went wrong. Please try again."),
color: "red",
});
}
}
async function switchAccount() {
if (submitting !== null) {
return;
}
setCurrentUser(RESET);
try {
await logout();
} finally {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}
return (
<Stack gap="lg">
<Title order={3} ta="center" fw={600}>
{t("{{name}} wants to access {{workspace}}", {
name: info.clientName,
workspace: currentUser.workspace.name,
})}
</Title>
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<UserInfo user={currentUser.user} />
<Anchor
component="button"
type="button"
size="xs"
c="dimmed"
disabled={submitting !== null}
onClick={switchAccount}
style={{ whiteSpace: "nowrap" }}
>
{t("Not you? Switch account")}
</Anchor>
</Group>
</Paper>
<Divider />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("This application will be able to:")}
</Text>
{scopeRows.map((row) => {
const requested = info.scopes.includes(row.scope);
return (
<Checkbox
key={row.scope}
size="sm"
checked={approvedScopes.includes(row.scope)}
disabled={!requested || submitting !== null}
onChange={(event) =>
toggleScope(row.scope, event.currentTarget.checked)
}
label={
<Group gap={6} wrap="nowrap">
{row.icon}
<Text size="sm" fw={500}>
{row.label}
</Text>
</Group>
}
description={row.description}
/>
);
})}
</Stack>
<div>
<Text size="xs" c="dimmed">
{t("You will be redirected to")}
</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{info.redirectUri}
</Text>
</div>
{!info.verified && (
<Alert
variant="light"
color="yellow"
icon={<IconInfoCircle size={16} />}
>
<Stack gap="xs">
<Text size="sm">
{t(
"This application is not on your workspace's trusted list. Authorize only if you recognize it.",
)}
</Text>
<Checkbox
size="sm"
checked={acknowledged}
disabled={submitting !== null}
onChange={(event) =>
setAcknowledged(event.currentTarget.checked)
}
label={t("I recognize this application and want to continue")}
/>
</Stack>
</Alert>
)}
<Group grow>
<Button
variant="default"
onClick={() => submitDecision(false)}
loading={submitting === "deny"}
disabled={submitting === "approve"}
>
{t("Cancel")}
</Button>
<Button
onClick={() => submitDecision(true)}
loading={submitting === "approve"}
disabled={
approvedScopes.length === 0 ||
(!info.verified && !acknowledged) ||
submitting === "deny"
}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -1,45 +0,0 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getOAuthGrants,
revokeOAuthGrant,
} from "@/ee/oauth/services/oauth-service";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useOAuthGrantsQuery(): UseQueryResult<IOAuthGrant[], Error> {
return useQuery({
queryKey: ["oauth-grants"],
queryFn: () => getOAuthGrants(),
staleTime: 0,
gcTime: 0,
});
}
export function useRevokeOAuthGrantMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (grantId) => revokeOAuthGrant(grantId),
onSuccess: () => {
notifications.show({ message: t("Access revoked") });
queryClient.invalidateQueries({
predicate: (item) =>
["oauth-grants"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Something went wrong. Please try again."),
color: "red",
});
},
});
}
@@ -1,36 +0,0 @@
import api from "@/lib/api-client";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
IOAuthGrant,
} from "@/ee/oauth/types/oauth.types";
export async function getOAuthAuthorizeInfo(
params: IAuthorizeParams,
): Promise<IOAuthAuthorizeInfo> {
const req = await api.post<IOAuthAuthorizeInfo>(
"/oauth/authorize-info",
params,
);
return req.data;
}
export async function approveOAuthAuthorization(
payload: IApproveAuthorizationPayload,
): Promise<{ redirectUrl: string }> {
const req = await api.post<{ redirectUrl: string }>(
"/oauth/authorize",
payload,
);
return req.data;
}
export async function getOAuthGrants(): Promise<IOAuthGrant[]> {
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
return req.data;
}
export async function revokeOAuthGrant(grantId: string): Promise<void> {
await api.post("/oauth/grants/revoke", { grantId });
}
@@ -1,24 +0,0 @@
export interface IOAuthAuthorizeInfo {
clientName: string;
redirectUri: string;
scopes: string[];
clientCreatedAt: string;
verified: boolean;
}
export interface IOAuthGrant {
id: string;
clientName: string;
redirectUris: string[];
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
}
export type IAuthorizeParams = Record<string, string>;
export type IApproveAuthorizationPayload = {
[param: string]: unknown;
approved: boolean;
approvedScopes?: string[];
};
@@ -0,0 +1,62 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceHideCommentsToggleProps = {
space: ISpace;
};
export default function SpaceHideCommentsToggle({
space,
}: SpaceHideCommentsToggleProps) {
const { t } = useTranslation();
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const allowViewerCommentsEnabled =
space.settings?.comments?.allowViewerComments === true;
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
const tooltipLabel = !hasHideComments
? upgradeLabel
: t("Turn off 'Allow viewers to comment' first");
const [checked, setChecked] = useState(
space.settings?.comments?.hideCommentsFromViewers === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
hideCommentsFromViewers: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hide comments from viewers")}</Text>
<Text size="sm" c="dimmed">
{t("Viewers cannot see or add comments on pages in this space.")}
</Text>
</div>
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle hide comments from viewers")}
/>
</Tooltip>
</Group>
);
}
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const hideCommentsEnabled =
space.settings?.comments?.hideCommentsFromViewers === true;
const isDisabled = !hasViewerComments || hideCommentsEnabled;
const tooltipLabel = !hasViewerComments
? upgradeLabel
: t("Turn off 'Hide comments from viewers' first");
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
</Text>
</div>
<Tooltip
label={upgradeLabel}
label={tooltipLabel}
disabled={!isDisabled}
refProp="rootRef"
>
@@ -41,7 +41,6 @@ export default function ReadonlyTemplateEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={template.content}
/>
@@ -87,7 +87,6 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
textDirection: "auto",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@@ -1,59 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -1,25 +0,0 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,17 +3,7 @@ import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon(
file: File,
@@ -1,5 +1,4 @@
export {
getPageAttachments,
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
@@ -15,15 +15,6 @@ export interface IAttachment {
deletedAt: string | null;
}
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
@@ -103,7 +103,6 @@ const CommentEditor = forwardRef(
},
content: defaultContent,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false,
@@ -0,0 +1,15 @@
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { extractPageSlugId } from "@/lib";
export function useCanViewComments(): boolean {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
return (
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
);
}
@@ -12,7 +12,6 @@ import {
IconMathFunction,
IconRotate2,
IconSitemap,
IconSuperscript,
IconTable,
IconTag,
} from "@tabler/icons-react";
@@ -271,12 +270,6 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
>
{t("Math block")}
</Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -30,7 +30,6 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -178,16 +177,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -40,7 +40,6 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content as any}
/>
@@ -1,6 +1,5 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -64,9 +63,6 @@ import {
TransclusionReference,
TableView,
BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -136,7 +132,6 @@ lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
document: false,
heading: false,
undoRedo: false,
link: false,
@@ -148,9 +143,6 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -211,8 +203,7 @@ export const mainExtensions = [
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote" ||
parentName === "footnote"
parentName === "blockquote"
) {
return i18n.t("Write...");
}
@@ -426,9 +417,6 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({
elementsToJoin: [],
}),
+27 -15
View File
@@ -82,6 +82,8 @@ import {
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
import clsx from "clsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageEditorProps {
pageId: string;
@@ -196,6 +198,7 @@ function CollabPageEditor({
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canViewComments = useCanViewComments();
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -249,7 +252,6 @@ function CollabPageEditor({
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -373,6 +375,7 @@ function CollabPageEditor({
};
useEffect(() => {
if (!canViewComments) return;
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
return () => {
document.removeEventListener(
@@ -380,7 +383,7 @@ function CollabPageEditor({
handleActiveCommentEvent,
);
};
}, []);
}, [canViewComments]);
useEffect(() => {
setActiveCommentId(null);
@@ -431,7 +434,13 @@ function CollabPageEditor({
}
return (
<div className="editor-container" style={{ position: "relative" }}>
<div
className={clsx(
"editor-container",
!canViewComments && "comments-hidden",
)}
style={{ position: "relative" }}
>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@@ -481,18 +490,21 @@ function StaticPageEditor({
content: any;
ariaLabel: string;
}) {
const canViewComments = useCanViewComments();
return (
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
<div className={clsx(!canViewComments && "comments-hidden")}>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
</div>
);
}
@@ -85,7 +85,6 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions}
content={title}
></EditorProvider>
@@ -94,7 +93,6 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
onCreate={({ editor }) => {
@@ -315,3 +315,8 @@
height: 100%;
}
}
.comments-hidden .ProseMirror .comment-mark {
background: none;
border-bottom: none;
}
@@ -1,26 +0,0 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,4 +18,3 @@
@import "./columns.css";
@import "./status.css";
@import "./base-embed.css";
@import "./footnotes.css";
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: start;
text-align: left;
}
.column-resize-handle {
@@ -86,7 +86,6 @@ export function TitleEditor({
},
editable: editable,
content: title,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -1,7 +1,7 @@
.history {
display: flex;
align-items: center;
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
@@ -12,28 +12,6 @@
}
}
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
@@ -99,8 +77,3 @@
flex: 1;
padding: rem(16px) rem(40px);
}
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -31,7 +31,6 @@ export function HistoryEditor({
const editor = useEditor({
extensions: mainExtensions,
editable: false,
textDirection: "auto",
});
useEffect(() => {
@@ -171,6 +170,7 @@ export function HistoryEditor({
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
@@ -1,21 +1,10 @@
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5;
@@ -26,13 +15,6 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
}
const HistoryItem = memo(function HistoryItem({
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
onHover,
onHoverEnd,
isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => {
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
@@ -69,115 +37,63 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0;
return (
<div
className={clsx(classes.history, { [classes.active]: isActive })}
<UnstyledButton
p="xs"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<UnstyledButton
p="xs"
onClick={handleClick}
className={classes.historyButton}
>
<Text size="sm">{date}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors
.slice(0, MAX_VISIBLE_AVATARS)
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
{contributors[0].name}
</Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</div>
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
);
});
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const {
data: pageHistoryData,
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback(
(id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -213,44 +148,22 @@ function HistoryList({ pageId }: Props) {
)}
</ScrollArea>
{compareMode ? (
{canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={handleCancelCompare}
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group>
</>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)}
</div>
);
@@ -1,6 +1,5 @@
import {
ActionIcon,
CloseButton,
Group,
Paper,
ScrollArea,
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useMemo, useRef } from "react";
import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props {
pageId: string;
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav>
<div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea
h={650}
w="100%"
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
@@ -7,29 +7,21 @@ import {
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
function HistoryView() {
const { t } = useTranslation();
const activeId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(resolvedId);
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(resolvedPrevId);
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
@@ -3,45 +3,22 @@ import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -1,4 +1,4 @@
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
@@ -9,8 +9,7 @@ import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -39,66 +40,47 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(
async (historyId: string) => {
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditorTitle
.chain()
.clearContent()
.setContent(historyData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
mainEditor
.chain()
.clearContent()
.setContent(historyData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
},
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
});
}
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery(
pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -1,18 +0,0 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,7 +11,6 @@ import {
IconList,
IconMarkdown,
IconMessage,
IconPaperclip,
IconPrinter,
IconStar,
IconStarFilled,
@@ -43,7 +42,6 @@ import {
import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
@@ -60,6 +58,7 @@ import {
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -67,6 +66,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
const canViewComments = useCanViewComments();
const tocTriggerProps = useAsideTriggerProps("toc");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
@@ -107,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
{canViewComments && (
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!page?.isBase && (
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
@@ -159,10 +161,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -299,15 +297,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item>
)}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && (
<PageVerificationMenuItem
pageId={page?.id}
@@ -410,12 +399,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened}
onClose={closeVerificationModal}
/>
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</>
);
}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
width?: number;
opened?: boolean;
clearable?: boolean;
withinPortal?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -42,7 +41,6 @@ export function SpaceSelect({
width,
opened,
clearable,
withinPortal = true,
}: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
@@ -52,13 +50,9 @@ export function SpaceSelect({
limit: 50,
});
const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => {
if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
@@ -89,19 +83,14 @@ export function SpaceSelect({
onSearchChange={setSearchValue}
clearable={clearable}
variant="filled"
onChange={(slug) => {
// options accumulate across fetches; resolve against everything
// fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);
@@ -70,7 +70,6 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
withinPortal={false}
/>
</Popover.Dropdown>
</Popover>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
<Divider my="lg" />
<SpaceHideCommentsToggle space={space} />
</div>
);
}
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
export interface ISpaceSettings {
@@ -36,6 +37,7 @@ export interface ISpace {
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
interface IMembership {
@@ -31,7 +31,6 @@ export interface IWorkspace {
allowPersonalSpaces?: boolean;
defaultPageEditMode?: string;
isScimEnabled?: boolean;
trustedOauthClients?: ITrustedOAuthClient[];
}
export interface IWorkspaceSettings {
@@ -43,11 +42,6 @@ export interface IWorkspaceSettings {
defaultPageEditMode?: string;
}
export interface ITrustedOAuthClient {
origin: string;
name: string;
}
export interface IWorkspaceApiSettings {
restrictToAdmins?: boolean;
}
-2
View File
@@ -76,8 +76,6 @@ function redirectToLogin() {
APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites",
// the oauth consent page redirects to login itself, preserving its query string
"/oauth/consent",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname;
-1
View File
@@ -43,7 +43,6 @@
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@jmondi/oauth2-server": "^4.3.7",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
-2
View File
@@ -27,7 +27,6 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
try {
@@ -54,7 +53,6 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -1,5 +1,4 @@
import { StarterKit } from '@tiptap/starter-kit';
import { Document } from '@tiptap/extension-document';
import { TextAlign } from '@tiptap/extension-text-align';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
@@ -46,16 +45,8 @@ import {
TransclusionSource,
TransclusionReference,
BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
} from '@docmost/editor-ext';
import {
extensions as coreExtensions,
generateText,
getSchema,
JSONContent,
} from '@tiptap/core';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
@@ -66,17 +57,12 @@ import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
coreExtensions.TextDirection.configure({ direction: 'auto' }),
StarterKit.configure({
document: false,
codeBlock: false,
link: false,
trailingNode: false,
heading: false,
}),
Document.extend({
content: 'block+ footnotes?',
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph', 'transclusionSource'],
@@ -124,10 +110,7 @@ export const tiptapExtensions = [
Status,
TransclusionSource,
TransclusionReference,
BaseEmbed,
Footnotes,
Footnote,
FootnoteReference,
BaseEmbed
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -1,8 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const OAUTH_SCOPE_KEY = 'oauthScope';
export type OAuthRouteScope = 'read' | 'write';
export const OAuthScope = (scope: OAuthRouteScope) =>
SetMetadata(OAUTH_SCOPE_KEY, scope);
@@ -1,6 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_SESSION_AUTH_KEY = 'requireSessionAuth';
export const RequireSessionAuth = () =>
SetMetadata(REQUIRE_SESSION_AUTH_KEY, true);
@@ -23,11 +23,6 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// OAuth
OAUTH_CLIENT_REGISTERED: 'oauth_client.registered',
OAUTH_GRANT_CREATED: 'oauth_grant.created',
OAUTH_GRANT_REVOKED: 'oauth_grant.revoked',
// SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated',
@@ -129,8 +124,6 @@ export const AuditResource = {
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
OAUTH_CLIENT: 'oauth_client',
OAUTH_GRANT: 'oauth_grant',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
@@ -12,8 +12,6 @@ export enum EventName {
SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted',
USER_PASSWORD_RESET = 'user.password.reset',
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
+1 -1
View File
@@ -18,12 +18,12 @@ export const Feature = {
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
OAUTH: 'oauth',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,186 +0,0 @@
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { OAUTH_SCOPE_KEY } from '../decorators/oauth-scope.decorator';
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
import { JwtType } from '../../core/auth/dto/jwt-payload';
const handlerSentinel = () => 'handler';
const classSentinel = class Controller {};
function createCtx(): ExecutionContext {
return {
getHandler: () => handlerSentinel,
getClass: () => classSentinel,
} as any;
}
function createGuard(scopeMetadata?: unknown, requireSession?: boolean) {
const reflector = {
getAllAndOverride: jest.fn((key: string) =>
key === REQUIRE_SESSION_AUTH_KEY ? requireSession : scopeMetadata,
),
} as any;
const environmentService = {
isCloud: jest.fn().mockReturnValue(false),
} as any;
const guard = new JwtAuthGuard(reflector, environmentService);
return { guard, reflector };
}
function oauthUser(scopes: string[]) {
return {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
oauth: { grantId: 'grant_1', scopes },
};
}
describe('JwtAuthGuard.handleRequest', () => {
it('rethrows the strategy error', () => {
const { guard } = createGuard();
const err = new UnauthorizedException('bad token');
expect(() => guard.handleRequest(err, null, null, createCtx())).toThrow(err);
});
it('throws UnauthorizedException when there is no user', () => {
const { guard } = createGuard();
expect(() => guard.handleRequest(null, null, null, createCtx())).toThrow(
UnauthorizedException,
);
});
it('returns a non-oauth user untouched without consulting scope metadata', () => {
const { guard, reflector } = createGuard();
const user = { user: { id: 'user_1' }, workspace: { id: 'ws_1' } };
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
expect(reflector.getAllAndOverride).not.toHaveBeenCalledWith(
OAUTH_SCOPE_KEY,
expect.anything(),
);
});
it('forbids an oauth user on a route without scope metadata', () => {
const { guard, reflector } = createGuard(undefined);
expect(() =>
guard.handleRequest(null, oauthUser(['read', 'write']), null, createCtx()),
).toThrow(ForbiddenException);
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OAUTH_SCOPE_KEY, [
handlerSentinel,
classSentinel,
]);
});
it('passes read scope on a read route', () => {
const { guard } = createGuard('read');
const user = oauthUser(['read']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
it('forbids read scope on a write route with insufficient_scope', () => {
const { guard } = createGuard('write');
expect(() =>
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
).toThrow('insufficient_scope');
});
it('passes write scope on a read route', () => {
const { guard } = createGuard('read');
const user = oauthUser(['write']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
it('passes write scope on a write route', () => {
const { guard } = createGuard('write');
const user = oauthUser(['write']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
describe('session-only routes', () => {
const sessionUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.ACCESS,
};
it('allows a signed-in session', () => {
const { guard } = createGuard(undefined, true);
expect(guard.handleRequest(null, sessionUser, null, createCtx())).toBe(
sessionUser,
);
});
it('forbids an api key', () => {
const { guard } = createGuard(undefined, true);
const apiKeyUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.API_KEY,
};
expect(() =>
guard.handleRequest(null, apiKeyUser, null, createCtx()),
).toThrow('This action requires an interactive user session');
});
it('forbids an oauth token even when it carries write scope', () => {
const { guard } = createGuard('write', true);
const user = { ...oauthUser(['write']), authType: JwtType.OAUTH_ACCESS };
expect(() => guard.handleRequest(null, user, null, createCtx())).toThrow(
'This action requires an interactive user session',
);
});
it('leaves api keys working on routes without the marker', () => {
const { guard } = createGuard(undefined, undefined);
const apiKeyUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.API_KEY,
};
expect(guard.handleRequest(null, apiKeyUser, null, createCtx())).toBe(
apiKeyUser,
);
});
});
it('lets handler metadata override class metadata', () => {
const metadataByTarget = new Map<unknown, string>([
[handlerSentinel, 'write'],
[classSentinel, 'read'],
]);
const reflector = {
getAllAndOverride: jest.fn((key: string, targets: unknown[]) => {
if (key === REQUIRE_SESSION_AUTH_KEY) {
return undefined;
}
for (const target of targets) {
if (metadataByTarget.has(target)) {
return metadataByTarget.get(target);
}
}
return undefined;
}),
} as any;
const environmentService = { isCloud: jest.fn().mockReturnValue(false) } as any;
const guard = new JwtAuthGuard(reflector, environmentService);
expect(() =>
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
).toThrow('insufficient_scope');
});
});
@@ -1,26 +1,16 @@
import {
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import {
OAUTH_SCOPE_KEY,
OAuthRouteScope,
} from '../decorators/oauth-scope.decorator';
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { Reflector } from '@nestjs/core';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { addDays } from 'date-fns';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private logger = new Logger('JwtAuthGuard');
constructor(
private reflector: Reflector,
private environmentService: EnvironmentService,
@@ -46,39 +36,6 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
throw err || new UnauthorizedException();
}
const requiresSession = this.reflector.getAllAndOverride<boolean>(
REQUIRE_SESSION_AUTH_KEY,
[ctx.getHandler(), ctx.getClass()],
);
if (requiresSession && user.authType !== JwtType.ACCESS) {
this.logger.warn(
`session-only endpoint ${ctx.getClass()?.name}.${ctx.getHandler()?.name} refused authType ${user.authType}`,
);
throw new ForbiddenException(
'This action requires an interactive user session',
);
}
if (user.oauth) {
const required = this.reflector.getAllAndOverride<
OAuthRouteScope | undefined
>(OAUTH_SCOPE_KEY, [ctx.getHandler(), ctx.getClass()]);
if (!required) {
this.logger.warn(
`oauth scope check: no @OAuthScope metadata on ${ctx.getClass()?.name}.${ctx.getHandler()?.name}`,
);
throw new ForbiddenException('OAuth tokens cannot access this endpoint');
}
const scopes: string[] = user.oauth.scopes ?? [];
const satisfied =
required === 'read'
? scopes.includes('read') || scopes.includes('write')
: scopes.includes('write');
if (!satisfied) {
throw new ForbiddenException('insufficient_scope');
}
}
this.setJoinedWorkspacesCookie(user, ctx);
return user;
}
@@ -1,66 +0,0 @@
import {
resolveFrameHeader,
resolveFrameHeadersForPath,
SecurityHeader,
} from './security-headers';
describe('resolveFrameHeader', () => {
it('denies framing with X-Frame-Options when embedding is off', () => {
expect(resolveFrameHeader(false, [])).toEqual({
name: 'X-Frame-Options',
value: 'SAMEORIGIN',
});
});
it('returns null when embedding is on but no origins are configured', () => {
expect(resolveFrameHeader(true, [])).toBeNull();
});
it('emits a frame-ancestors CSP for the allowed origins', () => {
expect(resolveFrameHeader(true, ['https://a.example', 'https://b.example']))
.toEqual({
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.example https://b.example",
});
});
});
describe('resolveFrameHeadersForPath', () => {
const configured: SecurityHeader = {
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.example",
};
it.each(['/oauth/consent', '/oauth/consent/nested'])(
'force-denies %s regardless of configured header',
(path) => {
expect(resolveFrameHeadersForPath(path, configured)).toEqual([
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]);
},
);
it('force-denies consent even when the global header is absent', () => {
expect(resolveFrameHeadersForPath('/oauth/consent', null)).toEqual([
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]);
});
it('does not match an unrelated path that merely contains the prefix', () => {
expect(
resolveFrameHeadersForPath('/oauth/consenting-adults', configured),
).toEqual([configured]);
});
it('passes the configured header through for other paths', () => {
expect(resolveFrameHeadersForPath('/home', configured)).toEqual([
configured,
]);
});
it('returns nothing for other paths when no header is configured', () => {
expect(resolveFrameHeadersForPath('/home', null)).toEqual([]);
});
});
@@ -17,19 +17,3 @@ export function resolveFrameHeader(
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
};
}
// Deny OAuth consent in iframe
export const OAUTH_CONSENT_PATH = '/oauth/consent';
export function resolveFrameHeadersForPath(
path: string,
configuredHeader: SecurityHeader | null,
): SecurityHeader[] {
if (path === OAUTH_CONSENT_PATH || path.startsWith(`${OAUTH_CONSENT_PATH}/`)) {
return [
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
];
}
return configuredHeader ? [configuredHeader] : [];
}
@@ -24,7 +24,6 @@ import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
@@ -54,14 +53,8 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import {
AttachmentInfoDto,
PageIdDto,
RemoveIconDto,
} from './dto/attachment.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
import { DomainService } from '../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
@@ -82,7 +75,6 @@ export class AttachmentController {
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
private readonly domainService: DomainService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -159,10 +151,7 @@ export class AttachmentController {
},
});
return res.send({
...fileResponse,
url: this.buildFileUrl(workspace, fileResponse),
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
@@ -176,7 +165,6 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
@OAuthScope('read')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@@ -423,38 +411,7 @@ export class AttachmentController {
await this.pageAccessService.validateCanView(page, user);
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/attachments')
@OAuthScope('read')
async getPageAttachments(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const result = await this.attachmentRepo.findPageAttachments(
page.id,
pagination,
);
return {
...result,
items: result.items.map((attachment) => ({
...attachment,
url: this.buildFileUrl(workspace, attachment),
})),
};
return attachment;
}
@UseGuards(JwtAuthGuard)
@@ -508,10 +465,6 @@ export class AttachmentController {
}
}
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
}
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
@@ -1,11 +1,4 @@
import {
IsEnum,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@@ -14,12 +7,6 @@ export class AttachmentInfoDto {
attachmentId: string;
}
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import {
ALL_NAMED_THROTTLERS_SKIPPED,
AI_CHAT_THROTTLER,
AUTH_THROTTLER,
} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
@@ -38,7 +38,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
@SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false })
@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
@@ -7,7 +7,6 @@ export enum JwtType {
API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
OAUTH_ACCESS = 'oauth_access',
}
export type JwtPayload = {
sub: string;
@@ -60,14 +59,3 @@ export type JwtPdfExportDownloadPayload = {
workspaceId: string;
type: 'pdf_export_download';
};
export type JwtOAuthPayload = {
sub: string;
workspaceId: string;
grantId: string;
scope: string;
aud: string;
iss: string;
jti: string;
type: JwtType.OAUTH_ACCESS;
};
@@ -2,7 +2,6 @@ import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
@@ -41,13 +40,9 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private signupService: SignupService,
private tokenService: TokenService,
@@ -58,7 +53,6 @@ export class AuthService {
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
private eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -267,19 +261,6 @@ export class AuthService {
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
// A failed revocation must not block the reset itself; log loudly instead.
try {
await this.eventEmitter.emitAsync(EventName.USER_PASSWORD_RESET, {
userId: user.id,
workspaceId: workspace.id,
});
} catch (err) {
this.logger.error(
`failed to revoke oauth grants for user ${user.id} after password reset`,
err,
);
}
this.auditService.setActorId(user.id);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET,
@@ -2,12 +2,7 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtApiKeyPayload,
JwtOAuthPayload,
JwtPayload,
JwtType,
} from '../dto/jwt-payload';
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
@@ -38,10 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
async validate(
req: any,
payload: JwtPayload | JwtApiKeyPayload | JwtOAuthPayload,
) {
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
if (!payload.workspaceId) {
throw new UnauthorizedException();
}
@@ -50,21 +42,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match');
}
// authType lets guards tell an interactive session from a programmatic credential.
if (payload.type === JwtType.API_KEY) {
const authResult = await this.validateApiKey(
req,
payload as JwtApiKeyPayload,
);
return { ...authResult, authType: JwtType.API_KEY };
}
if (payload.type === JwtType.OAUTH_ACCESS) {
const authResult = await this.validateOAuthToken(
req,
payload as JwtOAuthPayload,
);
return { ...authResult, authType: JwtType.OAUTH_ACCESS };
return this.validateApiKey(req, payload as JwtApiKeyPayload);
}
if (payload.type !== JwtType.ACCESS) {
@@ -120,36 +99,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Enterprise API Key module missing');
}
private async validateOAuthToken(req: any, payload: JwtOAuthPayload) {
let OAuthStrategyModule: any;
let isOAuthModuleReady = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
OAuthStrategyModule = require('./../../../ee/oauth/services/oauth-strategy.service');
isOAuthModuleReady = true;
} catch (err) {
this.logger.debug(
'OAuth module requested but enterprise module not bundled in this build',
);
isOAuthModuleReady = false;
}
if (isOAuthModuleReady) {
const OAuthStrategyService = this.moduleRef.get(
OAuthStrategyModule.OAuthStrategyService,
{
strict: false,
},
);
return OAuthStrategyService.validateOAuthToken(payload, {
workspaceId: req.raw.workspaceId,
host: req.raw.headers?.host ?? req.headers?.host,
});
}
throw new UnauthorizedException('Enterprise OAuth module missing');
}
}
@@ -16,7 +16,6 @@ import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
@@ -49,7 +48,6 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('create')
@OAuthScope('write')
async create(
@Body() createCommentDto: CreateCommentDto,
@AuthUser() user: User,
@@ -86,26 +84,34 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('/')
@OAuthScope('read')
async findPageComments(
@Body() input: PageIdDto,
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(input.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
async findOne(
@Body() input: CommentIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -116,14 +122,17 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return comment;
}
@HttpCode(HttpStatus.OK)
@Post('update')
@OAuthScope('write')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
import { PageAccessService } from '../../page/page-access/page-access.service';
@Injectable()
export class CommentNotificationService {
@@ -25,6 +26,7 @@ export class CommentNotificationService {
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly pageAccessService: PageAccessService,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
@@ -48,7 +50,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
@@ -72,7 +74,16 @@ export class CommentNotificationService {
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
let accessibleUserIds = usersWithPageAccess;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
accessibleUserIds =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
accessibleUserIds,
);
}
const usersWithAccess = new Set(accessibleUserIds);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -145,7 +156,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@@ -166,6 +177,16 @@ export class CommentNotificationService {
);
if (hasPageAccess.length === 0) return;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
const editCapable =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
[commentCreatorId],
);
if (editCapable.length === 0) return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -225,7 +246,7 @@ export class CommentNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'settings'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -236,6 +257,11 @@ export class CommentNotificationService {
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
pageUrl,
spaceSettings: (space.settings ?? null) as Record<string, any> | null,
};
}
}
@@ -7,6 +7,7 @@ import {
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageAccessService {
@@ -14,6 +15,7 @@ export class PageAccessService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
/**
@@ -118,8 +120,68 @@ export class PageAccessService {
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
if (
!settings?.comments?.allowViewerComments ||
settings?.comments?.hideCommentsFromViewers
) {
throw new ForbiddenException();
}
}
async validateCanViewComments(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
const { canEdit } = await this.validateCanViewWithPermissions(page, user);
if (canEdit) {
return;
}
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (settings?.comments?.hideCommentsFromViewers) {
throw new ForbiddenException();
}
}
/**
* Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
*/
async filterUserIdsWithPageEditAccess(
spaceId: string,
pageId: string,
userIds: string[],
): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const spaceHasRestrictedPages =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
const hasRestriction =
spaceHasRestrictedPages &&
(await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
if (!hasRestriction) {
const editCapableIds =
await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
userIds,
spaceId,
);
return userIds.filter((id) => editCapableIds.has(id));
}
const results = await Promise.all(
userIds.map(async (userId) => {
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
userId,
pageId,
);
return canEdit ? userId : null;
}),
);
return results.filter((id): id is string => id !== null);
}
}
@@ -26,7 +26,6 @@ import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
@@ -70,7 +69,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/info')
@OAuthScope('read')
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true,
@@ -201,7 +199,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('create')
@OAuthScope('write')
async create(
@Body() createPageDto: CreatePageDto,
@AuthUser() user: User,
@@ -272,7 +269,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('update')
@OAuthScope('write')
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(updatePageDto.pageId);
@@ -417,7 +413,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('recent')
@OAuthScope('read')
async getRecentPages(
@Body() recentPageDto: RecentPageDto,
@Body() pagination: PaginationOptions,
@@ -532,7 +527,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/sidebar-pages')
@OAuthScope('read')
async getSidebarPages(
@Body() dto: SidebarPageDto,
@Body() pagination: PaginationOptions,
@@ -575,7 +569,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('move-to-space')
@OAuthScope('write')
async movePageToSpace(
@Body() dto: MovePageToSpaceDto,
@AuthUser() user: User,
@@ -629,7 +622,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('duplicate')
@OAuthScope('write')
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) {
@@ -714,7 +706,6 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('move')
@OAuthScope('write')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) {
@@ -17,7 +17,6 @@ import {
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
@@ -43,7 +42,6 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
@OAuthScope('read')
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@@ -77,7 +75,6 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post('suggest')
@OAuthScope('read')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthUser() user: User,
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
@IsOptional()
@IsBoolean()
hideCommentsFromViewers: boolean;
}
@@ -30,6 +30,35 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
export function validateExclusiveCommentSettings(
dto: Partial<
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
>,
settingsBefore: Record<string, any>,
): void {
if (
dto.allowViewerComments === undefined &&
dto.hideCommentsFromViewers === undefined
) {
return;
}
const allowViewerComments =
dto.allowViewerComments ??
settingsBefore.comments?.allowViewerComments ??
false;
const hideCommentsFromViewers =
dto.hideCommentsFromViewers ??
settingsBefore.comments?.hideCommentsFromViewers ??
false;
if (allowViewerComments && hideCommentsFromViewers) {
throw new BadRequestException(
"'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
);
}
}
@Injectable()
export class SpaceService {
constructor(
@@ -141,7 +170,8 @@ export class SpaceService {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
@@ -168,6 +198,17 @@ export class SpaceService {
) {
throw new ForbiddenException('This feature requires a valid license');
}
if (
updateSpaceDto.hideCommentsFromViewers === true &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.HIDE_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
const spaceBefore = await this.spaceRepo.findById(
@@ -176,6 +217,8 @@ export class SpaceService {
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
const before: Record<string, any> = {};
const after: Record<string, any> = {};
@@ -218,6 +261,23 @@ export class SpaceService {
);
}
if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
if (prev !== updateSpaceDto.hideCommentsFromViewers) {
before.hideCommentsFromViewers = prev;
after.hideCommentsFromViewers =
updateSpaceDto.hideCommentsFromViewers;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'hideCommentsFromViewers',
updateSpaceDto.hideCommentsFromViewers,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -13,7 +13,6 @@ import { SpaceService } from './services/space.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service';
@@ -49,7 +48,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('/')
@OAuthScope('read')
async getWorkspaceSpaces(
@Body()
pagination: PaginationOptions,
@@ -94,7 +92,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('info')
@OAuthScope('read')
async getSpaceInfo(
@Body() spaceIdDto: SpaceIdDto,
@AuthUser() user: User,
@@ -132,7 +129,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('create')
@OAuthScope('write')
createSpace(
@Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User,
@@ -149,7 +145,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('update')
@OAuthScope('write')
async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User,
@@ -10,7 +10,6 @@ import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -25,7 +24,6 @@ export class UserController {
@HttpCode(HttpStatus.OK)
@Post('me')
@OAuthScope('read')
async getUserInfo(
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,
@@ -24,7 +24,6 @@ import {
RevokeInviteDto,
} from '../dto/invitation.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
@@ -59,7 +58,6 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('/info')
@OAuthScope('read')
async getWorkspace(@AuthWorkspace() workspace: Workspace) {
return this.workspaceService.getWorkspaceInfo(workspace.id);
}
@@ -115,7 +113,6 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('members')
@OAuthScope('read')
async getWorkspaceMembers(
@Body()
pagination: PaginationOptions,
@@ -1,32 +1,15 @@
import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { TrustedOAuthClient } from '../workspace.util';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class TrustedOAuthClientDto {
@IsString()
@IsNotEmpty()
origin: string;
@IsString()
@IsNotEmpty()
@MaxLength(64)
name: string;
}
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsArray()
@@ -85,11 +68,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsString()
@IsIn(['read', 'edit'])
defaultPageEditMode: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => TrustedOAuthClientDto)
trustedOauthClients?: TrustedOAuthClient[];
}
@@ -30,10 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import {
isAdminActingOnOwner,
normalizeTrustedOAuthClients,
} from '../workspace.util';
import { isAdminActingOnOwner } from '../workspace.util';
import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -317,12 +314,6 @@ export class WorkspaceService {
.filter(Boolean);
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients(
updateWorkspaceDto.trustedOauthClients,
);
}
if (updateWorkspaceDto.hostname) {
const hostname = updateWorkspaceDto.hostname;
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
@@ -343,8 +334,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.trustedOauthClients !== 'undefined'
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -372,18 +362,6 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.OAUTH,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
@@ -611,7 +589,6 @@ export class WorkspaceService {
'enforceMfa',
'emailDomains',
'isScimEnabled',
'trustedOauthClients',
],
updateWorkspaceDto,
workspaceBefore,
@@ -1,49 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTrustedOAuthClients } from './workspace.util';
describe('normalizeTrustedOAuthClients', () => {
it('lowercases origins and trims names', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: ' Acme MCP ' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Acme MCP' }]);
});
it('dedupes origins case-insensitively with the last entry winning', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: 'First' },
{ origin: 'https://mcp.acme.com', name: 'Second' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Second' }]);
});
it.each([
['http origin', 'http://mcp.acme.com'],
['trailing slash', 'https://mcp.acme.com/'],
['path suffix', 'https://mcp.acme.com/oauth'],
['uppercase host', 'https://MCP.acme.com'],
['not a url', 'mcp.acme.com'],
])('rejects %s naming the origin', (_label, origin) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(BadRequestException);
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(origin);
});
it.each([
['blank', ' '],
['too long', 'x'.repeat(65)],
])('rejects a %s name', (_label, name) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin: 'https://mcp.acme.com', name }]),
).toThrow(BadRequestException);
});
it('returns an empty array for no entries', () => {
expect(normalizeTrustedOAuthClients([])).toEqual([]);
});
});
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import { UserRole } from '../../common/helpers/types/permission';
export function isAdminActingOnOwner(
@@ -8,39 +7,6 @@ export function isAdminActingOnOwner(
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
}
export type TrustedOAuthClient = { origin: string; name: string };
// Origins must be exact https origins; duplicates collapse case-insensitively, last entry wins.
export function normalizeTrustedOAuthClients(
entries: { origin: string; name: string }[],
): TrustedOAuthClient[] {
const byOrigin = new Map<string, TrustedOAuthClient>();
for (const entry of entries) {
const name = entry.name.trim();
if (name.length < 1 || name.length > 64) {
throw new BadRequestException(
`Invalid trusted application name for origin: ${entry.origin}`,
);
}
let parsed: URL;
try {
parsed = new URL(entry.origin);
} catch {
throw new BadRequestException(
`Invalid trusted application origin: ${entry.origin}`,
);
}
if (parsed.protocol !== 'https:' || parsed.origin !== entry.origin) {
throw new BadRequestException(
`Trusted application origin must be an exact https origin: ${entry.origin}`,
);
}
const origin = entry.origin.toLowerCase();
byOrigin.set(origin, { origin, name });
}
return Array.from(byOrigin.values());
}
export type PageEditMode = 'read' | 'edit';
export function getWorkspaceDefaultPageEditMode(
@@ -1,96 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('oauth_clients')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('redirect_uris', 'jsonb', (col) => col.notNull())
.addColumn('client_uri', 'text')
.addColumn('logo_uri', 'text')
.addColumn('grant_types', 'jsonb', (col) => col.notNull())
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('token_endpoint_auth_method', 'text', (col) => col.notNull().defaultTo('none'))
.addColumn('secret_hash', 'text')
.addColumn('is_dynamic', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.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('oauth_clients_workspace_id_idx').on('oauth_clients').column('workspace_id').execute();
await db.schema
.createTable('oauth_authorization_codes')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('code_hash', 'text', (col) => col.notNull().unique())
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('redirect_uri', 'text', (col) => col.notNull())
.addColumn('code_challenge', 'text')
.addColumn('code_challenge_method', 'text')
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('consumed_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createIndex('oauth_authorization_codes_expires_at_idx').on('oauth_authorization_codes').column('expires_at').execute();
await db.schema.createIndex('oauth_authorization_codes_consumed_at_idx').on('oauth_authorization_codes').column('consumed_at').execute();
await db.schema.createIndex('oauth_authorization_codes_client_id_idx').on('oauth_authorization_codes').column('client_id').execute();
await db.schema.createIndex('oauth_authorization_codes_user_id_idx').on('oauth_authorization_codes').column('user_id').execute();
await db.schema.createIndex('oauth_authorization_codes_workspace_id_idx').on('oauth_authorization_codes').column('workspace_id').execute();
await db.schema
.createTable('oauth_grants')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('last_used_at', 'timestamptz')
.addColumn('revoked_at', 'timestamptz')
.addUniqueConstraint('oauth_grants_user_client_unique', ['user_id', 'client_id'])
.execute();
// The user_id/client_id unique constraint cannot serve client-side FK lookups.
await db.schema.createIndex('oauth_grants_client_id_idx').on('oauth_grants').column('client_id').execute();
await db.schema
.createTable('oauth_tokens')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('grant_id', 'uuid', (col) => col.notNull().references('oauth_grants.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('access_token_jti', 'text', (col) => col.notNull())
.addColumn('refresh_token_hash', 'text', (col) => col.unique())
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('access_expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('refresh_expires_at', 'timestamptz')
.addColumn('revoked_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createIndex('oauth_tokens_grant_id_idx').on('oauth_tokens').column('grant_id').execute();
await db.schema.createIndex('oauth_tokens_access_token_jti_idx').on('oauth_tokens').columns(['workspace_id', 'access_token_jti']).execute();
// One index per branch of the cleanup sweep's OR so it can bitmap-or them.
await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
await db.schema
.alterTable('workspaces')
.addColumn('trusted_oauth_clients', 'jsonb', (col) => col.defaultTo(sql`'[]'::jsonb`))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE workspaces DROP COLUMN IF EXISTS trusted_oauth_clients`.execute(
db,
);
await db.schema.dropTable('oauth_tokens').execute();
await db.schema.dropTable('oauth_grants').execute();
await db.schema.dropTable('oauth_authorization_codes').execute();
await db.schema.dropTable('oauth_clients').execute();
}
@@ -1,8 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DB } from '@docmost/db/types/db';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
@@ -11,8 +8,6 @@ import {
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AttachmentRepo {
@@ -94,41 +89,6 @@ export class AttachmentRepo {
.execute();
}
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('attachments')
.select(this.baseFields)
.select((eb) => this.withCreator(eb))
.where('pageId', '=', pageId)
.where('type', '=', AttachmentType.File)
.where('deletedAt', 'is', null);
if (pagination.query) {
query = query.where(
sql`f_unaccent(file_name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'attachments.creatorId'),
).as('creator');
}
async findByIds(
ids: string[],
opts?: {
@@ -20,6 +20,7 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable()
export class SpaceMemberRepo {
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
return new Set(rows.map((r) => r.userId));
}
async getUserIdsWithSpaceEditAccess(
userIds: string[],
spaceId: string,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const rows = await this.db
.selectFrom('spaceMembers')
.select('userId')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('groupUsers.userId', 'in', userIds)
.where('spaceMembers.spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
)
.execute();
return new Set(rows.map((r) => r.userId));
}
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('spaceMembers')
@@ -149,6 +149,17 @@ export class SpaceRepo {
.executeTakeFirst();
}
async getSpaceSettings(
spaceId: string,
): Promise<Record<string, any> | null> {
const row = await this.db
.selectFrom('spaces')
.select('settings')
.where('id', '=', spaceId)
.executeTakeFirst();
return (row?.settings as Record<string, any> | undefined) ?? null;
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -20,7 +20,6 @@ export class WorkspaceRepo {
'hostname',
'customDomain',
'settings',
'trustedOauthClients',
'defaultRole',
'emailDomains',
'defaultSpaceId',
-62
View File
@@ -460,7 +460,6 @@ export interface Workspaces {
status: string | null;
stripeCustomerId: string | null;
trialEndAt: Timestamp | null;
trustedOauthClients: Generated<Json | null>;
updatedAt: Generated<Timestamp>;
}
@@ -637,63 +636,6 @@ export interface UserSessions {
createdAt: Generated<Timestamp>;
}
export interface OauthAuthorizationCodes {
clientId: string;
codeChallenge: string | null;
codeChallengeMethod: string | null;
codeHash: string;
consumedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
expiresAt: Timestamp;
id: Generated<string>;
redirectUri: string;
scopes: Json;
userId: string;
workspaceId: string;
}
export interface OauthClients {
clientUri: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
grantTypes: Json;
id: Generated<string>;
isDynamic: Generated<boolean>;
logoUri: string | null;
name: string;
redirectUris: Json;
scopes: Json;
secretHash: string | null;
tokenEndpointAuthMethod: Generated<string>;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface OauthGrants {
clientId: string;
createdAt: Generated<Timestamp>;
id: Generated<string>;
lastUsedAt: Timestamp | null;
revokedAt: Timestamp | null;
scopes: Json;
updatedAt: Generated<Timestamp>;
userId: string;
workspaceId: string;
}
export interface OauthTokens {
accessExpiresAt: Timestamp;
accessTokenJti: string;
createdAt: Generated<Timestamp>;
grantId: string;
id: Generated<string>;
refreshExpiresAt: Timestamp | null;
refreshTokenHash: string | null;
revokedAt: Timestamp | null;
scopes: Json;
workspaceId: string;
}
export interface DB {
aiChats: AiChats;
aiChatMessages: AiChatMessages;
@@ -735,8 +677,4 @@ export interface DB {
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
oauthAuthorizationCodes: OauthAuthorizationCodes;
oauthClients: OauthClients;
oauthGrants: OauthGrants;
oauthTokens: OauthTokens;
}
@@ -40,10 +40,6 @@ import {
Watchers,
Audit as _Audit,
Templates,
OauthClients,
OauthAuthorizationCodes,
OauthGrants,
OauthTokens,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -188,13 +184,6 @@ export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// OAuth
export type OAuthClient = Selectable<OauthClients>;
export type InsertableOAuthClient = Insertable<OauthClients>;
export type OAuthGrant = Selectable<OauthGrants>;
export type OAuthAuthorizationCode = Selectable<OauthAuthorizationCodes>;
export type OAuthToken = Selectable<OauthTokens>;
// Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
@@ -1,13 +0,0 @@
export class UnableToInitialize extends Error {
constructor(message: string) {
super(`Unable to initialize the encryption service: ${message}`);
this.name = 'UnableToInitialize';
}
}
export class UnableToDecrypt extends Error {
constructor(reason: string) {
super(`Unable to decrypt the ciphertext: ${reason}`);
this.name = 'UnableToDecrypt';
}
}
@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './encryption.service';
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
@@ -1,184 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EncryptionService } from './encryption.service';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
const buildService = (appSecret: string | undefined) => {
const env = { getAppSecret: () => appSecret } as EnvironmentService;
return new EncryptionService(env);
};
const decodeEnvelope = (encrypted: string) =>
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
iv: string;
authTag: string;
cipherText: string;
};
const encodeEnvelope = (envelope: {
iv: string;
authTag: string;
cipherText: string;
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: EnvironmentService,
useValue: { getAppSecret: () => APP_SECRET },
},
],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
});
describe('initialization', () => {
it('compiles via Nest DI', () => {
expect(service).toBeDefined();
});
it('throws UnableToInitialize when APP_SECRET is missing', () => {
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
expect(() => buildService('')).toThrow(UnableToInitialize);
});
});
describe('encrypt + decrypt round-trip', () => {
it('decrypts back to the original plaintext', () => {
const plaintext = 'hello world';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles empty string', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('handles unicode (multi-byte UTF-8)', () => {
const plaintext = 'héllo 🔐 世界';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles long plaintext (>1 block)', () => {
const plaintext = 'a'.repeat(10_000);
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
const plaintext = 'same input';
const a = service.encrypt(plaintext);
const b = service.encrypt(plaintext);
expect(a).not.toBe(b);
expect(service.decrypt(a)).toBe(plaintext);
expect(service.decrypt(b)).toBe(plaintext);
});
});
describe('cross-key isolation', () => {
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
const other = buildService('totally-different-secret-value-9876543210');
const encrypted = service.encrypt('secret');
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
});
});
describe('tamper detection', () => {
it('rejects modified ciphertext', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
tamperedCipher[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
cipherText: tamperedCipher.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedTag = Buffer.from(env.authTag, 'base64');
tamperedTag[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
authTag: tamperedTag.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedIV = Buffer.from(env.iv, 'base64');
tamperedIV[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
iv: tamperedIV.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
});
describe('malformed payloads', () => {
it('rejects non-base64 garbage', () => {
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
UnableToDecrypt,
);
});
it('rejects base64 of non-JSON', () => {
const garbage = Buffer.from('not json at all').toString('base64');
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
});
it('rejects JSON missing required fields', () => {
const partial = encodeEnvelope({
iv: Buffer.alloc(12).toString('base64'),
authTag: Buffer.alloc(16).toString('base64'),
} as never);
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
iv: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
authTag: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
});
describe('envelope format', () => {
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
});
});
});

Some files were not shown because too many files have changed in this diff Show More