feat: add GitHub, GitLab, Jira, Linear, Figma, Azure Devops integrations (#2547)

This commit is contained in:
Philip Okugbe 2026-10-16 01:26:42 +01:00 committed by GitHub
parent b81872c003
commit 6852e0427e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 15891 additions and 142 deletions

View file

@ -68,3 +68,5 @@ DEBUG_DB=false
# Log http requests # Log http requests
LOG_HTTP=false LOG_HTTP=false
# See all environment variables: https://docmost.com/docs/self-hosting/environment-variables

View file

@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.", "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Assigned to {{name}}": "Assigned to {{name}}",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace", "Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.", "Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit", "Can edit": "Can edit",
@ -22,6 +23,8 @@
"Can view": "Can view", "Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.", "Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel", "Cancel": "Cancel",
"Card": "Card",
"Cards aren't available in this position": "Cards aren't available in this position",
"Change email": "Change email", "Change email": "Change email",
"Change password": "Change password", "Change password": "Change password",
"Change photo": "Change photo", "Change photo": "Change photo",
@ -30,6 +33,8 @@
"Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.", "Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm", "Confirm": "Confirm",
"Connect to {{name}} to preview": "Connect to {{name}} to preview",
"Connected as {{label}}": "Connected as {{label}}",
"Copy as Markdown": "Copy as Markdown", "Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "Copy link": "Copy link",
"Create": "Create", "Create": "Create",
@ -41,11 +46,25 @@
"Dark": "Dark", "Dark": "Dark",
"Date": "Date", "Date": "Date",
"Delete": "Delete", "Delete": "Delete",
"Initiative": "Initiative",
"Integration link actions": "Integration link actions",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Open link": "Open link",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Uninstall {{name}}": "Uninstall {{name}}",
"Disconnect {{name}}": "Disconnect {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.": "This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.",
"Remove from page": "Remove from page", "Remove from page": "Remove from page",
"Base options": "Base options", "Base options": "Base options",
"Delete group": "Delete group", "Delete group": "Delete group",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description", "Description": "Description",
"Display as": "Display as",
"Details": "Details", "Details": "Details",
"e.g ACME": "e.g. ACME", "e.g ACME": "e.g. ACME",
"e.g ACME Inc": "e.g. ACME Inc", "e.g ACME Inc": "e.g. ACME Inc",
@ -151,6 +170,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"replies": "replies",
"reply": "reply",
"Select version": "Select version", "Select version": "Select version",
"Highlight changes": "Highlight changes", "Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@ -193,6 +214,8 @@
"Invitation sent": "Invitation sent", "Invitation sent": "Invitation sent",
"Settings": "Settings", "Settings": "Settings",
"Setup workspace": "Setup workspace", "Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In", "Sign In": "Sign In",
"Sign Up": "Sign Up", "Sign Up": "Sign Up",
"Slug": "Slug", "Slug": "Slug",
@ -216,6 +239,7 @@
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width", "Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled", "untitled": "untitled",
"Untitled": "Untitled", "Untitled": "Untitled",
"Updated successfully": "Updated successfully", "Updated successfully": "Updated successfully",
@ -649,6 +673,8 @@
"Search in all your spaces": "Search in all your spaces", "Search in all your spaces": "Search in all your spaces",
"Type": "Type", "Type": "Type",
"Enterprise": "Enterprise", "Enterprise": "Enterprise",
"Business": "Business",
"Standard": "Standard",
"Download attachment": "Download attachment", "Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains", "Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.", "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.",
@ -1457,6 +1483,11 @@
"Defaults to this instance's hostname": "Defaults to this instance's hostname", "Defaults to this instance's hostname": "Defaults to this instance's hostname",
"Delete destination": "Delete destination", "Delete destination": "Delete destination",
"Deleted SIEM destination": "Deleted SIEM destination", "Deleted SIEM destination": "Deleted SIEM destination",
"Integrations": "Integrations",
"Installed integration": "Installed integration",
"Uninstalled integration": "Uninstalled integration",
"Connected integration account": "Connected integration account",
"Disconnected integration account": "Disconnected integration account",
"Destination created": "Destination created", "Destination created": "Destination created",
"Destination deleted": "Destination deleted", "Destination deleted": "Destination deleted",
"Destination updated": "Destination updated", "Destination updated": "Destination updated",
@ -1499,9 +1530,41 @@
"Updated SIEM destination": "Updated SIEM destination", "Updated SIEM destination": "Updated SIEM destination",
"Verify TLS certificate": "Verify TLS certificate", "Verify TLS certificate": "Verify TLS certificate",
"e.g. Splunk prod": "e.g. Splunk prod", "e.g. Splunk prod": "e.g. Splunk prod",
"Manage workspace integrations.": "Manage workspace integrations.",
"No integrations available.": "No integrations available.",
"Install": "Install",
"Uninstall": "Uninstall",
"Failed to start installation": "Failed to start installation",
"Integration uninstalled successfully": "Integration uninstalled successfully",
"Failed to uninstall integration": "Failed to uninstall integration",
"Connections": "Connections",
"Manage the apps you have connected to your account.": "Manage the apps you have connected to your account.",
"Connect": "Connect",
"Connected": "Connected",
"Disconnect": "Disconnect",
"Reconnect": "Reconnect",
"Connection expired": "Connection expired",
"Integration disconnected": "Integration disconnected",
"Failed to disconnect integration": "Failed to disconnect integration",
"Failed to start OAuth connection": "Failed to start OAuth connection",
"OAuth connection failed. Please try again.": "OAuth connection failed. Please try again.",
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.": "The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.",
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.": "The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.",
"Connect your {{name}} account so links you paste show rich previews.": "Connect your {{name}} account so links you paste show rich previews.",
"Connect {{name}} to Docmost": "Connect {{name}} to Docmost",
"Link your Slack account to your Docmost account to:": "Link your Slack account to your Docmost account to:",
"Search and open Docmost pages from Slack": "Search and open Docmost pages from Slack",
"Create pages and save Slack messages to Docmost": "Create pages and save Slack messages to Docmost",
"Ask AI questions about your workspace docs": "Ask AI questions about your workspace docs",
"Get your Docmost notifications as Slack DMs": "Get your Docmost notifications as Slack DMs",
"See previews of Docmost links in Slack and Slack links in Docmost": "See previews of Docmost links in Slack and Slack links in Docmost",
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.": "You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.",
"Updated comment": "Updated comment",
"Resolved comment": "Resolved comment",
"Reopened comment": "Reopened comment",
"Something went wrong": "Something went wrong", "Something went wrong": "Something went wrong",
"This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.", "This page failed to load. Reload to try again.": "This page failed to load. Reload to try again.",
"Reload": "Reload" "Reload": "Reload",
"Tabs": "Tabs", "Tabs": "Tabs",
"Insert a multi-tab content block.": "Insert a multi-tab content block.", "Insert a multi-tab content block.": "Insert a multi-tab content block.",
"Add tab": "Add tab", "Add tab": "Add tab",

View file

@ -87,6 +87,12 @@ const AiChat = lazyWithPrefetch(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx")); const VerifyEmail = lazyWithPrefetch(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page")); const LabelPage = lazyWithPrefetch(() => import("@/pages/label/label-page"));
const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx")); const OAuthConsent = lazyWithPrefetch(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
const Integrations = lazyWithPrefetch(
() => import("@/features/integration/pages/integrations.tsx"),
);
const Connections = lazyWithPrefetch(
() => import("@/features/integration/pages/connections.tsx"),
);
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -146,6 +152,12 @@ export default function App() {
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} /> <Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} /> <Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route
path={"/integrations/slack/link"}
element={
<Navigate to="/settings/account/connections?connect=slack" replace />
}
/>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
@ -179,6 +191,7 @@ export default function App() {
path={"account/api-keys/authorized-apps"} path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />} element={<UserApiKeys />}
/> />
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} /> <Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@ -196,6 +209,7 @@ export default function App() {
element={<Navigate to="/settings/audit/siem" replace />} element={<Navigate to="/settings/audit/siem" replace />}
/> />
<Route path={"verifications"} element={<VerifiedPages />} /> <Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>

View file

@ -0,0 +1,29 @@
import { rem } from '@mantine/core';
type Props = {
size?: number | string;
};
export function AzureDevOpsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="azure-devops-a" x1="9" x2="9" y1="16.97" y2="1.03" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#0078d4" />
<stop offset="0.16" stopColor="#1380da" />
<stop offset="0.53" stopColor="#3c91e5" />
<stop offset="0.82" stopColor="#559cec" />
<stop offset="1" stopColor="#5ea0ef" />
</linearGradient>
</defs>
<path
fill="url(#azure-devops-a)"
d="M17,4v9.74l-4,3.28-6.2-2.26V17L3.29,12.41l10.23.8V4.44Zm-3.41.49L7.85,1V3.29L2.58,4.84,1,6.87v4.61l2.26,1V6.57Z"
/>
</svg>
);
}

View file

@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}

View file

@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}

View file

@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}

View file

@ -1,10 +1,17 @@
export { AirtableIcon } from "./airtable-icon.tsx"; export { AirtableIcon } from "./airtable-icon.tsx";
export { AzureDevOpsIcon } from "./azure-devops-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx"; export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx"; export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx"; export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx"; export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx"; export { SlackIcon } from "./slack-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx"; export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx"; export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx"; export { YoutubeIcon } from "./youtube-icon.tsx";

View file

@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}

View file

@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }} style={{ width: rem(size), height: rem(size) }}
> >
<path <path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" /> fill="#FFDD33"
d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg> </svg>
); );
} }

View file

@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}

View file

@ -13,6 +13,7 @@ import {
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles, IconSparkles,
IconPlug,
IconHistory, IconHistory,
IconShieldCheck, IconShieldCheck,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@ -43,6 +44,7 @@ import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
import { useInstalledIntegrations } from "@/features/integration/queries/integration-query";
type DataItem = { type DataItem = {
label: string; label: string;
@ -51,6 +53,7 @@ type DataItem = {
feature?: string; feature?: string;
role?: "admin" | "owner"; role?: "admin" | "owner";
env?: "cloud" | "selfhosted"; env?: "cloud" | "selfhosted";
requiresInstalledIntegration?: boolean;
}; };
type DataGroup = { type DataGroup = {
@ -74,6 +77,12 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
feature: Feature.API_KEYS, feature: Feature.API_KEYS,
}, },
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
requiresInstalledIntegration: true,
},
], ],
}, },
{ {
@ -125,6 +134,12 @@ const groupedData: DataGroup[] = [
role: "owner", role: "owner",
env: "selfhosted", env: "selfhosted",
}, },
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
], ],
}, },
{ {
@ -154,6 +169,8 @@ export default function SettingsSidebar() {
setActive(location.pathname); setActive(location.pathname);
}, [location.pathname]); }, [location.pathname]);
const { data: installedIntegrations } = useInstalledIntegrations();
const hasFeature = (f: string) => const hasFeature = (f: string) =>
entitlements?.features?.includes(f) ?? false; entitlements?.features?.includes(f) ?? false;
@ -162,6 +179,9 @@ export default function SettingsSidebar() {
if (item.env === "selfhosted" && isCloud()) return false; if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false; if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false; if (item.role === "owner" && !isOwner) return false;
if (item.requiresInstalledIntegration && !installedIntegrations?.length) {
return false;
}
return true; return true;
}; };

View file

@ -34,6 +34,11 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key", "api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key", "api_key.deleted": "Deleted API key",
"integration.installed": "Installed integration",
"integration.uninstalled": "Uninstalled integration",
"integration.connected": "Connected integration account",
"integration.disconnected": "Disconnected integration account",
"scim_token.created": "Created SCIM token", "scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token", "scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token", "scim_token.deleted": "Deleted SCIM token",
@ -52,6 +57,9 @@ export const auditEventLabels: Record<string, string> = {
"group.member_added": "Added group member", "group.member_added": "Added group member",
"group.member_removed": "Removed group member", "group.member_removed": "Removed group member",
"comment.updated": "Updated comment",
"comment.resolved": "Resolved comment",
"comment.reopened": "Reopened comment",
"comment.deleted": "Deleted comment", "comment.deleted": "Deleted comment",
"attachment.deleted": "Deleted attachment", "attachment.deleted": "Deleted attachment",
@ -147,6 +155,9 @@ export const eventFilterOptions: EventGroup[] = [
{ {
group: "Comment", group: "Comment",
items: [ items: [
{ value: "comment.updated", label: "Updated comment" },
{ value: "comment.resolved", label: "Resolved comment" },
{ value: "comment.reopened", label: "Reopened comment" },
{ value: "comment.deleted", label: "Deleted comment" }, { value: "comment.deleted", label: "Deleted comment" },
], ],
}, },
@ -203,6 +214,15 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" }, { value: "api_key.deleted", label: "Deleted API key" },
], ],
}, },
{
group: "Integrations",
items: [
{ value: "integration.installed", label: "Installed integration" },
{ value: "integration.uninstalled", label: "Uninstalled integration" },
{ value: "integration.connected", label: "Connected integration account" },
{ value: "integration.disconnected", label: "Disconnected integration account" },
],
},
{ {
group: "SCIM token", group: "SCIM token",
items: [ items: [

View file

@ -22,6 +22,8 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
INTEGRATIONS: 'integrations',
INTEGRATIONS_ADVANCED: 'integrations:advanced',
OAUTH: 'oauth', OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls', MCP_CONTROLS: 'mcp:controls',

View file

@ -35,6 +35,7 @@ export default function AllowedDomains() {
emailDomains: data.emailDomains, emailDomains: data.emailDomains,
}); });
setWorkspace(updatedWorkspace); setWorkspace(updatedWorkspace);
form.setValues({ emailDomains: updatedWorkspace.emailDomains });
notifications.show({ notifications.show({
message: t("Updated successfully"), message: t("Updated successfully"),

View file

@ -0,0 +1,342 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, Node, type JSONContent } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryClient } from "@/main.tsx";
import { TiptapDocument } from "@/features/editor/extensions/document";
import {
IntegrationPasteMenuExtension,
integrationPasteMenuKey,
} from "@/features/editor/extensions/integration-paste-menu";
import {
convertIntegrationDisplay,
type IntegrationDisplay,
} from "@/features/editor/components/integration-link/integration-display";
import { handlePaste } from "./editor-paste-handler";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
const PR_URL = "https://github.com/org/repo/pull/12";
const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({
type: "text",
text: value,
...(marks && { marks }),
});
const paragraph = (...content: JSONContent[]): JSONContent => ({
type: "paragraph",
...(content.length && { content }),
});
const card = (): JSONContent => ({
type: "integrationCard",
attrs: { url: PR_URL, provider: "github" },
});
const mention = (): JSONContent => ({
type: "integrationMention",
attrs: { url: PR_URL, provider: "github" },
});
// The real footnote nodes bring plugins that clash with editor-ext's CJS build in vitest.
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "footnote*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const Footnote = Node.create({
name: "footnote",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["li", 0];
},
});
let editor: Editor;
function createEditor(content: object[], cursor: number) {
editor = new Editor({
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
Footnote,
IntegrationCard,
IntegrationMention,
IntegrationPasteMenuExtension,
],
content: { type: "doc", content },
});
editor.commands.setTextSelection(cursor);
return editor;
}
function paste(text: string, html = "") {
const event = {
clipboardData: {
getData: (type: string) =>
type === "text/plain" ? text : type === "text/html" ? html : "",
files: [],
},
preventDefault: vi.fn(),
};
const handled = handlePaste(
editor,
event as unknown as ClipboardEvent,
"page-id",
);
return { handled, preventDefault: event.preventDefault };
}
function choosePasteOption(display: IntegrationDisplay) {
const { pos, joinBefore, joinAfter } = integrationPasteMenuKey.getState(
editor.state,
);
return convertIntegrationDisplay(
editor,
{ kind: "node", pos, joinBefore, joinAfter },
display,
);
}
function hasIntegrationCard() {
let found = false;
editor.state.doc.descendants((node) => {
if (node.type.name === "integrationCard") found = true;
});
return found;
}
function expectPassThrough(text = PR_URL, html = "") {
const before = editor.getJSON();
const { handled, preventDefault } = paste(text, html);
expect(handled).toBe(false);
expect(preventDefault).not.toHaveBeenCalled();
expect(hasIntegrationCard()).toBe(false);
expect(editor.getJSON()).toEqual(before);
}
describe("handlePaste integration links", () => {
beforeEach(() => {
queryClient.setQueryData(
["installed-integrations"],
[{ type: "github", unfurlHosts: ["github.com"] }],
);
});
afterEach(() => {
editor?.destroy();
queryClient.clear();
});
it("inserts a card in a paragraph when the provider is installed", () => {
createEditor([paragraph(text("Hello"))], 6);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
const pasteMenu = integrationPasteMenuKey.getState(editor.state);
expect(pasteMenu).not.toBeNull();
expect(editor.state.doc.nodeAt(pasteMenu.pos)?.type.name).toBe(
"integrationCard",
);
expect(editor.state.doc.nodeAt(pasteMenu.pos)?.attrs).toMatchObject({
url: PR_URL,
provider: "github",
});
});
it("pastes a GitHub-like path on a host GitHub does not serve as a link", () => {
createEditor([paragraph(text("Hello"))], 6);
expectPassThrough("https://gitea.example.com/team/app/pulls/7");
});
it.each(["integrationCard", "integrationMention"])(
"leaves a copied %s to ProseMirror instead of pasting its URL as a card",
(type) => {
createEditor([paragraph(text("Hello world"))], 7);
expectPassThrough(
PR_URL,
`<p data-pm-slice="1 1 []"><span data-type="${type}" data-url="${PR_URL}" data-provider="github"></span></p>`,
);
},
);
it("inserts a card for a repo root on a GitHub Enterprise host", () => {
queryClient.setQueryData(
["installed-integrations"],
[{ type: "github", unfurlHosts: ["github.acme.com"] }],
);
createEditor([paragraph(text("Hello"))], 6);
const { handled } = paste("https://github.acme.com/org/repo");
expect(handled).toBe(true);
expect(hasIntegrationCard()).toBe(true);
});
it("splits the paragraph around a card pasted mid-sentence", () => {
createEditor([paragraph(text("Hello world"))], 7);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(editor.getJSON().content).toEqual([
paragraph(text("Hello ")),
card(),
paragraph(text("world")),
]);
});
it("rejoins the sentence when a mid-sentence paste becomes a mention", () => {
createEditor([paragraph(text("Fix in today"))], 8);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in "), mention(), text(" today")),
]);
expect(editor.state.selection.from).toBe(10);
});
it("adds no second space when the rest of the sentence starts with one", () => {
createEditor([paragraph(text("Fix in today"))], 7);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in"), mention(), text(" today")),
]);
});
it("rejoins the sentence when a mid-sentence paste becomes a URL", () => {
createEditor([paragraph(text("Fix in today"))], 8);
paste(PR_URL);
expect(choosePasteOption("url")).toBe(true);
expect(editor.getJSON().content).toHaveLength(1);
expect(editor.getJSON().content?.[0]).toMatchObject(
paragraph(
text("Fix in "),
text(PR_URL, [{ type: "link", attrs: { href: PR_URL } }]),
text("today"),
),
);
});
it("keeps an end-of-paragraph paste turned into a mention in its paragraph", () => {
createEditor([paragraph(text("Fix in ")), paragraph(text("Next"))], 8);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Fix in "), mention(), text(" ")),
paragraph(text("Next")),
]);
});
it("joins a start-of-paragraph paste turned into a mention with the rest of the line", () => {
createEditor([paragraph(text("today"))], 1);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(mention(), text(" today")),
]);
});
it("keeps a paste on an empty line in its own paragraph when it becomes a mention", () => {
createEditor(
[paragraph(text("Before")), paragraph(), paragraph(text("After"))],
9,
);
paste(PR_URL);
expect(choosePasteOption("mention")).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Before")),
paragraph(mention(), text(" ")),
paragraph(text("After")),
]);
});
it("pastes a mention inside a footnote, which cannot hold a card", () => {
createEditor(
[
paragraph(text("Body")),
{
type: "footnotes",
content: [
{
type: "footnote",
content: [paragraph(text("See "))],
},
],
},
],
13,
);
const { handled, preventDefault } = paste(PR_URL);
expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(hasIntegrationCard()).toBe(false);
expect(editor.getJSON().content?.[1]).toMatchObject({
type: "footnotes",
content: [
{
type: "footnote",
content: [paragraph(text("See "), mention())],
},
],
});
});
it("leaves a code block paste to the default handler", () => {
createEditor(
[
{
type: "codeBlock",
content: [text("const a = 1;")],
},
],
4,
);
expectPassThrough();
});
it("leaves a heading paste to the default handler", () => {
createEditor(
[
{
type: "heading",
attrs: { level: 2 },
content: [text("Title")],
},
],
3,
);
expectPassThrough();
});
it("leaves an inline code paste to the default handler", () => {
createEditor([paragraph(text("run "), text("cmd", [{ type: "code" }]))], 6);
expect(editor.isActive("code")).toBe(true);
expectPassThrough();
});
});

View file

@ -5,6 +5,11 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { canPlaceIntegrationCard } from "@/features/editor/components/integration-link/integration-display";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import { import {
getAttachmentInfo, getAttachmentInfo,
uploadFile, uploadFile,
@ -22,6 +27,24 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//; const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// copied cards and mentions paste back as nodes
const INTEGRATION_NODE_HTML_RE = /data-type="integration(Card|Mention)"/;
// Reads the cache the page editor prefetches; a cold cache pastes an ordinary link.
function matchInstalledIntegrationLink(url: string) {
const installed =
queryClient.getQueryData<Integration[]>(["installed-integrations"]) ?? [];
const unfurlHosts = Object.fromEntries(
installed
.filter((integration) => integration.unfurlHosts)
.map((integration) => [integration.type, integration.unfurlHosts]),
);
const match = matchIntegrationLink(url, unfurlHosts);
return match && installed.some((i) => i.type === match.provider)
? match
: null;
}
export const handlePaste = ( export const handlePaste = (
editor: Editor, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
@ -30,6 +53,84 @@ export const handlePaste = (
) => { ) => {
const clipboardData = event.clipboardData.getData("text/plain"); const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchInstalledIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
!INTEGRATION_NODE_HTML_RE.test(event.clipboardData.getData("text/html")) &&
editor.state.selection.empty &&
editor.state.selection.$from.parent.type.name === "paragraph" &&
!editor.isActive("code")
) {
const pastedUrl = clipboardData.trim();
const { $from } = editor.state.selection;
if (canPlaceIntegrationCard(editor, $from.pos)) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationCard({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Set the meta in this transaction (BubbleMenu ignores meta-only ones) and find the node by changed range, not url.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start =
start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationCard") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, {
pos: pastedPos,
joinBefore: $from.parentOffset > 0,
joinAfter: $from.parentOffset < $from.parent.content.size,
});
}
return true;
})
.run();
return true;
}
// Containers such as footnotes only hold paragraphs, but a mention still fits.
const mentionType = editor.schema.nodes.integrationMention;
if (
mentionType &&
$from.parent.canReplaceWith($from.index(), $from.index(), mentionType)
) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationMention({
url: pastedUrl,
provider: integrationMatch.provider,
})
.run();
return true;
}
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) { if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();

View file

@ -0,0 +1,50 @@
// Light-scheme badge text; hexes replace shade 9 for hues that miss AA 4.5:1.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
// Only palette names with a contrast-checked text colour pass through.
return Object.prototype.hasOwnProperty.call(BADGE_TEXT_LIGHT, raw)
? raw
: "gray";
}

View file

@ -0,0 +1,27 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}

View file

@ -0,0 +1,586 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
Button,
} from "@mantine/core";
import { useCallback, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-card-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const meta = unfurlData.metadata ?? {};
const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null;
const text: string = unfurlData.description ?? "";
const isLong =
text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES;
const footer = [
meta.replyCount
? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}`
: null,
unfurlData.status,
meta.teamName,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
data-integration-action="open"
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon("jira", 28)}
</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
onSelect,
}: {
url: string;
unfurlData: Record<string, any>;
onSelect: React.MouseEventHandler<HTMLElement>;
}) {
const { t } = useTranslation();
// Figma thumbnail urls are pre-signed and expire.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={onSelect}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationCardView(props: any) {
const { editor, getPos, node } = props;
const { url, provider } = node.attrs;
const safeUrl = isHttpUrl(url) ? url : "";
const { t } = useTranslation();
const unfurl = useUnfurl(safeUrl);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const selectForEditing = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!editor.isEditable || event.metaKey || event.ctrlKey) return;
const target = event.target as Element;
if (
target.closest(
"button, [role='button'], [data-integration-action='open']",
)
) {
return;
}
const pos = getPos();
if (typeof pos !== "number") return;
event.preventDefault();
event.stopPropagation();
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setNodeSelection(pos)
.run();
},
[editor, getPos],
);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// Without unfurl data, describe the link from its url.
const described = describeIntegrationLink(safeUrl);
if (described) {
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{described.title}
</Text>
{described.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{described.description}
</Text>
)}
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card className={classes.card} withBorder padding="sm" radius="sm">
{safeUrl ? (
<Anchor href={safeUrl} target="_blank" rel="noopener" size="sm">
{safeUrl}
</Anchor>
) : (
<Text size="sm">{url}</Text>
)}
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// A message unfurl without metadata.type is identified by metadata.ts.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (
slackMeta?.type === "message" ||
(slackMeta && !slackMeta.type && slackMeta.ts)
) {
return (
<SlackMessageCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return (
<JiraIssueCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
if (provider === "figma") {
return (
<FigmaFileCard
url={safeUrl}
unfurlData={unfurlData}
onSelect={selectForEditing}
/>
);
}
return (
<NodeViewWrapper data-drag-handle="" onClickCapture={selectForEditing}>
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={safeUrl}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationCardView);

View file

@ -0,0 +1,191 @@
.picker {
display: inline-flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 6px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3));
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 120ms ease,
background-color 120ms ease;
}
.option svg {
width: 18px;
height: 18px;
}
.option:hover:not([aria-disabled="true"]):not([aria-checked="true"]) {
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-5)
);
}
.option[aria-checked="true"] {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent)
);
}
.option:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.option[aria-disabled="true"] {
opacity: 0.42;
cursor: not-allowed;
}
.menuTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 1px;
width: 40px;
min-width: 40px;
height: 32px;
padding: 0 4px;
border-radius: 6px;
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 55%, transparent)
);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
color 120ms ease,
background-color 120ms ease;
}
.menuTrigger:hover {
background: light-dark(
var(--mantine-color-blue-1),
color-mix(in srgb, var(--mantine-color-blue-8) 70%, transparent)
);
}
.menuTrigger:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.menuChevron {
margin-inline-end: -2px;
}
.menuDropdown {
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
border-radius: 9px;
}
.menuLabel {
padding: 5px 9px 4px;
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
}
.menuItem {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
width: 100%;
min-height: 36px;
padding: 6px 9px;
border-radius: 6px;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-gray-2));
font-size: var(--mantine-font-size-sm);
text-align: start;
cursor: pointer;
}
.menuItem:hover:not(:disabled) {
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-5)
);
}
.menuItem[aria-checked="true"] {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3));
background: light-dark(
var(--mantine-color-blue-0),
color-mix(in srgb, var(--mantine-color-blue-9) 45%, transparent)
);
}
.menuItem:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.menuItem:disabled {
opacity: 0.44;
cursor: not-allowed;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.option,
.menuTrigger {
transition: none;
}
}
@media (pointer: coarse) {
.option {
width: 44px;
min-width: 44px;
height: 44px;
border-radius: 8px;
}
.option svg {
width: 21px;
height: 21px;
}
.menuTrigger {
width: 50px;
min-width: 50px;
height: 44px;
border-radius: 8px;
}
.menuItem {
min-height: 44px;
}
}

View file

@ -0,0 +1,188 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
IntegrationDisplayMenu,
IntegrationDisplayPicker,
} from "./integration-display-picker";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderPicker(
props: Partial<React.ComponentProps<typeof IntegrationDisplayPicker>> = {},
) {
const onChange = vi.fn();
render(
<MantineProvider>
<IntegrationDisplayPicker
current="card"
label="Display as"
canUseCard
onChange={onChange}
{...props}
/>
</MantineProvider>,
);
return { onChange };
}
function renderMenu(
props: Partial<React.ComponentProps<typeof IntegrationDisplayMenu>> = {},
) {
const onChange = vi.fn();
render(
<MantineProvider>
<IntegrationDisplayMenu
current="url"
label="Display as"
canUseCard
onChange={onChange}
{...props}
/>
</MantineProvider>,
);
return { onChange };
}
describe("IntegrationDisplayPicker", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("exposes every display mode directly and identifies the selected mode", () => {
renderPicker({ current: "mention" });
expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy();
const card = screen.getByRole("radio", { name: "Card" });
const mention = screen.getByRole("radio", { name: "Mention" });
const url = screen.getByRole("radio", { name: "URL" });
expect(card.getAttribute("aria-checked")).toBe("false");
expect(card.getAttribute("tabindex")).toBe("-1");
expect(mention.getAttribute("aria-checked")).toBe("true");
expect(mention.getAttribute("tabindex")).toBe("0");
expect(url.getAttribute("aria-checked")).toBe("false");
});
it("changes mode with standard radio-group arrow navigation", () => {
const { onChange } = renderPicker({ current: "card" });
const card = screen.getByRole("radio", { name: "Card" });
card.focus();
fireEvent.keyDown(card, { key: "ArrowRight" });
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: "Mention" }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("keeps an unavailable card understandable and skips it during navigation", () => {
const { onChange } = renderPicker({
current: "url",
canUseCard: false,
});
const card = screen.getByRole("radio", { name: "Card" });
expect(card.getAttribute("aria-disabled")).toBe("true");
const reasonId = card.getAttribute("aria-describedby");
expect(reasonId).toBeTruthy();
expect(document.getElementById(reasonId!)?.textContent).toBe(
"Cards aren't available in this position",
);
fireEvent.click(card);
expect(onChange).not.toHaveBeenCalled();
const url = screen.getByRole("radio", { name: "URL" });
url.focus();
fireEvent.keyDown(url, { key: "ArrowRight" });
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: "Mention" }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("reports activation of the current mode so the paste nudge can close", () => {
const { onChange } = renderPicker({ current: "card" });
fireEvent.click(screen.getByRole("radio", { name: "Card" }));
expect(onChange).toHaveBeenCalledWith("card");
});
});
describe("IntegrationDisplayMenu", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("identifies the current display and changes it from the dropdown", async () => {
const { onChange } = renderMenu();
const trigger = screen.getByRole("button", { name: "Display as: URL" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(
(
await screen.findByRole("menuitemradio", {
name: "URL",
hidden: true,
})
).getAttribute("aria-checked"),
).toBe("true");
fireEvent.click(
screen.getByRole("menuitemradio", { name: "Mention", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith("mention");
});
it("supports keyboard opening, option navigation, and Escape", async () => {
renderMenu({ canUseCard: false });
const trigger = screen.getByRole("button", { name: "Display as: URL" });
trigger.focus();
fireEvent.keyDown(trigger, { key: "ArrowDown" });
const url = await screen.findByRole("menuitemradio", {
name: "URL",
hidden: true,
});
await waitFor(() => expect(document.activeElement).toBe(url));
fireEvent.keyDown(url, { key: "ArrowDown" });
expect(document.activeElement).toBe(
screen.getByRole("menuitemradio", { name: "Mention", hidden: true }),
);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
});

View file

@ -0,0 +1,366 @@
import { Menu, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconAt,
IconCheck,
IconChevronDown,
IconLink,
type IconProps,
} from "@tabler/icons-react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import type { IntegrationDisplay } from "./integration-display";
import classes from "./integration-display-picker.module.css";
export type IntegrationDisplayPickerProps = {
current: IntegrationDisplay;
label: string;
canUseCard: boolean;
onChange: (display: IntegrationDisplay) => void;
};
const DISPLAY_ORDER: IntegrationDisplay[] = ["card", "mention", "url"];
const DISPLAY_ICONS = {
card: CardDisplayIcon,
mention: IconAt,
url: IconLink,
} as const;
function CardDisplayIcon({ size = 18, stroke = 1.75, ...props }: IconProps) {
return (
<svg
{...props}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<line x1="7" y1="10" x2="17" y2="10" />
<line x1="7" y1="14" x2="13" y2="14" />
</svg>
);
}
function useDisplayLabels() {
const { t } = useTranslation();
return {
labels: {
card: t("Card"),
mention: t("Mention"),
url: t("URL"),
} satisfies Record<IntegrationDisplay, string>,
getCardUnavailableLabel: () => t("Cards aren't available in this position"),
};
}
export function IntegrationDisplayPicker({
current,
label,
canUseCard,
onChange,
}: IntegrationDisplayPickerProps) {
const { labels, getCardUnavailableLabel } = useDisplayLabels();
const cardReasonId = useId();
const optionRefs = useRef<
Record<IntegrationDisplay, HTMLButtonElement | null>
>({
card: null,
mention: null,
url: null,
});
const cardUnavailableLabel = getCardUnavailableLabel();
const isUnavailable = (value: IntegrationDisplay) =>
value === "card" && !canUseCard;
const choose = (value: IntegrationDisplay) => {
if (isUnavailable(value)) return;
onChange(value);
};
const handleKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
value: IntegrationDisplay,
) => {
if (
![
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
].includes(event.key)
) {
return;
}
event.preventDefault();
event.stopPropagation();
const available = DISPLAY_ORDER.filter(
(display) => !isUnavailable(display),
);
const currentIndex = available.indexOf(value);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? available.length - 1
: ["ArrowRight", "ArrowDown"].includes(event.key)
? (currentIndex + 1 + available.length) % available.length
: (currentIndex - 1 + available.length) % available.length;
const next = available[nextIndex];
if (!next) return;
optionRefs.current[next]?.focus();
onChange(next);
};
return (
<div
className={classes.picker}
role="radiogroup"
aria-label={label}
data-integration-display-picker=""
>
{DISPLAY_ORDER.map((value) => {
const Icon = DISPLAY_ICONS[value];
const selected = value === current;
const unavailable = isUnavailable(value);
const tooltipLabel = unavailable ? cardUnavailableLabel : labels[value];
return (
<Tooltip
key={value}
label={tooltipLabel}
withArrow
withinPortal={false}
openDelay={350}
>
<UnstyledButton
ref={(element) => {
optionRefs.current[value] = element;
}}
type="button"
role="radio"
aria-label={labels[value]}
aria-checked={selected}
aria-disabled={unavailable || undefined}
aria-describedby={unavailable ? cardReasonId : undefined}
tabIndex={selected ? 0 : -1}
className={classes.option}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
choose(value);
}}
onKeyDown={(event) => handleKeyDown(event, value)}
>
<Icon size={18} stroke={1.75} aria-hidden="true" />
</UnstyledButton>
</Tooltip>
);
})}
{!canUseCard ? (
<span id={cardReasonId} className={classes.visuallyHidden}>
{cardUnavailableLabel}
</span>
) : null}
</div>
);
}
export function IntegrationDisplayMenu({
current,
label,
canUseCard,
onChange,
}: IntegrationDisplayPickerProps) {
const { labels, getCardUnavailableLabel } = useDisplayLabels();
const [opened, setOpened] = useState(false);
const cardReasonId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<
Record<IntegrationDisplay, HTMLButtonElement | null>
>({
card: null,
mention: null,
url: null,
});
const CurrentIcon = DISPLAY_ICONS[current];
const cardUnavailableLabel = getCardUnavailableLabel();
const availableOptions = DISPLAY_ORDER.filter(
(value) => value !== "card" || canUseCard,
);
const initialOption = availableOptions.includes(current)
? current
: availableOptions[0];
useEffect(() => {
if (!opened) return;
let focusFrame = 0;
const layoutFrame = requestAnimationFrame(() => {
focusFrame = requestAnimationFrame(() => {
if (initialOption) optionRefs.current[initialOption]?.focus();
});
});
return () => {
cancelAnimationFrame(layoutFrame);
cancelAnimationFrame(focusFrame);
};
}, [initialOption, opened]);
const openFromKeyboard = () => {
setOpened(true);
};
const closeAndRestoreFocus = () => {
setOpened(false);
triggerRef.current?.focus();
};
const handleOptionKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
value: IntegrationDisplay,
) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeAndRestoreFocus();
return;
}
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
const currentIndex = availableOptions.indexOf(value);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? availableOptions.length - 1
: event.key === "ArrowDown"
? (currentIndex + 1 + availableOptions.length) %
availableOptions.length
: (currentIndex - 1 + availableOptions.length) %
availableOptions.length;
const next = availableOptions[nextIndex];
if (next) optionRefs.current[next]?.focus();
};
return (
<Menu
opened={opened}
onChange={setOpened}
position="bottom-end"
offset={6}
width={190}
shadow="md"
withinPortal
>
<Menu.Target>
<UnstyledButton
ref={triggerRef}
type="button"
className={classes.menuTrigger}
aria-label={`${label}: ${labels[current]}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
if (
!opened &&
["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)
) {
event.preventDefault();
event.stopPropagation();
openFromKeyboard();
}
}}
>
<CurrentIcon size={18} stroke={1.75} aria-hidden="true" />
<IconChevronDown
className={classes.menuChevron}
size={13}
stroke={2}
aria-hidden="true"
/>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
aria-label={label}
p={4}
className={classes.menuDropdown}
data-integration-display-menu-dropdown=""
onMouseDown={(event) => event.stopPropagation()}
>
<div className={classes.menuLabel}>{label}</div>
{DISPLAY_ORDER.map((value) => {
const Icon = DISPLAY_ICONS[value];
const selected = value === current;
const unavailable = value === "card" && !canUseCard;
return (
<UnstyledButton
key={value}
ref={(element) => {
optionRefs.current[value] = element;
}}
type="button"
role="menuitemradio"
aria-checked={selected}
aria-describedby={unavailable ? cardReasonId : undefined}
disabled={unavailable}
className={classes.menuItem}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
setOpened(false);
onChange(value);
}}
onKeyDown={(event) => handleOptionKeyDown(event, value)}
>
<Icon size={18} stroke={1.75} aria-hidden="true" />
<span>{labels[value]}</span>
{selected ? (
<IconCheck size={16} stroke={2} aria-hidden="true" />
) : (
<span aria-hidden="true" />
)}
</UnstyledButton>
);
})}
{!canUseCard ? (
<span id={cardReasonId} className={classes.visuallyHidden}>
{cardUnavailableLabel}
</span>
) : null}
</Menu.Dropdown>
</Menu>
);
}

View file

@ -0,0 +1,290 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, type JSONContent } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, describe, expect, it } from "vitest";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
getSelectedIntegrationDisplay,
} from "./integration-display";
const url = "https://github.com/docmost/docmost/pull/2475";
const text = (value: string, marks?: JSONContent["marks"]): JSONContent => ({
type: "text",
text: value,
...(marks && { marks }),
});
const paragraph = (...content: JSONContent[]): JSONContent => ({
type: "paragraph",
...(content.length && { content }),
});
const card = (): JSONContent => ({
type: "integrationCard",
attrs: { url, provider: "github" },
});
const mention = (): JSONContent => ({
type: "integrationMention",
attrs: { url, provider: "github" },
});
const IntegrationAwareLink = TiptapLink.extend({
addAttributes() {
return {
...this.parent?.(),
integrationProvider: { default: null },
};
},
});
let activeEditor: Editor;
afterEach(() => activeEditor?.destroy());
function createEditor(...content: JSONContent[]) {
activeEditor = new Editor({
extensions: [
StarterKit.configure({ link: false }),
IntegrationAwareLink,
IntegrationCard,
IntegrationMention,
],
content: { type: "doc", content },
});
return activeEditor;
}
describe("convertIntegrationDisplay", () => {
it("turns a card into an inline mention without losing its source", () => {
const editor = createEditor(card());
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "mention"),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [paragraph(mention(), text(" "))],
});
});
it("turns a card into a normal linked URL", () => {
const editor = createEditor(card());
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 0 }, "url"),
).toBe(true);
expect(editor.getJSON().content?.[0]).toMatchObject(
paragraph(
text(url, [
{
type: "link",
attrs: { href: url, integrationProvider: "github" },
},
]),
),
);
});
it("keeps the paragraphs around a card apart when it becomes a mention", () => {
const editor = createEditor(
paragraph(text("Intro")),
card(),
paragraph(text("Outro")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 7 }, "mention"),
).toBe(true);
expect(editor.getJSON().content).toEqual([
paragraph(text("Intro")),
paragraph(mention(), text(" ")),
paragraph(text("Outro")),
]);
});
it("turns a standalone mention back into a card and ignores surrounding whitespace", () => {
const editor = createEditor(paragraph(text(" "), mention(), text(" ")));
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 2 }, "card"),
).toBe(true);
expect(editor.getJSON().content?.[0]).toEqual(card());
});
it("splits inline prose around a mention converted to a card", () => {
const editor = createEditor(
paragraph(text("Review "), mention(), text(" before release.")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "card"),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [
paragraph(text("Review")),
card(),
paragraph(text("before release.")),
],
});
});
it("turns a mention inside prose into a URL without disturbing the sentence", () => {
const editor = createEditor(
paragraph(text("Review "), mention(), text(" before release.")),
);
expect(
convertIntegrationDisplay(editor, { kind: "node", pos: 8 }, "url"),
).toBe(true);
expect(editor.getText()).toBe(`Review ${url} before release.`);
expect(editor.getJSON().content?.[0]?.content?.[1]).toMatchObject(
text(url, [
{
type: "link",
attrs: { href: url, integrationProvider: "github" },
},
]),
);
});
it("turns an existing linked URL into a mention in place", () => {
const from = 8;
const editor = createEditor(
paragraph(
text("Review "),
text(url, [{ type: "link", attrs: { href: url } }]),
text(" before release."),
),
);
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from, to: from + url.length, url, provider: "github" },
"mention",
),
).toBe(true);
expect(editor.getJSON().content?.[0]?.content?.[1]).toEqual(mention());
expect(editor.getText()).toBe(`Review ${url} before release.`);
});
it("converts an entire link when inline formatting splits it into text nodes", () => {
const splitAt = 19;
const editor = createEditor(
paragraph(
text(url.slice(0, splitAt), [{ type: "link", attrs: { href: url } }]),
text(url.slice(splitAt), [
{ type: "link", attrs: { href: url } },
{ type: "bold" },
]),
),
);
expect(
convertIntegrationDisplay(
editor,
{
kind: "link",
from: 1,
to: 1 + url.length,
url,
provider: "github",
},
"mention",
),
).toBe(true);
expect(editor.getJSON().content?.[0]?.content).toEqual([mention()]);
});
it("turns a standalone linked URL into a card", () => {
const editor = createEditor(
paragraph(text(url, [{ type: "link", attrs: { href: url } }]), text(" ")),
);
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from: 1, to: 1 + url.length, url, provider: "github" },
"card",
),
).toBe(true);
expect(editor.getJSON().content?.[0]).toEqual(card());
});
it("splits a paragraph around a linked URL converted to a card", () => {
const before = "Review ";
const after = " before release.";
const editor = createEditor(
paragraph(
text(before),
text(url, [{ type: "link", attrs: { href: url } }]),
text(after),
),
);
const from = 1 + before.length;
expect(
convertIntegrationDisplay(
editor,
{ kind: "link", from, to: from + url.length, url, provider: "github" },
"card",
),
).toBe(true);
expect(editor.getJSON()).toEqual({
type: "doc",
content: [
paragraph(text("Review")),
card(),
paragraph(text("before release.")),
],
});
});
it("reports whether an inline source can safely become a card", () => {
const editor = createEditor(
paragraph(text("Review "), mention()),
paragraph(mention()),
);
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 8 }),
).toEqual({ canUseCard: true });
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 11 }),
).toEqual({ canUseCard: true });
});
it("distinguishes unsupported containers from inline prose", () => {
const editor = createEditor({
type: "bulletList",
content: [
{
type: "listItem",
content: [paragraph(mention())],
},
],
});
expect(
getIntegrationCardAvailability(editor, { kind: "node", pos: 3 }),
).toEqual({ canUseCard: false });
});
it("resolves the selected rich integration to its current display", () => {
const editor = createEditor(card(), paragraph(mention()));
editor.commands.setNodeSelection(0);
expect(getSelectedIntegrationDisplay(editor)).toEqual({
current: "card",
source: { kind: "node", pos: 0 },
url,
});
editor.commands.setNodeSelection(2);
expect(getSelectedIntegrationDisplay(editor)).toEqual({
current: "mention",
source: { kind: "node", pos: 2 },
url,
});
});
});

View file

@ -0,0 +1,309 @@
import { getMarkRange, type Editor } from "@tiptap/core";
import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
export type IntegrationDisplay = "card" | "mention" | "url";
export type IntegrationCardAvailability = { canUseCard: boolean };
export type IntegrationDisplaySource =
| {
kind: "node";
pos: number;
// Set when a paste split a paragraph around the node.
joinBefore?: boolean;
joinAfter?: boolean;
}
| {
kind: "link";
from: number;
to: number;
url: string;
provider: string;
};
export type SelectedIntegrationDisplay = {
current: Exclude<IntegrationDisplay, "url">;
source: Extract<IntegrationDisplaySource, { kind: "node" }>;
url: string;
};
export function getSelectedIntegrationDisplay(
editor: Editor,
): SelectedIntegrationDisplay | null {
const { selection } = editor.state;
if (!(selection instanceof NodeSelection)) return null;
const current =
selection.node.type.name === "integrationCard"
? "card"
: selection.node.type.name === "integrationMention"
? "mention"
: null;
if (!current || typeof selection.node.attrs.url !== "string") return null;
return {
current,
source: { kind: "node", pos: selection.from },
url: selection.node.attrs.url,
};
}
function getCardPlacement(
editor: Editor,
from: number,
to: number,
attrs: Record<string, unknown>,
):
| {
canPlace: true;
range: { from: number; to: number };
content: Fragment;
}
| {
canPlace: false;
range: null;
content: null;
} {
const $from = editor.state.doc.resolve(from);
if ($from.parent.type.name !== "paragraph") {
return { canPlace: false, range: null, content: null };
}
const parentStart = $from.start();
const parentEnd = parentStart + $from.parent.content.size;
if (from < parentStart || to > parentEnd || from > to) {
return { canPlace: false, range: null, content: null };
}
const depth = $from.depth;
const parentContainer = $from.node(depth - 1);
const parentIndex = $from.index(depth - 1);
const cardType = editor.state.schema.nodes.integrationCard;
if (!cardType) {
return { canPlace: false, range: null, content: null };
}
const before = trimInlineBoundary(
$from.parent.content.cut(0, from - parentStart),
"end",
);
const after = trimInlineBoundary(
$from.parent.content.cut(to - parentStart),
"start",
);
const replacement: ProseMirrorNode[] = [];
if (before.size) replacement.push($from.parent.copy(before));
replacement.push(cardType.create(attrs));
if (after.size) replacement.push($from.parent.copy(after));
const content = Fragment.fromArray(replacement);
if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) {
return { canPlace: false, range: null, content: null };
}
return {
canPlace: true,
range: { from: $from.before(depth), to: $from.after(depth) },
content,
};
}
function trimInlineBoundary(
fragment: Fragment,
boundary: "start" | "end",
): Fragment {
const children: ProseMirrorNode[] = [];
fragment.forEach((child) => children.push(child));
while (children.length) {
const index = boundary === "start" ? 0 : children.length - 1;
const child = children[index];
if (!child?.isText) break;
const text =
boundary === "start"
? child.text?.replace(/^\s+/, "")
: child.text?.replace(/\s+$/, "");
if (text) {
children[index] = child.type.schema.text(text, child.marks);
break;
}
children.splice(index, 1);
}
return Fragment.fromArray(children);
}
function replaceWithCard(
editor: Editor,
placement: Extract<ReturnType<typeof getCardPlacement>, { canPlace: true }>,
): boolean {
return editor.commands.command(({ tr }) => {
tr.replaceWith(placement.range.from, placement.range.to, placement.content);
return true;
});
}
export function getIntegrationCardAvailability(
editor: Editor,
source: IntegrationDisplaySource,
): IntegrationCardAvailability {
if (source.kind === "link") {
const placement = getCardPlacement(editor, source.from, source.to, {
url: source.url,
provider: source.provider,
});
return { canUseCard: placement.canPlace };
}
const node = editor.state.doc.nodeAt(source.pos);
if (node?.type.name === "integrationCard") {
return { canUseCard: true };
}
if (node?.type.name !== "integrationMention") {
return { canUseCard: false };
}
const placement = getCardPlacement(
editor,
source.pos,
source.pos + node.nodeSize,
{ ...node.attrs },
);
return { canUseCard: placement.canPlace };
}
export function canPlaceIntegrationCard(editor: Editor, pos: number): boolean {
return getCardPlacement(editor, pos, pos, {}).canPlace;
}
export function convertIntegrationDisplay(
editor: Editor,
source: IntegrationDisplaySource,
target: IntegrationDisplay,
): boolean {
if (source.kind === "link") {
const linkType = editor.state.schema.marks.link;
const sourceRange = linkType
? getMarkRange(editor.state.doc.resolve(source.from), linkType, {
href: source.url,
})
: null;
if (
!sourceRange ||
sourceRange.from !== source.from ||
sourceRange.to !== source.to
) {
return false;
}
if (target === "card") {
const paragraphRange = getCardPlacement(editor, source.from, source.to, {
url: source.url,
provider: source.provider,
});
if (!paragraphRange.canPlace) return false;
return replaceWithCard(editor, paragraphRange);
}
if (target !== "mention") return false;
return editor.commands.insertContentAt(
{ from: source.from, to: source.to },
{
type: "integrationMention",
attrs: { url: source.url, provider: source.provider },
},
);
}
const node = editor.state.doc.nodeAt(source.pos);
if (!node) return false;
if (node.type.name === "integrationMention" && target === "card") {
const paragraphRange = getCardPlacement(
editor,
source.pos,
source.pos + node.nodeSize,
{ ...node.attrs },
);
if (!paragraphRange.canPlace) return false;
return replaceWithCard(editor, paragraphRange);
}
if (node.type.name === "integrationMention" && target === "url") {
return editor.commands.insertContentAt(
{ from: source.pos, to: source.pos + node.nodeSize },
{
type: "text",
text: node.attrs.url,
marks: [
{
type: "link",
attrs: {
href: node.attrs.url,
integrationProvider: node.attrs.provider,
},
},
],
},
);
}
if (node.type.name !== "integrationCard") return false;
const $card = editor.state.doc.resolve(source.pos);
const before = source.joinBefore ? $card.nodeBefore : null;
const after = source.joinAfter
? $card.parent.maybeChild($card.index() + 1)
: null;
const joinBefore = before?.type.name === "paragraph";
const joinAfter = after?.type.name === "paragraph";
const spaceFollows = joinAfter && /^\s/.test(after?.textContent ?? "");
const content =
target === "mention"
? [
{ type: "integrationMention", attrs: { ...node.attrs } },
...(spaceFollows ? [] : [{ type: "text", text: " " }]),
]
: target === "url"
? [
{
type: "text",
text: node.attrs.url,
marks: [
{
type: "link",
attrs: {
href: node.attrs.url,
integrationProvider: node.attrs.provider,
},
},
],
},
]
: null;
if (!content) return false;
return editor
.chain()
.insertContentAt(
{ from: source.pos, to: source.pos + node.nodeSize },
{
type: "paragraph",
attrs: (joinBefore ? before : joinAfter ? after : null)?.attrs,
content,
},
)
.command(({ tr }) => {
const paragraph = tr.doc.nodeAt(source.pos);
if (paragraph?.type.name !== "paragraph") return false;
if (joinAfter) tr.join(source.pos + paragraph.nodeSize);
if (joinBefore) tr.join(source.pos);
return true;
})
.run();
}

View file

@ -0,0 +1,345 @@
import {
describeIntegrationLink,
matchIntegrationLink,
} from "@docmost/editor-ext";
import { describe, expect, it } from "vitest";
const AZURE = "https://dev.azure.com/contoso";
const LEGACY_AZURE = "https://contoso.visualstudio.com";
const JIRA_HOSTS = { jira: ["jira.acme.com"] };
// Exercise matching through the public API; regex order and counts are implementation details.
describe("integration link matching", () => {
it.each([
["https://github.com/docmost/docmost", "github", "github-repo"],
["https://gitlab.com/docmost/docmost", "gitlab", "gitlab-project"],
["https://code.example.com/o/r/pull/1", "github", "github-pr"],
["https://code.example.com/o/r/-/merge_requests/1", "gitlab", "gitlab-mr"],
])("identifies %s as %s", (url, provider, type) => {
expect(matchIntegrationLink(url)).toMatchObject({ provider, type });
});
it.each([
"https://example.com/foo/bar",
"https://github.com/o/r/-/merge_requests/1",
"https://gitlab.com/o/r/pull/1",
])("does not misclassify %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("restricts GitHub links to the configured Enterprise host", () => {
const hosts = { github: ["github.acme.com"] };
expect(
matchIntegrationLink("https://github.acme.com/o/r", hosts),
).toMatchObject({ provider: "github", type: "github-repo" });
expect(
matchIntegrationLink("https://github.acme.com/o/r/pull/1", hosts),
).toMatchObject({ provider: "github", type: "github-pr" });
expect(
matchIntegrationLink("https://github.com/o/r/pull/1", hosts),
).toBeNull();
expect(
matchIntegrationLink("https://gitea.example.com/o/r/pull/1", hosts),
).toBeNull();
});
it("recognizes a self-managed GitLab project root", () => {
expect(
matchIntegrationLink("https://gitlab.acme.com/g/p", {
gitlab: ["gitlab.acme.com"],
}),
).toMatchObject({ provider: "gitlab", type: "gitlab-project" });
});
it("matches Jira wildcard subdomains without accepting lookalike hosts", () => {
const hosts = { jira: ["*.atlassian.net", "*.jira.com"] };
expect(
matchIntegrationLink("https://acme.atlassian.net/browse/AB-1", hosts),
).toMatchObject({ provider: "jira", type: "jira-issue" });
for (const host of [
"atlassian.net",
"evilatlassian.net",
"jira.acme.com",
]) {
expect(
matchIntegrationLink(`https://${host}/browse/AB-1`, hosts),
).toBeNull();
}
});
it("keeps default matching for providers without a configured host", () => {
expect(
matchIntegrationLink("https://code.example.com/o/r/pull/1", JIRA_HOSTS),
).toMatchObject({ provider: "github", type: "github-pr" });
expect(
matchIntegrationLink("https://acme.slack.com/archives/C123", {}),
).toMatchObject({ provider: "slack", type: "slack-channel" });
});
it.each([
"https://github.com/o/r/pull/1\n",
"https://github.com/o/r/pulls?" + "a".repeat(9000),
])("rejects whitespace or oversized input: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([null, undefined])("rejects a link mark without an href: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([
["https://github.com/o/r/pulls?q=is%3Aopen", "github-pulls-list"],
["https://github.com/o/r/pulls/extra", "github-pulls-list"],
["https://github.com/o/r/releases/tag/v1.0.0", "github-releases-list"],
])("recognizes the collection at %s", (url, type) => {
expect(matchIntegrationLink(url)?.type).toBe(type);
});
it("does not treat a longer segment as a collection", () => {
expect(matchIntegrationLink("https://github.com/o/r/pullsx")).toBeNull();
});
});
describe("Azure DevOps links", () => {
it.each([
[
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`,
"azure-devops-pr",
["contoso", "Fabrikam", "web-app", "318"],
],
[
`${AZURE}/Fabrikam/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${AZURE}/Fabrikam/_boards/board/?workitem=318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/Fabrikam/_git/web-app/pullrequest/318`,
"azure-devops-pr",
["contoso", "Fabrikam", "web-app", "318"],
],
[
`${LEGACY_AZURE}/DefaultCollection/Fabrikam/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/DefaultCollection/Fabrikam/_boards/board/?workitem=318`,
"azure-devops-work-item",
["contoso", "Fabrikam", "318"],
],
[
`${AZURE}/_git/Fabrikam/pullrequest/318`,
"azure-devops-pr",
["contoso", undefined, "Fabrikam", "318"],
],
[
`${LEGACY_AZURE}/_workitems/edit/318`,
"azure-devops-work-item",
["contoso", undefined, "318"],
],
] as const)(
"captures routing fields from %s and rejects invalid IDs",
(url, type, groups) => {
const result = matchIntegrationLink(url);
expect(result).toMatchObject({ provider: "azure_devops", type });
expect(result?.match.slice(1)).toEqual(groups);
expect(matchIntegrationLink(`${url}x`)).toBeNull();
expect(matchIntegrationLink(`${url}00000000`)).toBeNull();
},
);
it("matches Azure links when other providers report their hosts", () => {
expect(
matchIntegrationLink(`${AZURE}/Fabrikam/_git/web-app/pullrequest/318`, {
github: ["github.com"],
jira: ["*.atlassian.net"],
}),
).toMatchObject({ provider: "azure_devops", type: "azure-devops-pr" });
expect(
matchIntegrationLink(
"https://dev.azure.com/browse/AB-1/_workitems/edit/5",
),
).toMatchObject({
provider: "azure_devops",
type: "azure-devops-work-item",
});
});
it.each([
"http://dev.azure.com/contoso/Fabrikam/_workitems/edit/1",
"http://contoso.visualstudio.com/Fabrikam/_workitems/edit/1",
"https://dev.azure.com.evil.com/contoso/Fabrikam/_workitems/edit/1",
"https://contoso.visualstudio.com.evil.com/Fabrikam/_workitems/edit/1",
"https://dev.azure.com/con_toso/Fabrikam/_workitems/edit/1",
"https://dev.azure.com/con%2Ftoso/Fabrikam/_workitems/edit/1",
`${AZURE}/_Fabrikam/_workitems/edit/1`,
`${AZURE}/Fabrikam/_git/web-app?workitem=42`,
`${AZURE}/Fabrikam/_boardsx/board/?workitem=42`,
`${AZURE}/Fabrikam/_boards/board/?xworkitem=42`,
`${LEGACY_AZURE}/Fabrikam/boards/board/?workitem=42`,
])("rejects an unsupported or misleading URL: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("reads work items from board, backlog and sprint hubs on both hosts", () => {
for (const host of [AZURE, LEGACY_AZURE]) {
for (const hub of [
"_boards/board",
"_backlogs/backlog",
"_sprints/taskboard",
]) {
const url = `${host}/Fabrikam/${hub}/Team?showParents=true&workitem=42`;
expect(matchIntegrationLink(url)?.match.slice(1), url).toEqual([
"contoso",
"Fabrikam",
"42",
]);
}
}
});
it.each([
[
`${AZURE}/Fabrikam%20Fiber/_workitems/edit/1234`,
"Work Item #1234",
"contoso/Fabrikam Fiber",
],
[
`${AZURE}/Fabrikam/_git/web%20app/pullrequest/318`,
"Pull Request #318",
"contoso/Fabrikam/web app",
],
[
`${AZURE}/_git/Fabrikam/pullrequest/9`,
"Pull Request #9",
"contoso/Fabrikam",
],
[`${AZURE}/_workitems/edit/7`, "Work Item #7", "contoso"],
])("describes %s without a connection", (url, title, description) => {
expect(describeIntegrationLink(url)).toEqual({
provider: "azure_devops",
title,
description,
});
});
it.each([
["Fab%0Arikam", "Fab%0Arikam"],
["Fab%E2%80%AErikam", "Fab%E2%80%AErikam"],
["Fab%E2%80%A8rikam", "Fab%E2%80%A8rikam"],
["Fab%ZZ", "Fab%ZZ"],
["Fab\u202erikam", "Fab%E2%80%AErikam"],
["a%2Fb", "a%2Fb"],
["Caf%C3%A9", "Café"],
])("keeps decoded project names safe: %s", (project, expected) => {
expect(
describeIntegrationLink(`${AZURE}/${project}/_workitems/edit/1`)
?.description,
).toBe(`contoso/${expected}`);
});
});
describe("URL path boundaries", () => {
// Representative providers and failure classes instead of every route permutation.
it.each([
"https://github.com/a/b/pull/1/../../../../evil/x/pull/2",
"https://github.com/a/b/pull/1/%2e%2e/evil",
"https://github.com/a/b/pull/1\\..\\evil",
"https://gitlab.com/a/b/../../evil/x/-/issues/1",
"https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2",
"https://acme.atlassian.net/browse/AB-1/%2e%2e/browse/EVIL-2",
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318/../../evil`,
`${LEGACY_AZURE}/Fabrikam/_workitems/edit/1\\..\\evil`,
])("rejects paths the browser resolves elsewhere: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it.each([
["https://github.com/o/.github/pull/1", "github-pr"],
["https://github.com/o/my..repo/issues/2", "github-issue"],
[
"https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3",
"github-file",
],
["https://github.com/o/r/pulls?q=../../x", "github-pulls-list"],
["https://linear.app/acme/issue/ABC-1#comment-1/../..", "linear-issue"],
["https://acme.atlassian.net/browse/AB-1?atlOrigin=x/../..", "jira-issue"],
[
`${AZURE}/Fabrikam/_git/web-app/pullrequest/318?_a=files&path=/../x`,
"azure-devops-pr",
],
])("allows dots in names, query strings and fragments: %s", (url, type) => {
expect(matchIntegrationLink(url)?.type).toBe(type);
});
it("applies the same path guard on an Enterprise host", () => {
expect(
matchIntegrationLink("https://github.acme.com/a/b/pull/1/../../evil", {
github: ["github.acme.com"],
}),
).toBeNull();
});
it.each([
"https://github.com/o/r?x=/pull/1",
"https://github.com/o/r#x/pull/1",
"https://github.com/o?x=/r/issues/1",
])("does not read a route from an owner or repository query: %s", (url) => {
expect(matchIntegrationLink(url)).toBeNull();
});
it("captures ordinary dots, dashes and underscores in repository names", () => {
expect(
matchIntegrationLink(
"https://github.com/my-org_1/repo.name-x/pull/1",
)?.match.slice(1),
).toEqual(["my-org_1", "repo.name-x", "1"]);
});
});
describe("Jira Data Center context paths", () => {
it.each([
"https://jira.acme.com/jira/browse/AB-1",
"https://jira.acme.com/tools/jira/browse/AB-1",
"https://jira.acme.com/jira/secure/RapidBoard.jspa?rapidView=1&selectedIssue=AB-1",
])("reads the issue under an installed context path: %s", (url) => {
const result = matchIntegrationLink(url, JIRA_HOSTS);
expect(result).toMatchObject({ provider: "jira", type: "jira-issue" });
expect(result?.match.slice(1)).toEqual(["AB-1"]);
expect(matchIntegrationLink(url)).toBeNull();
expect(matchIntegrationLink(url, { jira: ["jira.other.com"] })).toBeNull();
});
it("takes the first issue key after the context path", () => {
expect(
matchIntegrationLink(
"https://jira.acme.com/jira/browse/AB-1/browse/CD-2",
JIRA_HOSTS,
)?.match[1],
).toBe("AB-1");
});
it("handles an uppercase Cloud host when matching a selected board issue", () => {
expect(
matchIntegrationLink(
"https://ACME.Atlassian.net/jira/software/projects/AB/boards/1?selectedIssue=AB-12",
{
jira: ["*.atlassian.net"],
},
),
).toMatchObject({ provider: "jira", type: "jira-issue" });
});
it.each([
"https://jira.acme.com/jira/../browse/AB-1",
"https://jira.acme.com/jira/%2e%2e/browse/AB-1",
"https://jira.acme.com/jira\\..\\browse/AB-1",
])("rejects context paths the browser resolves elsewhere: %s", (url) => {
expect(matchIntegrationLink(url, JIRA_HOSTS)).toBeNull();
});
});

View file

@ -0,0 +1,122 @@
import { IntegrationCard, IntegrationMention } from "@docmost/editor-ext";
import { Editor, generateText } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, describe, expect, it } from "vitest";
const PR_URL = "https://github.com/o/r/pull/1";
const SCRIPT_URLS = [" JaVaScRiPt:alert(1)", "java\tscript:alert(1)"];
const ENCODED_URL = "https://github.com/o/r/issues?q=label%3A%22a%2520b%22";
let editor: Editor;
function createEditor(content: string | Record<string, unknown> = "") {
editor = new Editor({
extensions: [StarterKit, IntegrationCard, IntegrationMention],
content,
});
return editor;
}
function findAttrs(typeName: string) {
const found: Record<string, unknown>[] = [];
editor.state.doc.descendants((node) => {
if (node.type.name === typeName) found.push(node.attrs);
});
return found[0];
}
afterEach(() => editor?.destroy());
describe.each([
{ typeName: "integrationCard", tag: "div", command: "setIntegrationCard" },
{
typeName: "integrationMention",
tag: "span",
command: "setIntegrationMention",
},
] as const)("$typeName url attr", ({ typeName, tag, command }) => {
it.each(SCRIPT_URLS)("stores an empty url when inserting %j", (url) => {
createEditor();
editor.commands[command]({ url, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: "" });
});
it("preserves percent encoding when inserting", () => {
createEditor();
editor.commands[command]({ url: ENCODED_URL, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL });
});
it("trims whitespace around an inserted url", () => {
createEditor();
editor.commands[command]({ url: ` ${PR_URL}\n`, provider: "github" });
expect(findAttrs(typeName)).toMatchObject({ url: PR_URL });
});
it("preserves percent encoding through HTML", () => {
createEditor(
`<p>x</p><${tag} data-type="${typeName}" data-url="${ENCODED_URL}"></${tag}>`,
);
expect(findAttrs(typeName)).toMatchObject({ url: ENCODED_URL });
expect(editor.getHTML()).toContain(`href="${ENCODED_URL}"`);
});
it("drops a non-http data-url when parsing HTML", () => {
createEditor(
`<p>x</p><${tag} data-type="${typeName}" data-url="file:///x" data-provider="github"></${tag}>`,
);
expect(findAttrs(typeName)).toMatchObject({ url: "", provider: "github" });
});
it("renders no unsafe href for JSON content that bypassed parsing", () => {
const node = { type: typeName, attrs: { url: "file:///etc/passwd" } };
createEditor({
type: "doc",
content: [
typeName === "integrationCard"
? node
: { type: "paragraph", content: [node] },
],
});
const html = editor.getHTML();
expect(html).not.toContain("file:");
expect(html).toContain('href=""');
});
});
describe("integration node plain text", () => {
it("renders each node as its url", () => {
const issueUrl = "https://github.com/o/r/issues/2";
const text = generateText(
{
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "See " },
{
type: "integrationMention",
attrs: { url: PR_URL, provider: "github" },
},
{ type: "text", text: " today" },
],
},
{
type: "integrationCard",
attrs: { url: issueUrl, provider: "github" },
},
],
},
[StarterKit, IntegrationCard, IntegrationMention],
);
expect(text).toBe(`See ${PR_URL} today\n\n${issueUrl}`);
});
});

View file

@ -0,0 +1,53 @@
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: baseline;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
/* Text runs share the paragraph baseline; icons and badges center on the line. */
.mentionMeta,
.mentionText {
align-self: baseline;
}
.mentionMeta {
color: var(--mantine-color-dimmed);
white-space: nowrap;
}
.mentionText {
text-decoration: underline;
text-decoration-thickness: 0.07em;
text-decoration-color: light-dark(
var(--mantine-color-dark-0),
var(--mantine-color-dark-2)
);
text-underline-offset: 0.2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}

View file

@ -0,0 +1,259 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Avatar,
Badge,
Button,
Group,
HoverCard,
Stack,
Text,
} from "@mantine/core";
import { memo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink, isHttpUrl } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-mention-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { editor, getPos, node } = props;
const { url, provider } = node.attrs;
const safeUrl = isHttpUrl(url) ? url : "";
const { t } = useTranslation();
const unfurl = useUnfurl(safeUrl);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const [connecting, setConnecting] = useState(false);
const selectForEditing = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (!editor.isEditable || event.metaKey || event.ctrlKey) return;
const pos = getPos();
if (typeof pos !== "number") return;
event.preventDefault();
event.stopPropagation();
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setNodeSelection(pos)
.run();
},
[editor, getPos],
);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
const meta = data?.metadata ?? {};
const isSlackMessage =
provider === "slack" &&
(meta.type === "message" || (!meta.type && meta.ts));
const issueNumber = meta.iid ?? meta.number;
const typeLabel =
meta.type === "project"
? t("Project")
: meta.type === "initiative"
? t("Initiative")
: null;
const statusBadge = data?.status ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// Without unfurl data, describe the link from its url.
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{needsConnection?.title ??
describeIntegrationLink(safeUrl)?.title ??
(safeUrl ? shortUrl(safeUrl) : url)}
</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<span className={classes.mentionMeta}>{data.author}</span>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira issue
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<span className={classes.mentionMeta}>{meta.issueKey}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionMeta}>#{issueNumber}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionMeta}>{typeLabel}</span>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{data.title || shortUrl(url)}
</span>
{statusBadge}
</>
);
}
const anchor = (
<a
href={safeUrl || undefined}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
onClick={selectForEditing}
>
{content}
</a>
);
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
{needsConnection ? (
<HoverCard
position="bottom-start"
shadow="md"
radius="md"
openDelay={150}
closeDelay={150}
>
<HoverCard.Target>{anchor}</HoverCard.Target>
<HoverCard.Dropdown p="sm">
<Stack gap="xs">
<Group gap={6} wrap="nowrap">
{getIntegrationIcon(provider, 16)}
<Text size="sm" fw={500}>
{needsConnection.integrationName}
</Text>
</Group>
<Button
size="xs"
variant="filled"
color="dark"
leftSection={getIntegrationIcon(provider, 16)}
loading={connecting}
onClick={handleConnect}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
anchor
)}
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);

View file

@ -0,0 +1,71 @@
.toolbar {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 4px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: 10px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2));
background: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36));
}
.divider {
width: 1px;
height: 22px;
margin: 0 3px;
flex: 0 0 1px;
background: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
.actionButton {
--ai-size: 32px !important;
flex: 0 0 32px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-2));
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.actionButton svg {
width: 18px;
height: 18px;
}
.actionButton:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
.pasteOption {
min-height: 30px;
}
@media (pointer: coarse) {
.pasteOption {
min-height: 44px;
}
.actionButton {
--ai-size: 44px !important;
flex-basis: 44px;
}
.actionButton svg {
width: 21px;
height: 21px;
}
.divider {
height: 26px;
margin-inline: 4px;
}
}

View file

@ -0,0 +1,213 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IntegrationPasteMenu } from "./integration-paste-menu";
const mocks = vi.hoisted(() => ({
pasteState: null as {
pos: number;
joinBefore?: boolean;
joinAfter?: boolean;
} | null,
selectedDisplay: null as Record<string, unknown> | null,
convertDisplay: vi.fn(),
hasFocus: true,
blurHandler: null as ((props: { event: FocusEvent }) => void) | null,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("@tiptap/react", () => ({
posToDOMRect: vi.fn(),
useEditorState: ({ editor, selector }: Record<string, any>) =>
selector({ editor }),
}));
vi.mock("@tiptap/react/menus", () => ({
BubbleMenu: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/features/editor/extensions/integration-paste-menu", () => ({
integrationPasteMenuKey: {
getState: () => mocks.pasteState,
},
}));
vi.mock("./integration-display", () => ({
convertIntegrationDisplay: mocks.convertDisplay,
getIntegrationCardAvailability: () => ({ canUseCard: true }),
getSelectedIntegrationDisplay: () => mocks.selectedDisplay,
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderMenu(nodeType = "integrationCard") {
const editor = {
state: {
doc: {
nodeAt: () => ({
type: { name: nodeType },
attrs: {
url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576",
},
nodeSize: 1,
}),
},
tr: { setMeta: vi.fn() },
},
view: {
dispatch: vi.fn(),
nodeDOM: vi.fn(),
hasFocus: () => mocks.hasFocus,
},
commands: { focus: vi.fn() },
isDestroyed: false,
on: (name: string, handler: (props: { event: FocusEvent }) => void) => {
if (name === "blur") mocks.blurHandler = handler;
},
off: vi.fn(),
};
render(
<MantineProvider>
<IntegrationPasteMenu
editor={
editor as unknown as ComponentProps<
typeof IntegrationPasteMenu
>["editor"]
}
/>
</MantineProvider>,
);
return editor;
}
describe("IntegrationPasteMenu", () => {
beforeEach(() => {
setMediaMatch();
mocks.pasteState = null;
mocks.selectedDisplay = null;
mocks.convertDisplay.mockReset();
mocks.hasFocus = true;
mocks.blurHandler = null;
});
afterEach(cleanup);
it("shows the original vertical Paste as chooser only for a fresh paste", () => {
mocks.pasteState = { pos: 4 };
renderMenu();
const chooser = screen.getByRole("listbox", { name: "Paste as" });
expect(chooser).toBeTruthy();
expect(chooser.getAttribute("aria-orientation")).toBe("vertical");
expect(screen.getByRole("option", { name: "Card" })).toBeTruthy();
expect(screen.getByRole("option", { name: "Mention" })).toBeTruthy();
expect(screen.getByRole("option", { name: "URL" })).toBeTruthy();
expect(
screen.queryByRole("toolbar", { name: "Integration link actions" }),
).toBeNull();
});
it("keeps the horizontal display toolbar for an existing node", () => {
mocks.selectedDisplay = {
current: "mention",
source: { kind: "node", pos: 4 },
url: "https://gitlab.com/gitlab-org/gitlab/-/issues/609576",
};
renderMenu("integrationMention");
expect(
screen.getByRole("toolbar", { name: "Integration link actions" }),
).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "Display as" })).toBeTruthy();
expect(screen.queryByRole("listbox", { name: "Paste as" })).toBeNull();
});
it("handles Paste as keys while the editor has focus", () => {
mocks.pasteState = { pos: 4 };
renderMenu();
fireEvent.keyDown(window, { key: "ArrowDown" });
fireEvent.keyDown(window, { key: "Enter" });
expect(mocks.convertDisplay).toHaveBeenCalledWith(
expect.anything(),
{ kind: "node", pos: 4 },
"mention",
);
});
it("hands the paste split to the conversion so the sentence can rejoin", () => {
mocks.pasteState = { pos: 4, joinBefore: true, joinAfter: false };
renderMenu();
fireEvent.click(screen.getByRole("option", { name: "URL" }));
expect(mocks.convertDisplay).toHaveBeenCalledWith(
expect.anything(),
{ kind: "node", pos: 4, joinBefore: true, joinAfter: false },
"url",
);
});
it("leaves keys alone when focus is outside the editor and the prompt", () => {
mocks.pasteState = { pos: 4 };
mocks.hasFocus = false;
renderMenu();
const enter = new KeyboardEvent("keydown", {
key: "Enter",
cancelable: true,
});
window.dispatchEvent(enter);
expect(enter.defaultPrevented).toBe(false);
expect(mocks.convertDisplay).not.toHaveBeenCalled();
});
it("dismisses the prompt when the editor loses focus elsewhere", () => {
mocks.pasteState = { pos: 4 };
const editor = renderMenu();
mocks.blurHandler?.({
event: new FocusEvent("blur", { relatedTarget: document.body }),
});
expect(editor.state.tr.setMeta).toHaveBeenCalledWith(expect.anything(), null);
expect(editor.view.dispatch).toHaveBeenCalled();
});
it("keeps the prompt when focus moves into it", () => {
mocks.pasteState = { pos: 4 };
const editor = renderMenu();
mocks.blurHandler?.({
event: new FocusEvent("blur", {
relatedTarget: screen.getByRole("option", { name: "Card" }),
}),
});
expect(editor.view.dispatch).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,335 @@
import {
ActionIcon,
Button,
Paper,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconCopy, IconExternalLink } from "@tabler/icons-react";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { copyToClipboard } from "@docmost/editor-ext";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
getSelectedIntegrationDisplay,
type IntegrationDisplay,
type IntegrationDisplaySource,
} from "./integration-display";
import { IntegrationDisplayPicker } from "./integration-display-picker";
import classes from "./integration-paste-menu.module.css";
const PASTE_OPTIONS: { value: IntegrationDisplay; label: string }[] = [
{ value: "card", label: "Card" },
{ value: "mention", label: "Mention" },
{ value: "url", label: "URL" },
];
type DisplayTarget = {
current: Exclude<IntegrationDisplay, "url">;
source: Extract<IntegrationDisplaySource, { kind: "node" }>;
url: string;
isPaste: boolean;
};
function getDisplayTarget(
editor: EditorMenuProps["editor"],
): DisplayTarget | null {
const pasteState = integrationPasteMenuKey.getState(editor.state);
if (pasteState) {
const node = editor.state.doc.nodeAt(pasteState.pos);
const current =
node?.type.name === "integrationCard"
? "card"
: node?.type.name === "integrationMention"
? "mention"
: null;
if (current && typeof node?.attrs.url === "string") {
return {
current,
source: {
kind: "node",
pos: pasteState.pos,
joinBefore: pasteState.joinBefore,
joinAfter: pasteState.joinAfter,
},
url: node.attrs.url,
isPaste: true,
};
}
}
const selected = getSelectedIntegrationDisplay(editor);
return selected ? { ...selected, isPaste: false } : null;
}
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [selectedPasteIndex, setSelectedPasteIndex] = useState(0);
const pasteOptionIdPrefix = useId();
const promptRef = useRef<HTMLDivElement>(null);
const target = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
currentEditor ? getDisplayTarget(currentEditor) : null,
});
const findTarget = useCallback(() => getDisplayTarget(editor), [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const currentTarget = findTarget();
if (!currentTarget) return undefined;
const { pos } = currentTarget.source;
const node = editor.state.doc.nodeAt(pos);
if (!node) return undefined;
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(editor.view, pos, pos + node.nodeSize);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismissPastePrompt = useCallback(() => {
if (!integrationPasteMenuKey.getState(editor.state)) return;
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
requestAnimationFrame(() => {
const activeElement = document.activeElement;
if (
!activeElement ||
activeElement === document.body ||
!activeElement.isConnected
) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
});
}, [editor]);
const changeDisplay = useCallback(
(display: IntegrationDisplay) => {
const currentTarget = findTarget();
if (!currentTarget) return;
dismissPastePrompt();
if (display === currentTarget.current) {
editor.commands.focus(undefined, { scrollIntoView: false });
return;
}
if (convertIntegrationDisplay(editor, currentTarget.source, display)) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
},
[dismissPastePrompt, editor, findTarget],
);
useEffect(() => {
if (target?.isPaste) setSelectedPasteIndex(0);
}, [target?.isPaste, target?.source.pos]);
// Focus stays in the editor, so capture keys before ProseMirror moves past the atom node.
useEffect(() => {
if (!target?.isPaste) return;
const ownsFocus = () =>
editor.view.hasFocus() ||
Boolean(promptRef.current?.contains(document.activeElement));
const onKeyDown = (event: KeyboardEvent) => {
if (!ownsFocus()) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dismissPastePrompt();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const delta = event.key === "ArrowDown" ? 1 : -1;
setSelectedPasteIndex(
(current) =>
(current + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length,
);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const option = PASTE_OPTIONS[selectedPasteIndex];
if (option) changeDisplay(option.value);
}
};
// Leaving the editor dismisses the prompt, unless focus moved into the prompt itself.
const onBlur = ({ event }: { event: FocusEvent }) => {
const next = event.relatedTarget;
if (next instanceof Node && promptRef.current?.contains(next)) return;
if (editor.isDestroyed) return;
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
};
window.addEventListener("keydown", onKeyDown, true);
editor.on("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
editor.off("blur", onBlur);
};
}, [
changeDisplay,
dismissPastePrompt,
editor,
selectedPasteIndex,
target?.isPaste,
]);
const copyLink = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const currentTarget = findTarget();
if (!currentTarget) return;
copyToClipboard(currentTarget.url);
notifications.show({ message: t("Link copied") });
},
[findTarget, t],
);
const cardAvailability = target
? getIntegrationCardAvailability(editor, target.source)
: null;
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-display-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement:
target?.isPaste || target?.current !== "card"
? "bottom-start"
: "top-end",
offset: 10,
flip: true,
shift: true,
}}
shouldShow={shouldShow}
>
{target?.isPaste ? (
<Paper
ref={promptRef}
shadow="md"
radius="md"
withBorder
p={4}
miw={140}
role="listbox"
aria-label={t("Paste as")}
aria-orientation="vertical"
aria-activedescendant={`${pasteOptionIdPrefix}-${PASTE_OPTIONS[selectedPasteIndex].value}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{t(PASTE_OPTIONS[selectedPasteIndex].label)}
</VisuallyHidden>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
{PASTE_OPTIONS.map((option, index) => (
<Button
key={option.value}
id={`${pasteOptionIdPrefix}-${option.value}`}
role="option"
aria-selected={index === selectedPasteIndex}
variant={index === selectedPasteIndex ? "light" : "subtle"}
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
className={classes.pasteOption}
onMouseEnter={() => setSelectedPasteIndex(index)}
onFocus={() => setSelectedPasteIndex(index)}
onClick={() => changeDisplay(option.value)}
>
{t(option.label)}
</Button>
))}
</Stack>
</Paper>
) : target ? (
<div
className={classes.toolbar}
role="toolbar"
aria-label={t("Integration link actions")}
>
<IntegrationDisplayPicker
key={`${target.source.pos}-selected`}
current={target.current}
label={t("Display as")}
canUseCard={cardAvailability?.canUseCard ?? false}
onChange={changeDisplay}
/>
<span className={classes.divider} aria-hidden="true" />
<Tooltip label={t("Open link")} withArrow withinPortal={false}>
<ActionIcon
component="a"
href={target.url}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size={32}
className={classes.actionButton}
aria-label={t("Open link")}
>
<IconExternalLink size={18} stroke={1.75} aria-hidden="true" />
</ActionIcon>
</Tooltip>
{target.current === "mention" ? (
<Tooltip label={t("Copy link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
size={32}
className={classes.actionButton}
aria-label={t("Copy link")}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={copyLink}
>
<IconCopy size={18} stroke={1.75} aria-hidden="true" />
</ActionIcon>
</Tooltip>
) : null}
</div>
) : null}
</BaseBubbleMenu>
);
}

View file

@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";
import { useUnfurl } from "./use-unfurl";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
vi.mock("@/features/integration/services/integration-service", () => ({
unfurlUrl: vi.fn(),
}));
const mockedUnfurlUrl = vi.mocked(unfurlUrl);
const ISSUE_URL = "https://github.com/acme/repo/issues/42";
const loadedResult: UnfurlResult = {
title: "Fix race condition in file watcher",
url: ISSUE_URL,
provider: "github",
status: "open",
};
const needsConnectionResult: UnfurlNeedsConnection = {
needsConnection: true,
integrationId: "int-1",
integrationType: "github",
integrationName: "GitHub",
title: "GitHub link",
description: "github.com/acme/repo/issues/42",
};
function createWrapper(loggedIn: boolean) {
const store = createStore();
if (loggedIn) {
store.set(currentUserAtom, { user: { id: "user-1" } } as any);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
describe("useUnfurl", () => {
beforeEach(() => {
localStorage.clear();
vi.resetAllMocks();
});
it("never fetches for anonymous viewers and reports anonymous", () => {
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(false),
});
expect(result.current.state).toBe("anonymous");
expect(mockedUnfurlUrl).not.toHaveBeenCalled();
});
it("starts loading then exposes the unfurl result", async () => {
mockedUnfurlUrl.mockResolvedValue(loadedResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
expect(result.current.state).toBe("loading");
await waitFor(() => expect(result.current.state).toBe("loaded"));
expect(
result.current.state === "loaded" && result.current.data,
).toEqual(loadedResult);
expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL });
});
it("maps a needsConnection response without treating it as an error", async () => {
mockedUnfurlUrl.mockResolvedValue(needsConnectionResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() =>
expect(result.current.state).toBe("needsConnection"),
);
expect(
result.current.state === "needsConnection" &&
result.current.needsConnection,
).toEqual(needsConnectionResult);
});
it("maps a null result (no matching provider) to error", async () => {
mockedUnfurlUrl.mockResolvedValue(null);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
it("maps a rejected request to error", async () => {
mockedUnfurlUrl.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
});

View file

@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Fetched per viewer and never written into the doc, so each viewer's third-party access applies.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}

View file

@ -0,0 +1,225 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LinkView from "./link-view";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({ pathname: "/s/example/p/page" }),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
const installedIntegrations = vi.hoisted(() => [
{ type: "github", unfurlHosts: ["github.com"] },
]);
vi.mock("@/features/integration/queries/integration-query", () => ({
useInstalledIntegrations: () => ({ data: installedIntegrations }),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/public-space/queries/public-space-query.ts", () => ({
usePublicSpacePageQuery: () => ({ data: undefined }),
}));
vi.mock("@/features/editor/components/link/link-editor-panel.tsx", () => ({
LinkEditorPanel: () => null,
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderDetachedLinkView(
attrs: Record<string, unknown> = {
href: "https://github.com/docmost/docmost/pull/2475",
integrationProvider: "github",
internal: false,
},
) {
const props = {
editor: {
isEditable: true,
state: {
doc: {
content: { size: 100 },
resolve: (position: number) => {
if (position < 0) {
throw new RangeError(`Position ${position} out of range`);
}
return {};
},
},
},
view: { posAtDOM: () => -1 },
},
mark: {
attrs,
type: { name: "link" },
},
};
const renderView = () => (
<MantineProvider>
<LinkView {...(props as unknown as ComponentProps<typeof LinkView>)} />
</MantineProvider>
);
return { ...render(renderView()), renderView };
}
describe("LinkView", () => {
beforeEach(setMediaMatch);
afterEach(cleanup);
it("does not resolve the mark position while the popover is closed", () => {
const { rerender, renderView } = renderDetachedLinkView();
expect(() => rerender(renderView())).not.toThrow();
});
it("does not resolve a negative mark-view position after opening", () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(() => fireEvent.click(link!)).not.toThrow();
});
it("opens URL-current display choices from the existing link action row", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
const popoverTarget = container.querySelector('[aria-haspopup="dialog"]');
expect(link).not.toBeNull();
expect(popoverTarget).not.toBeNull();
fireEvent.click(link!);
const menu = await screen.findByRole("dialog", { hidden: true });
const menuQueries = within(menu);
const display = menuQueries.getByRole("button", {
name: "Display as: URL",
hidden: true,
});
fireEvent.click(display);
const displayMenu = await screen.findByRole("menu", { hidden: true });
expect(
within(displayMenu)
.getByRole("menuitemradio", { name: "URL", hidden: true })
.getAttribute("aria-checked"),
).toBe("true");
fireEvent.mouseDown(
within(displayMenu).getByRole("menuitemradio", {
name: "Mention",
hidden: true,
}),
);
expect(popoverTarget?.getAttribute("aria-expanded")).toBe("true");
});
it("offers display choices for a plain link on a host the provider serves", async () => {
const { container } = renderDetachedLinkView({
href: "https://github.com/docmost/docmost/pull/2475",
internal: false,
});
fireEvent.click(container.querySelector("a")!);
const menu = await screen.findByRole("dialog", { hidden: true });
expect(
within(menu).getByRole("button", {
name: "Display as: URL",
hidden: true,
}),
).toBeTruthy();
});
it("hides display choices for a GitHub-like path on a host GitHub does not serve", async () => {
const { container } = renderDetachedLinkView({
href: "https://gitea.example.com/team/app/pulls/7",
internal: false,
});
fireEvent.click(container.querySelector("a")!);
const menu = await screen.findByRole("dialog", { hidden: true });
expect(
within(menu).getByRole("button", { name: "Edit link", hidden: true }),
).toBeTruthy();
expect(
within(menu).queryByRole("button", {
name: "Display as: URL",
hidden: true,
}),
).toBeNull();
});
it("supports keyboard-generated clicks on link-menu actions", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
expect(link).not.toBeNull();
fireEvent.click(link!);
const menu = await screen.findByRole("dialog", { hidden: true });
const edit = within(menu).getByRole("button", {
name: "Edit link",
hidden: true,
});
fireEvent.click(edit);
expect(await within(menu).findByText("Page or URL")).toBeTruthy();
});
it("closes the integrated link menu when Escape is pressed on the selector", async () => {
const { container } = renderDetachedLinkView();
const link = container.querySelector("a");
const target = container.querySelector('[aria-haspopup="dialog"]');
expect(link).not.toBeNull();
expect(target).not.toBeNull();
fireEvent.click(link!);
expect(target?.getAttribute("aria-expanded")).toBe("true");
const menu = await screen.findByRole("dialog", { hidden: true });
const display = within(menu).getByRole("button", {
name: "Display as: URL",
hidden: true,
});
fireEvent.keyDown(display, { key: "Escape" });
expect(target?.getAttribute("aria-expanded")).toBe("false");
});
});

View file

@ -1,4 +1,5 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react"; import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { getMarkRange } from "@tiptap/core";
import { useNavigate, useLocation, useParams } from "react-router-dom"; import { useNavigate, useLocation, useParams } from "react-router-dom";
import { import {
IconFileDescription, IconFileDescription,
@ -8,7 +9,7 @@ import {
IconPencil, IconPencil,
IconWorld, IconWorld,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
Divider, Divider,
@ -33,8 +34,20 @@ import {
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext"; import {
matchIntegrationLink,
sanitizeUrl,
copyToClipboard,
isEditorReady,
} from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils"; import { normalizeUrl } from "@/lib/utils";
import { useInstalledIntegrations } from "@/features/integration/queries/integration-query";
import {
convertIntegrationDisplay,
getIntegrationCardAvailability,
type IntegrationDisplaySource,
} from "@/features/editor/components/integration-link/integration-display";
import { IntegrationDisplayMenu } from "@/features/editor/components/integration-link/integration-display-picker";
const parseInternalLink = ( const parseInternalLink = (
href: string, href: string,
@ -80,6 +93,31 @@ export default function LinkView(props: MarkViewProps) {
const wrapperRef = useRef<HTMLSpanElement>(null); const wrapperRef = useRef<HTMLSpanElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const isEditable = editor.isEditable; const isEditable = editor.isEditable;
const integrationMatch = useMemo(() => matchIntegrationLink(href), [href]);
const markIntegrationProvider =
typeof mark.attrs.integrationProvider === "string"
? mark.attrs.integrationProvider
: null;
const { data: installedIntegrations } = useInstalledIntegrations(
Boolean(markIntegrationProvider || integrationMatch) && isEditable,
);
// Only hosts an installed provider unfurls get "Display as".
const hostIntegrationProvider = useMemo(() => {
if (!installedIntegrations) return null;
const unfurlHosts = Object.fromEntries(
installedIntegrations
.filter((integration) => integration.unfurlHosts)
.map((integration) => [integration.type, integration.unfurlHosts]),
);
return matchIntegrationLink(href, unfurlHosts)?.provider ?? null;
}, [href, installedIntegrations]);
const detectedIntegrationProvider =
markIntegrationProvider ?? hostIntegrationProvider;
const integrationProvider = installedIntegrations?.some(
(integration) => integration.type === detectedIntegrationProvider,
)
? detectedIntegrationProvider
: null;
const { const {
isInternal, isInternal,
slugId, slugId,
@ -124,12 +162,36 @@ export default function LinkView(props: MarkViewProps) {
const getLinkPos = useCallback((): number | null => { const getLinkPos = useCallback((): number | null => {
if (!wrapperRef.current) return null; if (!wrapperRef.current) return null;
try { try {
return editor.view.posAtDOM(wrapperRef.current, 0); const pos = editor.view.posAtDOM(wrapperRef.current, 0);
return pos >= 0 && pos <= editor.state.doc.content.size ? pos : null;
} catch { } catch {
return null; return null;
} }
}, [editor]); }, [editor]);
const getIntegrationSource = useCallback((): Extract<
IntegrationDisplaySource,
{ kind: "link" }
> | null => {
if (!integrationProvider) return null;
const pos = getLinkPos();
if (pos === null) return null;
const range = getMarkRange(editor.state.doc.resolve(pos), mark.type, {
href,
});
if (!range) return null;
return {
kind: "link",
from: range.from,
to: range.to,
url: href,
provider: integrationProvider,
};
}, [editor, getLinkPos, href, integrationProvider, mark.type]);
const handleUpdateLinkTitle = useCallback( const handleUpdateLinkTitle = useCallback(
(newTitle: string) => { (newTitle: string) => {
if (!newTitle) return; if (!newTitle) return;
@ -225,7 +287,9 @@ export default function LinkView(props: MarkViewProps) {
const target = e.target as Node; const target = e.target as Node;
if ( if (
wrapperRef.current?.contains(target) || wrapperRef.current?.contains(target) ||
dropdownRef.current?.contains(target) dropdownRef.current?.contains(target) ||
(target instanceof Element &&
target.closest("[data-integration-display-menu-dropdown]"))
) { ) {
return; return;
} }
@ -233,6 +297,12 @@ export default function LinkView(props: MarkViewProps) {
}; };
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (
e.target instanceof Element &&
e.target.closest("[data-integration-display-menu-dropdown]")
) {
return;
}
setPopoverState("closed"); setPopoverState("closed");
} }
}; };
@ -366,6 +436,22 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed"); setPopoverState("closed");
}, [editor]); }, [editor]);
const handleIntegrationDisplay = useCallback(
(display: "card" | "mention" | "url") => {
const source = getIntegrationSource();
setPopoverState("closed");
if (!source || display === "url") {
editor.commands.focus(undefined, { scrollIntoView: false });
return;
}
if (convertIntegrationDisplay(editor, source, display)) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
},
[editor, getIntegrationSource],
);
const internalHref = () => { const internalHref = () => {
if (isShareRoute && slugId) { if (isShareRoute && slugId) {
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }); return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
@ -440,7 +526,13 @@ export default function LinkView(props: MarkViewProps) {
return ( return (
<Popover <Popover
opened={isPopoverVisible} opened={isPopoverVisible}
width={activeView === "edit" ? 320 : undefined} width={
activeView === "edit"
? "min(320px, calc(100vw - 24px))"
: integrationProvider
? "min(340px, calc(100vw - 24px))"
: undefined
}
position="bottom" position="bottom"
withArrow withArrow
shadow="md" shadow="md"
@ -451,6 +543,7 @@ export default function LinkView(props: MarkViewProps) {
<span <span
ref={wrapperRef} ref={wrapperRef}
className={classes.linkWrapper} className={classes.linkWrapper}
data-active={isPopoverVisible || undefined}
onClick={handleClick} onClick={handleClick}
> >
<a <a
@ -468,6 +561,11 @@ export default function LinkView(props: MarkViewProps) {
<Popover.Dropdown <Popover.Dropdown
ref={dropdownRef} ref={dropdownRef}
p={activeView === "edit" ? "sm" : 6} p={activeView === "edit" ? "sm" : 6}
className={
activeView === "preview" && integrationProvider
? classes.integrationPopover
: undefined
}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{activeView === "edit" ? ( {activeView === "edit" ? (
@ -560,7 +658,7 @@ export default function LinkView(props: MarkViewProps) {
)} )}
</> </>
) : ( ) : (
<Group gap={4} wrap="nowrap"> <Group gap={2} wrap="nowrap" className={classes.previewRow}>
<Group <Group
component="a" component="a"
//@ts-ignore //@ts-ignore
@ -569,13 +667,7 @@ export default function LinkView(props: MarkViewProps) {
rel={isInternal ? undefined : "noopener noreferrer"} rel={isInternal ? undefined : "noopener noreferrer"}
gap={6} gap={6}
wrap="nowrap" wrap="nowrap"
style={{ className={classes.previewDestination}
cursor: "pointer",
maxWidth: 250,
textDecoration: "none",
color: "inherit",
userSelect: "none",
}}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
handleNavigate(); handleNavigate();
@ -591,15 +683,43 @@ export default function LinkView(props: MarkViewProps) {
</Text> </Text>
</Group> </Group>
<Divider orientation="vertical" /> <Divider
orientation="vertical"
className={classes.previewDivider}
/>
{isPopoverVisible &&
integrationProvider &&
(() => {
const source = getIntegrationSource();
const cardAvailability = source
? getIntegrationCardAvailability(editor, source)
: null;
return (
<IntegrationDisplayMenu
current="url"
label={t("Display as")}
canUseCard={cardAvailability?.canUseCard ?? false}
onChange={handleIntegrationDisplay}
/>
);
})()}
<Tooltip label={t("Edit link")} withArrow withinPortal={false}> <Tooltip label={t("Edit link")} withArrow withinPortal={false}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Edit link")}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSearch(false); setShowSearch(false);
setPopoverState("edit"); setPopoverState("edit");
}} }}
@ -612,9 +732,16 @@ export default function LinkView(props: MarkViewProps) {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Copy link")}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy(e); handleCopy(e);
}} }}
> >
@ -626,9 +753,16 @@ export default function LinkView(props: MarkViewProps) {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size={32}
className={classes.previewAction}
aria-label={t("Remove link")}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveLink(); handleRemoveLink();
}} }}
> >

View file

@ -3,6 +3,69 @@
display: inline; display: inline;
} }
.linkWrapper[data-active="true"] > a {
border-radius: 4px;
outline: 2px solid
light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6));
outline-offset: 2px;
}
.integrationPopover {
overflow: hidden;
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
border-radius: 10px;
box-shadow: 0 5px 18px light-dark(rgba(31, 35, 40, 0.12), rgba(0, 0, 0, 0.36));
}
.previewRow {
min-width: 0;
}
.previewDestination {
min-width: 0;
max-width: min(250px, calc(100vw - 170px));
flex: 1 1 auto;
cursor: pointer;
text-decoration: none;
color: inherit;
user-select: none;
}
.previewDivider {
height: 22px;
margin: 0 3px;
}
.previewAction {
--ai-size: 32px !important;
flex: 0 0 32px;
border-radius: 6px;
-webkit-tap-highlight-color: transparent;
}
.previewAction:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: -2px;
}
@media (pointer: coarse) {
.previewDivider {
height: 26px;
margin-inline: 4px;
}
.previewAction {
--ai-size: 44px !important;
flex-basis: 44px;
border-radius: 8px;
}
}
.linkInput { .linkInput {
border: 1.5px solid border: 1.5px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));

View file

@ -57,6 +57,8 @@ import {
Indent, Indent,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
IntegrationCard,
IntegrationMention,
Columns, Columns,
Column, Column,
Status, Status,
@ -99,6 +101,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TabsView from "@/features/editor/components/tabs/tabs-view.tsx"; import TabsView from "@/features/editor/components/tabs/tabs-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
@ -396,6 +401,13 @@ export const mainExtensions = [
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
IntegrationCard.configure({
view: IntegrationCardView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({ Status.configure({
view: StatusView, view: StatusView,
}), }),

View file

@ -0,0 +1,47 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = {
pos: number;
joinBefore: boolean;
joinAfter: boolean;
} | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Position of a just-pasted integration node, where the "Paste as" menu anchors,
// and which sides of it hold the halves of the paragraph the paste split.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Follow-up transactions (unique ids, trailing node) keep the anchor.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationCard" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { ...prev, pos } : null;
}
return prev;
},
},
}),
];
},
});

View file

@ -82,6 +82,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@ -388,6 +390,15 @@ function CollabPageEditor({
[pageId, editable, extensions], [pageId, editable, extensions],
); );
useEffect(() => {
// Read by the paste handler with no observer, so it must not be garbage collected.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
gcTime: Infinity,
});
}, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (editor && !editor.isDestroyed) { if (editor && !editor.isDestroyed) {
// @ts-ignore // @ts-ignore
@ -515,6 +526,7 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div> </div>
)} )}
{editor && !editorIsEditable && (editable || canComment) && ( {editor && !editorIsEditable && (editable || canComment) && (

View file

@ -0,0 +1,91 @@
import { Group, Text, Button, Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const connectedLabel =
connection?.providerDisplayName || connection?.providerUserId;
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>
{definition.name}
</Text>
{connection?.invalidatedAt ? (
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
) : connection && connectedLabel ? (
<Text size="xs" c="dimmed" truncate>
{t("Connected as {{label}}", { label: connectedLabel })}
</Text>
) : (
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
)}
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{connection ? (
<>
{connection.invalidatedAt && (
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
)}
<Button
size="xs"
variant="default"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</Group>
</Group>
</Box>
);
}

View file

@ -0,0 +1,34 @@
import { ReactNode } from "react";
import {
AzureDevOpsIcon,
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
azure_devops: (size) => <AzureDevOpsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
// The type can come from a document; ignore inherited keys like "constructor".
if (Object.prototype.hasOwnProperty.call(integrationIconMap, type)) {
return integrationIconMap[type](size);
}
return <IconPuzzle size={size} stroke={1.5} />;
}

View file

@ -0,0 +1,66 @@
import { Box, Group, Skeleton, Stack } from "@mantine/core";
const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56];
const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260];
type IntegrationListSkeletonProps = {
rows?: number;
withBadges?: boolean;
};
export default function IntegrationListSkeleton({
rows = 7,
withBadges = true,
}: IntegrationListSkeletonProps) {
return (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}

View file

@ -0,0 +1,102 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features";
import { isCloud } from "@/lib/config.ts";
// Keep in sync with apps/server/src/ee/licence/feature-registry.ts.
const MINIMUM_PLAN: Record<string, { selfHosted: string; cloud: string }> = {
[Feature.INTEGRATIONS]: { selfHosted: "Business", cloud: "Standard" },
[Feature.INTEGRATIONS_ADVANCED]: { selfHosted: "Enterprise", cloud: "Business" },
};
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(definition.requiresFeature ?? "");
const locked = !!definition.requiresFeature && !hasAccess;
const upgradeLabel = useUpgradeLabel();
const minimumPlan = MINIMUM_PLAN[definition.requiresFeature ?? ""];
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{minimumPlan
? t(isCloud() ? minimumPlan.cloud : minimumPlan.selfHosted)
: t("Paid")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="default"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}

View file

@ -0,0 +1,271 @@
import { useEffect, useRef } from "react";
import { Text, Alert, Stack, Button, Group, List } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
import { resolveConnectPrompt } from "../utils/connect-prompt";
import type { IntegrationDefinition } from "../types/integration.types";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const [searchParams, setSearchParams] = useSearchParams();
const connectType = searchParams.get("connect");
const prompted = useRef(false);
const mounted = useRef(true);
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
// Workspace-scoped providers otherwise return to the admin integrations page.
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/account/connections",
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
useEffect(() => {
if (isLoading) return;
const definition = resolveConnectPrompt(
connectType,
available,
installed,
myConnections,
);
if (!definition) {
if (connectType) {
modals.close("connect-prompt");
setSearchParams({}, { replace: true });
} else {
prompted.current = false;
}
return;
}
if (prompted.current) return;
prompted.current = true;
modals.open({
modalId: "connect-prompt",
title: t("Connect {{name}} to Docmost", { name: definition.name }),
centered: true,
// Mantine also calls onClose on unmount, and navigating then would bounce the user back here.
onClose: () => {
if (mounted.current) setSearchParams({}, { replace: true });
},
children: (
<ConnectPromptBody
definition={definition}
onConnect={() => {
modals.close("connect-prompt");
handleConnect(definition.type);
}}
onCancel={() => modals.close("connect-prompt")}
/>
),
});
}, [connectType, available, installed, myConnections, isLoading]);
// The modal lives on the app-root provider, so it must not outlive this page.
useEffect(
() => () => {
mounted.current = false;
modals.close("connect-prompt");
},
[],
);
const handleDisconnect = (integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Disconnect {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
{ name },
)}
</Text>
),
labels: { confirm: t("Disconnect"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => disconnectMutation.mutate({ integrationId }),
});
};
// Only the row being disconnected shows a loader.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{error === "identity_mismatch" && (
<Alert color="red" mb="md">
{t(
"The account you signed in with does not use your Docmost email address. Sign in to the provider with the account that matches your Docmost email and try again.",
)}
</Alert>
)}
{error === "tenant_mismatch" && (
<Alert color="red" mb="md">
{t(
"The Slack account you signed in with is not in the Slack workspace connected to Docmost. Sign in to Slack with your account in that workspace and try again.",
)}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
type ConnectPromptBodyProps = {
definition: IntegrationDefinition;
onConnect: () => void;
onCancel: () => void;
};
function ConnectPromptBody({
definition,
onConnect,
onCancel,
}: ConnectPromptBodyProps) {
const { t } = useTranslation();
return (
<Stack gap="sm">
{definition.type === "slack" ? (
<>
<Text size="sm">
{t("Link your Slack account to your Docmost account to:")}
</Text>
<List size="sm" spacing="xs">
<List.Item>
{t("Search and open Docmost pages from Slack")}
</List.Item>
<List.Item>
{t("Create pages and save Slack messages to Docmost")}
</List.Item>
<List.Item>
{t("Ask AI questions about your workspace docs")}
</List.Item>
<List.Item>
{t("Get your Docmost notifications as Slack DMs")}
</List.Item>
<List.Item>
{t(
"See previews of Docmost links in Slack and Slack links in Docmost",
)}
</List.Item>
</List>
<Text size="sm" c="dimmed">
{t(
"You will sign in with Slack to confirm which Slack account is yours. It must use the same email as your Docmost account.",
)}
</Text>
</>
) : (
<Text size="sm">
{t(
"Connect your {{name}} account so links you paste show rich previews.",
{ name: definition.name },
)}
</Text>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button onClick={onConnect}>{t("Connect")}</Button>
</Group>
</Stack>
);
}

View file

@ -0,0 +1,123 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useUninstallIntegration,
} from "../queries/integration-query";
import { getOAuthInstallUrl } from "../services/integration-service";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
},
[t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Uninstall {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
{ name },
)}
</Text>
),
labels: { confirm: t("Uninstall"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => uninstallMutation.mutate({ integrationId }),
});
},
[uninstallMutation, installed, available, t],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{error === "tenant_in_use" && (
<Alert color="red" mb="md">
{t(
"This Slack workspace is already connected to another Docmost workspace. Disconnect it there first, or install Docmost into a different Slack workspace.",
)}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}

View file

@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations(enabled = true) {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
enabled,
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({ queryKey: ["my-connections"] });
// refetchOnMount is false, so invalidated inactive queries would stay stale.
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}

View file

@ -0,0 +1,70 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations");
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections");
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
// The integration row is only created when the OAuth callback succeeds.
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}

View file

@ -0,0 +1,62 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
credentialsKey?: string;
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresFeature?: string;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
// Hostnames the provider unfurls; "*.example.com" means any subdomain.
unfurlHosts?: string[];
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
providerDisplayName: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveConnectPrompt } from "./connect-prompt";
const slack = { type: "slack", name: "Slack", capabilities: ["oauth", "unfurl"] } as any;
const github = { type: "github", name: "GitHub", capabilities: ["oauth", "unfurl"] } as any;
const installed = [{ id: "i-slack", type: "slack" }] as any;
describe("resolveConnectPrompt", () => {
it("returns nothing without a connect parameter", () => {
expect(resolveConnectPrompt(null, [slack], installed, [])).toBeNull();
});
it("returns nothing for a provider that is not installed", () => {
expect(resolveConnectPrompt("github", [slack, github], installed, [])).toBeNull();
});
it("returns nothing when the user already has a live connection", () => {
const live = [{ type: "slack", invalidatedAt: null }] as any;
expect(resolveConnectPrompt("slack", [slack], installed, live)).toBeNull();
});
it("prompts when the user is unlinked", () => {
expect(resolveConnectPrompt("slack", [slack], installed, [])).toBe(slack);
});
it("prompts when the existing connection is invalidated", () => {
const dead = [{ type: "slack", invalidatedAt: "2026-09-01T00:00:00Z" }] as any;
expect(resolveConnectPrompt("slack", [slack], installed, dead)).toBe(slack);
});
});

View file

@ -0,0 +1,22 @@
import type {
Integration,
IntegrationDefinition,
UserConnection,
} from "../types/integration.types";
// The provider a `?connect=<type>` deep link should prompt for, if any.
export function resolveConnectPrompt(
connectType: string | null,
available: IntegrationDefinition[] | undefined,
installed: Integration[] | undefined,
myConnections: UserConnection[] | undefined,
): IntegrationDefinition | null {
if (!connectType) return null;
const definition = available?.find((d) => d.type === connectType);
if (!definition || !definition.capabilities.includes("oauth")) return null;
if (!installed?.some((i) => i.type === connectType)) return null;
const live = myConnections?.find(
(c) => c.type === connectType && !c.invalidatedAt,
);
return live ? null : definition;
}

View file

@ -27,6 +27,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces", SPACES: "/settings/spaces",
BILLING: "/settings/billing", BILLING: "/settings/billing",
SECURITY: "/settings/security", SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
}, },
}, },
}; };

View file

@ -49,6 +49,8 @@ import {
Footnotes, Footnotes,
Footnote, Footnote,
FootnoteReference, FootnoteReference,
IntegrationCard,
IntegrationMention,
Tabs, Tabs,
Tab, Tab,
TabLabel, TabLabel,
@ -137,6 +139,8 @@ export const tiptapExtensions = [
Footnotes, Footnotes,
Footnote, Footnote,
FootnoteReference, FootnoteReference,
IntegrationCard,
IntegrationMention
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {

View file

@ -29,6 +29,12 @@ export const AuditEvent = {
OAUTH_GRANT_CREATED: 'oauth_grant.created', OAUTH_GRANT_CREATED: 'oauth_grant.created',
OAUTH_GRANT_REVOKED: 'oauth_grant.revoked', OAUTH_GRANT_REVOKED: 'oauth_grant.revoked',
// Integrations
INTEGRATION_INSTALLED: 'integration.installed',
INTEGRATION_UNINSTALLED: 'integration.uninstalled',
INTEGRATION_CONNECTED: 'integration.connected',
INTEGRATION_DISCONNECTED: 'integration.disconnected',
// SCIM Tokens // SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created', SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated', SCIM_TOKEN_UPDATED: 'scim_token.updated',
@ -124,10 +130,6 @@ export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
AuditEvent.PAGE_CREATED, AuditEvent.PAGE_CREATED,
AuditEvent.PAGE_MOVED_TO_SPACE, AuditEvent.PAGE_MOVED_TO_SPACE,
AuditEvent.PAGE_DUPLICATED, AuditEvent.PAGE_DUPLICATED,
AuditEvent.COMMENT_CREATED,
AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED,
AuditEvent.ATTACHMENT_UPLOADED, AuditEvent.ATTACHMENT_UPLOADED,
AuditEvent.SIEM_DESTINATION_TEST, AuditEvent.SIEM_DESTINATION_TEST,
]); ]);
@ -144,6 +146,7 @@ export const AuditResource = {
API_KEY: 'api_key', API_KEY: 'api_key',
OAUTH_CLIENT: 'oauth_client', OAUTH_CLIENT: 'oauth_client',
OAUTH_GRANT: 'oauth_grant', OAUTH_GRANT: 'oauth_grant',
INTEGRATION: 'integration',
SCIM_TOKEN: 'scim_token', SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider', SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation', WORKSPACE_INVITATION: 'workspace_invitation',

View file

@ -17,6 +17,7 @@ export enum EventName {
WORKSPACE_CREATED = 'workspace.created', WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted', WORKSPACE_DELETED = 'workspace.deleted',
NOTIFICATION_CREATED = 'notification.created',
BASE_CREATED = 'base.created', BASE_CREATED = 'base.created',
BASE_UPDATED = 'base.updated', BASE_UPDATED = 'base.updated',

View file

@ -23,6 +23,8 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
INTEGRATIONS: 'integrations',
INTEGRATIONS_ADVANCED: 'integrations:advanced',
OAUTH: 'oauth', OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls', MCP_CONTROLS: 'mcp:controls',

View file

@ -189,7 +189,7 @@ export function isUserDisabled(user: {
return !!(user.deactivatedAt || user.deletedAt); return !!(user.deactivatedAt || user.deletedAt);
} }
const SENSITIVE_URL_PREFIXES = ['/api/sso/']; const SENSITIVE_URL_PREFIXES = ['/api/sso/', '/api/integrations/oauth/'];
export function redactSensitiveUrl(url: string): string { export function redactSensitiveUrl(url: string): string {
if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) { if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) {

View file

@ -0,0 +1,64 @@
import { getProxyAwareFetch, proxyFetch } from './proxy-fetch';
describe('getProxyAwareFetch', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns undefined when no proxy env vars are set', () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
expect(getProxyAwareFetch()).toBeUndefined();
});
it('returns a fetch function when HTTP_PROXY is set', () => {
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('returns a fetch function when HTTPS_PROXY is set', () => {
delete process.env.HTTP_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
process.env.HTTPS_PROXY = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('returns a fetch function when lowercase http_proxy is set', () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
process.env.http_proxy = 'http://proxy.example.com:8080';
expect(typeof getProxyAwareFetch()).toBe('function');
});
it('proxyFetch delegates to the platform fetch when no proxy is configured', async () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
const original = globalThis.fetch;
const response = new Response('ok');
const spy = jest.fn().mockResolvedValue(response);
globalThis.fetch = spy as unknown as typeof fetch;
try {
await expect(proxyFetch('https://example.com')).resolves.toBe(response);
expect(spy).toHaveBeenCalledWith('https://example.com', undefined);
} finally {
globalThis.fetch = original;
}
});
});

View file

@ -0,0 +1,37 @@
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
const LOOPBACK_BYPASS = ['localhost', '127.0.0.1', '::1'];
let cachedAgent: EnvHttpProxyAgent | undefined;
function hasProxyEnv(): boolean {
return Boolean(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy,
);
}
function buildAgent(): EnvHttpProxyAgent {
const existing = process.env.NO_PROXY || process.env.no_proxy || '';
const merged = [existing, ...LOOPBACK_BYPASS]
.map((s) => s.trim())
.filter(Boolean)
.join(',');
return new EnvHttpProxyAgent({ noProxy: merged });
}
export function getProxyAwareFetch(): typeof fetch | undefined {
if (!hasProxyEnv()) return undefined;
cachedAgent ??= buildAgent();
const agent = cachedAgent;
return ((input, init) =>
undiciFetch(input as any, {
...(init as any),
dispatcher: agent,
}) as unknown as Promise<Response>) as typeof fetch;
}
export const proxyFetch: typeof fetch = (input, init) =>
(getProxyAwareFetch() ?? fetch)(input, init);

View file

@ -145,7 +145,19 @@ export class CommentController {
await this.pageAccessService.validateCanComment(page, user, workspace.id); await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.update(comment, dto, user); const updatedComment = await this.commentService.update(comment, dto, user);
this.auditService.log({
event: AuditEvent.COMMENT_UPDATED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: {
pageId: comment.pageId,
},
});
return updatedComment;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View file

@ -22,6 +22,10 @@ import { PublicSpaceModule } from './public-space/public-space.module';
import { LabelModule } from './label/label.module'; import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module'; import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module'; import { WatcherModule } from './watcher/watcher.module';
import { IntegrationModule } from './integration/integration.module';
import { GitHubModule } from './integration/providers/github/github.module';
import { FigmaModule } from './integration/providers/figma/figma.module';
import { LinearModule } from './integration/providers/linear/linear.module';
import { FavoriteModule } from './favorite/favorite.module'; import { FavoriteModule } from './favorite/favorite.module';
import { SessionModule } from './session/session.module'; import { SessionModule } from './session/session.module';
import { ClsMiddleware } from 'nestjs-cls'; import { ClsMiddleware } from 'nestjs-cls';
@ -45,6 +49,10 @@ import { ClsMiddleware } from 'nestjs-cls';
LabelModule, LabelModule,
NotificationModule, NotificationModule,
WatcherModule, WatcherModule,
IntegrationModule,
GitHubModule,
FigmaModule,
LinearModule,
SessionModule, SessionModule,
], ],
}) })
@ -55,6 +63,7 @@ export class CoreModule implements NestModule {
{ path: 'health', method: RequestMethod.GET }, { path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET }, { path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST }, { path: 'billing/stripe/webhook', method: RequestMethod.POST },
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
]; ];
consumer consumer

View file

@ -0,0 +1,57 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
OAuthAuthorizeDto,
OAuthDisconnectDto,
UninstallIntegrationDto,
} from './integration.dto';
describe.each([
['UninstallIntegrationDto', UninstallIntegrationDto],
['OAuthAuthorizeDto', OAuthAuthorizeDto],
['OAuthDisconnectDto', OAuthDisconnectDto],
])('%s integrationId', (_name, Dto) => {
const violations = async (integrationId: unknown) => {
const errors = await validate(plainToInstance(Dto, { integrationId }));
return errors.flatMap((e) => Object.keys(e.constraints ?? {}));
};
it('accepts a uuid7', async () => {
expect(await violations('019a1b2c-3d4e-7f00-8a00-123456789abc')).toEqual([]);
});
it.each([
['a non-UUID string', 'integration-1'],
['an empty string', ''],
['a number', 42],
['a missing value', undefined],
])('rejects %s', async (_label, integrationId) => {
expect(await violations(integrationId)).toContain('isUuid');
});
});
describe('OAuthAuthorizeDto returnPath', () => {
const violations = async (returnPath: string) => {
const errors = await validate(
plainToInstance(OAuthAuthorizeDto, {
integrationId: '019a1b2c-3d4e-7f00-8a00-123456789abc',
returnPath,
}),
);
return errors.flatMap((e) => Object.keys(e.constraints ?? {}));
};
it('accepts a workspace path with a query', async () => {
expect(await violations('/settings/account/connections?connect=github')).toEqual([]);
});
it.each([
['a protocol-relative path', '//evil.example'],
['a backslash', '/\\evil.example'],
['an escape character', '/settings\x1b[2J'],
['a NUL byte', '/settings\x00'],
['DEL', '/settings\x7f'],
])('rejects %s', async (_label, returnPath) => {
expect(await violations(returnPath)).toContain('matches');
});
});

View file

@ -0,0 +1,45 @@
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
export class UninstallIntegrationDto {
@IsUUID()
integrationId: string;
}
export class UnfurlDto {
// Caps the input the unfurl regexes run on
@IsNotEmpty()
@IsString()
@MaxLength(8192)
@Matches(/^[^\s\x00-\x1f\x7f]+$/)
url: string;
}
export class OAuthAuthorizeDto {
@IsUUID()
integrationId: string;
// A single leading slash, so "//host" can't redirect off the workspace origin
@IsOptional()
@IsString()
@MaxLength(512)
@Matches(/^\/(?!\/)[^\s\\\x00-\x1f\x7f]*$/)
returnPath?: string;
}
export class OAuthDisconnectDto {
@IsUUID()
integrationId: string;
}
export class OAuthInstallDto {
@IsNotEmpty()
@IsString()
type: string;
}

View file

@ -0,0 +1,36 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UnfurlDto } from './integration.dto';
async function violations(url: string): Promise<string[]> {
const errors = await validate(plainToInstance(UnfurlDto, { url }));
return errors.flatMap((e) => Object.keys(e.constraints ?? {}));
}
describe('UnfurlDto', () => {
it('accepts an ordinary provider URL', async () => {
expect(await violations('https://github.com/o/r/pulls?q=is%3Aopen')).toEqual([]);
});
it('rejects a URL longer than 8192 characters', async () => {
expect(await violations('https://github.com/o/r/pulls?' + 'a'.repeat(9000))).toContain('maxLength');
});
it('rejects a URL containing whitespace', async () => {
expect(await violations('https://github.com/o/r/pulls/' + '?'.repeat(100) + '\n')).toContain('matches');
});
it.each([
['an escape', '\x1b'],
['a NUL', '\x00'],
['a backspace', '\x08'],
['a unit separator', '\x1f'],
['a DEL', '\x7f'],
])('rejects a URL containing %s character', async (_label, char) => {
expect(await violations(`https://github.com/o/r/pull/1${char}[31m`)).toContain('matches');
});
it('accepts non-ASCII characters in a URL', async () => {
expect(await violations('https://docs.google.com/document/d/abc/edit#heading=été')).toEqual([]);
});
});

View file

@ -0,0 +1,73 @@
import { IntegrationConnectionService } from './integration-connection.service';
const connectedAt = new Date('2026-10-07T09:00:00.000Z');
function buildService(rows: Record<string, unknown>[]) {
const connectionRepo = {
findByUserAndWorkspace: jest.fn().mockResolvedValue(rows),
};
return new IntegrationConnectionService(
connectionRepo as any,
{} as any,
{ log: jest.fn() } as any,
);
}
describe('IntegrationConnectionService connections list', () => {
it("shows the Slack link's account name and id", async () => {
const service = buildService([
{
integrationId: 'integration-slack',
type: 'slack',
providerUserId: 'U04ABCD1234',
metadata: {
account: { id: 'U04ABCD1234', displayName: 'Philip' },
notifyEnabled: true,
},
createdAt: connectedAt,
invalidatedAt: null,
},
]);
await expect(
service.getUserConnections('user-1', 'workspace-1'),
).resolves.toEqual([
{
integrationId: 'integration-slack',
type: 'slack',
providerUserId: 'U04ABCD1234',
providerDisplayName: 'Philip',
connectedAt,
invalidatedAt: null,
},
]);
});
it('keeps the account of every other provider out of the list', async () => {
const service = buildService([
{
integrationId: 'integration-github',
type: 'github',
providerUserId: '583231',
metadata: {
account: { id: '583231', displayName: 'Philip', username: 'Philipinho' },
},
createdAt: connectedAt,
invalidatedAt: null,
},
]);
await expect(
service.getUserConnections('user-1', 'workspace-1'),
).resolves.toEqual([
{
integrationId: 'integration-github',
type: 'github',
providerUserId: null,
providerDisplayName: null,
connectedAt,
invalidatedAt: null,
},
]);
});
});

View file

@ -0,0 +1,68 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationRepo } from './repos/integration.repo';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import {
AuditEvent,
AuditResource,
} from '../../common/events/audit-events';
import { ProviderAccount } from './registry/integration-provider.interface';
@Injectable()
export class IntegrationConnectionService {
constructor(
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly integrationRepo: IntegrationRepo,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getUserConnections(userId: string, workspaceId: string) {
const rows = await this.connectionRepo.findByUserAndWorkspace(
userId,
workspaceId,
);
return rows.map((row) => {
// Only Slack shows its linked account until the connections page uses it.
const isSlack = row.type === 'slack';
const account = (row.metadata as { account?: ProviderAccount } | null)
?.account;
return {
integrationId: row.integrationId,
type: row.type,
providerUserId: isSlack ? (row.providerUserId ?? null) : null,
providerDisplayName: isSlack ? (account?.displayName ?? null) : null,
connectedAt: row.createdAt,
invalidatedAt: row.invalidatedAt ?? null,
};
});
}
async disconnect(
integrationId: string,
userId: string,
workspaceId: string,
): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const disconnected = await this.connectionRepo.deleteByIntegrationAndUser(
integrationId,
userId,
);
if (disconnected) {
this.auditService.log({
event: AuditEvent.INTEGRATION_DISCONNECTED,
resourceType: AuditResource.INTEGRATION,
resourceId: integrationId,
changes: { before: { provider: integration.type } },
});
}
}
}

View file

@ -0,0 +1,78 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { IntegrationService } from './integration.service';
import { IntegrationConnectionService } from './integration-connection.service';
import { UninstallIntegrationDto } from './dto/integration.dto';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
@Controller('integrations')
export class IntegrationController {
constructor(
private readonly integrationService: IntegrationService,
private readonly connectionService: IntegrationConnectionService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('available')
async getAvailableIntegrations() {
return this.integrationService.getAvailableIntegrations();
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('/')
async getInstalledIntegrations(
@AuthWorkspace() workspace: Workspace,
) {
return this.integrationService.getInstalledIntegrations(workspace.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('uninstall')
async uninstall(
@Body() dto: UninstallIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.integrationService.uninstall(dto.integrationId, workspace.id);
return { success: true };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connections')
async getMyConnections(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.connectionService.getUserConnections(user.id, workspace.id);
}
}

View file

@ -0,0 +1,29 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@Injectable()
export class IntegrationListener implements OnApplicationBootstrap {
private readonly logger = new Logger(IntegrationListener.name);
constructor(
@InjectQueue(QueueName.INTEGRATION_QUEUE)
private readonly integrationQueue: Queue,
) {}
async onApplicationBootstrap() {
await this.integrationQueue.upsertJobScheduler(
TOKEN_REFRESH_SCHEDULER_ID,
{ every: TOKEN_REFRESH_INTERVAL_MS },
{
name: QueueJob.INTEGRATION_TOKEN_REFRESH,
data: {},
},
);
this.logger.debug('Integration token refresh scheduler created');
}
}

View file

@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { IntegrationRegistry } from './registry/integration-registry';
import { IntegrationService } from './integration.service';
import { IntegrationConnectionService } from './integration-connection.service';
import { IntegrationController } from './integration.controller';
import { OAuthController } from './oauth/oauth.controller';
import { OAuthService } from './oauth/oauth.service';
import { UnfurlController } from './unfurl/unfurl.controller';
import { UnfurlService } from './unfurl/unfurl.service';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationListener } from './integration.listener';
import { IntegrationProcessor } from './integration.processor';
@Module({
controllers: [IntegrationController, OAuthController, UnfurlController],
providers: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
UnfurlService,
IntegrationRepo,
IntegrationConnectionRepo,
IntegrationListener,
IntegrationProcessor,
],
exports: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
IntegrationRepo,
IntegrationConnectionRepo,
],
})
export class IntegrationModule {}

View file

@ -0,0 +1,238 @@
import { Logger, NotFoundException } from '@nestjs/common';
import { Job } from 'bullmq';
import { IntegrationProcessor } from './integration.processor';
import { TokenInvalidError } from './registry/integration-provider.interface';
import { QueueJob } from '../../integrations/queue/constants/queue.constants';
describe('IntegrationProcessor token refresh', () => {
const connections = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }];
const manyConnections = (count: number) =>
Array.from({ length: count }, (_, i) => ({ id: `c${i + 1}` }));
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
const refreshJob = { name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job;
function build(refreshAccessToken: jest.Mock, rows: object[] = connections) {
const connectionRepo = {
findExpiringTokens: jest.fn().mockResolvedValue(rows),
invalidate: jest.fn().mockResolvedValue(undefined),
};
const oauthService = { refreshAccessToken };
const locks = new Map<string, string>();
const redis = {
set: jest.fn(async (key: string, value: string) => {
if (locks.has(key)) return null;
locks.set(key, value);
return 'OK';
}),
eval: jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => {
if (locks.get(key) !== token) return 0;
locks.delete(key);
return 1;
}),
};
const processor = new IntegrationProcessor(
connectionRepo as any,
oauthService as any,
{ getOrThrow: () => redis } as any,
);
return { processor, connectionRepo, oauthService, locks };
}
it('loads at most 500 connections expiring within 15 minutes', async () => {
const { processor, connectionRepo } = build(jest.fn());
await processor.process(refreshJob);
expect(connectionRepo.findExpiringTokens).toHaveBeenCalledWith(
15 * 60 * 1000,
500,
);
});
it('refreshes every selected connection exactly once', async () => {
const rows = manyConnections(12);
const refreshAccessToken = jest.fn().mockResolvedValue(undefined);
const { processor } = build(refreshAccessToken, rows);
await processor.process(refreshJob);
expect(refreshAccessToken.mock.calls.map(([row]) => row.id).sort()).toEqual(
rows.map((row) => row.id).sort(),
);
});
it('refreshes five connections at a time', async () => {
let running = 0;
let mostRunning = 0;
const refreshAccessToken = jest.fn(async () => {
running += 1;
mostRunning = Math.max(mostRunning, running);
await flushPromises();
running -= 1;
});
const { processor } = build(refreshAccessToken, manyConnections(12));
await processor.process(refreshJob);
expect(refreshAccessToken).toHaveBeenCalledTimes(12);
expect(mostRunning).toBe(5);
});
it('keeps refreshing the rest while one refresh hangs', async () => {
const refreshAccessToken = jest
.fn()
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValue(undefined);
const { processor } = build(refreshAccessToken, manyConnections(8));
const controller = new AbortController();
const run = processor.process(refreshJob, undefined, controller.signal);
await flushPromises();
expect(refreshAccessToken).toHaveBeenCalledTimes(8);
controller.abort('lock lost');
await expect(run).rejects.toThrow('lock lost');
});
it('never retires a connection itself and keeps refreshing the rest', async () => {
const refreshAccessToken = jest
.fn()
.mockRejectedValueOnce(new TokenInvalidError())
.mockRejectedValueOnce(new NotFoundException())
.mockRejectedValueOnce(new Error('network'));
const { processor, connectionRepo } = build(refreshAccessToken);
await processor.process({
name: QueueJob.INTEGRATION_TOKEN_REFRESH,
} as Job);
expect(refreshAccessToken).toHaveBeenCalledTimes(3);
expect(connectionRepo.invalidate).not.toHaveBeenCalled();
});
it('skips a connection whose refresh lock someone else holds', async () => {
const refreshAccessToken = jest.fn().mockResolvedValue('token');
const { processor, locks } = build(refreshAccessToken);
locks.set('integration:refresh:c2', 'request-holder');
await processor.process(refreshJob);
expect(refreshAccessToken.mock.calls.map(([row]) => row.id)).toEqual(['c1', 'c3']);
expect([...locks]).toEqual([['integration:refresh:c2', 'request-holder']]);
});
it('holds the lock during each refresh and releases it whether the refresh succeeds or fails', async () => {
const heldDuringRefresh: boolean[] = [];
let locks: Map<string, string>;
const refreshAccessToken = jest.fn(async ({ id }: { id: string }) => {
heldDuringRefresh.push(locks.has(`integration:refresh:${id}`));
if (id === 'c2') throw new Error('network');
return 'token';
});
const built = build(refreshAccessToken);
locks = built.locks;
await built.processor.process(refreshJob);
expect(heldDuringRefresh).toEqual([true, true, true]);
expect(locks.size).toBe(0);
});
describe('cancellation', () => {
const job = { id: 'j1', name: QueueJob.INTEGRATION_TOKEN_REFRESH } as Job;
const hang = () => new Promise<string>(() => {});
afterEach(() => jest.restoreAllMocks());
it('starts no further refreshes once the job is aborted', async () => {
const finishers: Array<(token: string) => void> = [];
const refreshAccessToken = jest.fn(
() => new Promise<string>((resolve) => finishers.push(resolve)),
);
const { processor } = build(refreshAccessToken, manyConnections(7));
const controller = new AbortController();
const run = processor.process(job, undefined, controller.signal);
await flushPromises();
expect(refreshAccessToken).toHaveBeenCalledTimes(5);
controller.abort('lock lost');
await expect(run).rejects.toThrow('lock lost');
finishers.forEach((finish) => finish('token'));
await flushPromises();
expect(refreshAccessToken).toHaveBeenCalledTimes(5);
});
it('fails a hung run when the job timeout fires', async () => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
const { processor } = build(jest.fn(hang));
const run = processor.process(
job,
undefined,
new AbortController().signal,
);
await flushPromises();
timeout.abort(new DOMException('timed out', 'TimeoutError'));
await expect(run).rejects.toThrow('timed out');
}, 1000);
it('logs the connections in flight when aborted', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const { processor } = build(jest.fn(hang));
const controller = new AbortController();
const run = processor.process(job, undefined, controller.signal);
await flushPromises();
controller.abort('lock lost');
await expect(run).rejects.toThrow();
expect(warn.mock.calls).toEqual([
[
'Token refresh aborted while refreshing connections c1, c2, c3: lock lost',
],
]);
});
it('logs the load step when aborted before any refresh starts', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const { processor, connectionRepo } = build(jest.fn());
connectionRepo.findExpiringTokens.mockImplementation(hang);
const controller = new AbortController();
const run = processor.process(job, undefined, controller.signal);
await flushPromises();
controller.abort('lock lost');
await expect(run).rejects.toThrow();
expect(warn.mock.calls).toEqual([
['Token refresh aborted while loading expiring tokens: lock lost'],
]);
});
it('does not log an abort after the run completes', async () => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const { processor } = build(jest.fn().mockResolvedValue('token'));
await processor.process(job, undefined, new AbortController().signal);
timeout.abort(new DOMException('timed out', 'TimeoutError'));
expect(warn).not.toHaveBeenCalled();
});
it('cancels every job whose lock renewal failed', () => {
const { processor } = build(jest.fn());
const cancelJob = jest.fn();
Object.assign(processor, { _worker: { cancelJob } });
processor.onLockRenewalFailed(['j1', 'j2']);
expect(cancelJob).toHaveBeenCalledWith('j1', 'lock lost');
expect(cancelJob).toHaveBeenCalledWith('j2', 'lock lost');
});
});
});

View file

@ -0,0 +1,131 @@
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { OAuthService } from './oauth/oauth.service';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { RefreshLock } from './utils/refresh-lock';
const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000;
// Even at 2 s per refresh, a full batch finishes inside JOB_TIMEOUT_MS.
const TOKEN_REFRESH_BATCH_SIZE = 500;
const TOKEN_REFRESH_CONCURRENCY = 5;
const JOB_TIMEOUT_MS = 5 * 60 * 1000;
@Processor(QueueName.INTEGRATION_QUEUE)
export class IntegrationProcessor extends WorkerHost {
private readonly logger = new Logger(IntegrationProcessor.name);
private readonly refreshLock: RefreshLock;
constructor(
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly oauthService: OAuthService,
private readonly redisService: RedisService,
) {
super();
this.refreshLock = new RefreshLock(this.redisService.getOrThrow());
}
async process(job: Job, token?: string, signal?: AbortSignal): Promise<void> {
const timeout = AbortSignal.timeout(JOB_TIMEOUT_MS);
const jobSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
// a hung db query can't be cancelled, so stop waiting on it and free the worker
return Promise.race([
this.runJob(job, jobSignal),
new Promise<never>((_, reject) => {
jobSignal.addEventListener(
'abort',
() =>
reject(new Error(`Job ${job.id} aborted: ${jobSignal.reason}`)),
{ once: true },
);
}),
]);
}
private async runJob(job: Job, signal: AbortSignal): Promise<void> {
switch (job.name) {
case QueueJob.INTEGRATION_TOKEN_REFRESH:
await this.handleTokenRefresh(signal);
break;
default:
this.logger.warn(`Unknown job: ${job.name}`);
}
}
@OnWorkerEvent('error')
onError(err: Error): void {
this.logger.error(`Worker error: ${err.message}`);
}
@OnWorkerEvent('lockRenewalFailed')
onLockRenewalFailed(jobIds: string[]): void {
for (const jobId of jobIds) {
this.worker.cancelJob(jobId, 'lock lost');
}
}
private async handleTokenRefresh(signal: AbortSignal): Promise<void> {
const inFlight = new Set<string>();
const logAbort = () => {
const step =
inFlight.size > 0
? `refreshing connections ${[...inFlight].join(', ')}`
: 'loading expiring tokens';
this.logger.warn(`Token refresh aborted while ${step}: ${signal.reason}`);
};
signal.addEventListener('abort', logAbort, { once: true });
try {
const connections = await this.connectionRepo.findExpiringTokens(
TOKEN_REFRESH_WINDOW_MS,
TOKEN_REFRESH_BATCH_SIZE,
);
if (connections.length === 0) {
return;
}
let next = 0;
let refreshed = 0;
// Each connection is taken once, and refreshes of different rows never conflict.
const refreshRemaining = async () => {
while (next < connections.length) {
signal.throwIfAborted();
const connection = connections[next++];
inFlight.add(connection.id);
const lockToken = await this.refreshLock.acquire(connection.id);
// Someone else is refreshing this connection right now.
if (!lockToken) {
inFlight.delete(connection.id);
continue;
}
try {
await this.oauthService.refreshAccessToken(connection);
refreshed += 1;
} catch (err) {
this.logger.error(
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
);
} finally {
inFlight.delete(connection.id);
await this.refreshLock.release(connection.id, lockToken);
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(TOKEN_REFRESH_CONCURRENCY, connections.length) },
() => refreshRemaining(),
),
);
this.logger.debug(
`Refreshed ${refreshed} of ${connections.length} expiring token(s)`,
);
} finally {
signal.removeEventListener('abort', logAbort);
}
}
}

View file

@ -0,0 +1,63 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationRegistry } from './registry/integration-registry';
import { Integration } from '@docmost/db/types/entity.types';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import {
AuditEvent,
AuditResource,
} from '../../common/events/audit-events';
@Injectable()
export class IntegrationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly registry: IntegrationRegistry,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getAvailableIntegrations() {
return this.registry.getAvailableIntegrations();
}
async getInstalledIntegrations(
workspaceId: string,
): Promise<(Integration & { unfurlHosts?: string[] })[]> {
const integrations =
await this.integrationRepo.findAllByWorkspace(workspaceId);
return integrations.map((integration) => {
const unfurlHosts = this.registry
.getProvider(integration.type)
?.getUnfurlHosts?.((integration.settings as Record<string, any>) ?? {});
return unfurlHosts ? { ...integration, unfurlHosts } : integration;
});
}
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
// Soft delete doesn't cascade to connections
await executeTx(this.db, async (trx) => {
await this.connectionRepo.deleteByIntegration(integrationId, trx);
await this.integrationRepo.softDelete(integrationId, trx);
});
this.auditService.log({
event: AuditEvent.INTEGRATION_UNINSTALLED,
resourceType: AuditResource.INTEGRATION,
resourceId: integrationId,
changes: { before: { provider: integration.type } },
});
}
}

View file

@ -0,0 +1,23 @@
import { credentialEnvKey } from './credential-env';
describe('credentialEnvKey', () => {
it('names credentials after the provider type by default', () => {
expect(credentialEnvKey('CLIENT_ID', 'github')).toBe(
'INTEGRATION_GITHUB_CLIENT_ID',
);
expect(credentialEnvKey('CLIENT_SECRET', 'gitlab', {})).toBe(
'INTEGRATION_GITLAB_CLIENT_SECRET',
);
});
it('names credentials after the shared authorization server when declared', () => {
expect(
credentialEnvKey('CLIENT_ID', 'jira', { credentialsKey: 'atlassian' }),
).toBe('INTEGRATION_ATLASSIAN_CLIENT_ID');
expect(
credentialEnvKey('CLIENT_SECRET', 'google_docs', {
credentialsKey: 'google',
}),
).toBe('INTEGRATION_GOOGLE_CLIENT_SECRET');
});
});

View file

@ -0,0 +1,11 @@
import { OAuthConfig } from '../registry/integration-provider.interface';
// Products on one authorization server (Atlassian, Google) share credentials via credentialsKey
export function credentialEnvKey(
field: 'CLIENT_ID' | 'CLIENT_SECRET',
type: string,
oauthConfig?: Pick<OAuthConfig, 'credentialsKey'>,
): string {
const key = oauthConfig?.credentialsKey ?? type;
return `INTEGRATION_${key.toUpperCase()}_${field}`;
}

View file

@ -0,0 +1,27 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
ForbiddenException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { COMPLETION_FALLBACK_REDIRECT } from './oauth.utils';
// Redirects a browser back into the app instead of showing a JSON auth error.
@Catch(UnauthorizedException, ForbiddenException)
export class OAuthCompleteAuthFilter implements ExceptionFilter {
private readonly logger = new Logger(OAuthCompleteAuthFilter.name);
catch(
exception: UnauthorizedException | ForbiddenException,
host: ArgumentsHost,
) {
const http = host.switchToHttp();
this.logger.warn(
`OAuth completion on ${http.getRequest<FastifyRequest>().host} refused without a usable session: ${exception.message}`,
);
http.getResponse<FastifyReply>().redirect(COMPLETION_FALLBACK_REDIRECT, 302);
}
}

View file

@ -0,0 +1,28 @@
import { supportsOAuthConnect } from './oauth-connect';
const base = { authUrl: 'https://p.example/authorize', tokenUrl: 'https://p.example/token', scopes: [] };
describe('supportsOAuthConnect', () => {
it('is false for providers without OAuth', () => {
expect(supportsOAuthConnect(undefined)).toBe(false);
});
it('is true for per-user providers', () => {
expect(supportsOAuthConnect(base)).toBe(true);
expect(supportsOAuthConnect({ ...base, connectionScope: 'user' })).toBe(true);
});
it('is false for workspace-scoped providers without an identity flow', () => {
expect(supportsOAuthConnect({ ...base, connectionScope: 'workspace' })).toBe(false);
});
it('is true for workspace-scoped providers with an identity flow', () => {
expect(
supportsOAuthConnect({
...base,
connectionScope: 'workspace',
identity: { authUrl: 'https://p.example/oidc', tokenUrl: 'https://p.example/oidc/token', scopes: ['openid'] },
}),
).toBe(true);
});
});

View file

@ -0,0 +1,9 @@
import { OAuthConfig } from '../registry/integration-provider.interface';
// Workspace-scoped providers can only connect through the identity flow
export function supportsOAuthConnect(
oauth: OAuthConfig | undefined,
): boolean {
if (!oauth) return false;
return (oauth.connectionScope ?? 'user') === 'user' || !!oauth.identity;
}

View file

@ -0,0 +1,95 @@
import { BadRequestException } from '@nestjs/common';
import { OAuthService } from './oauth.service';
const integrationId = 'integration-1';
const workspaceId = 'workspace-1';
const providerUserId = 'U-slack-1';
function buildService(existingLinkUserId?: string) {
const provider = {
definition: {
oauth: {
connectionScope: 'workspace',
identity: { tokenUrl: 'https://slack.example/token' },
},
},
resolveIdentity: jest.fn().mockResolvedValue({ account: { id: providerUserId } }),
};
const findUserLink = jest
.fn()
.mockResolvedValue(
existingLinkUserId ? { userId: existingLinkUserId } : undefined,
);
const upsertUserLink = jest.fn().mockResolvedValue({ id: 'connection-1' });
const service = new OAuthService(
{} as any,
{} as any,
{} as any,
{ getProvider: jest.fn().mockReturnValue(provider) } as any,
{
findById: jest.fn().mockResolvedValue({ id: integrationId, workspaceId, settings: {} }),
} as any,
{ findUserLink, upsertUserLink } as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ log: jest.fn() } as any,
{ getOrThrow: () => ({}) } as any,
);
// Stub the live token exchange with the provider
(service as any).requestTokens = jest
.fn()
.mockResolvedValue({ access_token: 'token' });
return { service, findUserLink, upsertUserLink };
}
function connectState(userId: string) {
return {
flow: 'connect' as const,
integrationId,
type: 'slack',
userId,
workspaceId,
};
}
describe('OAuthService identity link rebinding', () => {
it('refuses a provider account already linked to another Docmost user', async () => {
const { service, upsertUserLink } = buildService('user-a');
await expect(
service.exchangeCodeForTokens('slack', 'code', connectState('user-b')),
).rejects.toBeInstanceOf(BadRequestException);
expect(upsertUserLink).not.toHaveBeenCalled();
});
it('allows the same user to relink their own provider account', async () => {
const { service, upsertUserLink } = buildService('user-a');
await expect(
service.exchangeCodeForTokens('slack', 'code', connectState('user-a')),
).resolves.toEqual({ id: 'connection-1' });
expect(upsertUserLink).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-a', providerUserId }),
);
});
it('allows a first-time link', async () => {
const { service, upsertUserLink } = buildService();
await expect(
service.exchangeCodeForTokens('slack', 'code', connectState('user-b')),
).resolves.toEqual({ id: 'connection-1' });
expect(upsertUserLink).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-b', providerUserId }),
);
});
});

View file

@ -0,0 +1,553 @@
import {
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { OAuthController } from './oauth.controller';
import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter';
import { OAuthStatePayload } from './oauth.service';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import { UserRole } from '../../../common/helpers/types/permission';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
IdentityEmailMismatchError,
IdentityTenantMismatchError,
IntegrationTenantInUseError,
} from '../registry/integration-provider.interface';
import {
EncryptionPurpose,
EncryptionService,
} from '../../../integrations/encryption/encryption.service';
import { Test } from '@nestjs/testing';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { JwtStrategy } from '../../auth/strategies/jwt.strategy';
import { OAuthService } from './oauth.service';
import { IntegrationConnectionService } from '../integration-connection.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { SessionActivityService } from '../../session/session-activity.service';
const workspace = { id: 'workspace-1' } as Workspace;
const encryptionService = new EncryptionService({
getAppSecret: () => 'test-secret',
} as any);
const environmentService = {
isHttps: () => true,
isCloud: () => false,
getAppUrl: () => 'https://acme.example',
getSubdomainHost: () => undefined,
} as any;
const cloudEnvironment = {
...environmentService,
isCloud: () => true,
getAppUrl: () => 'https://app.docmost.example',
getSubdomainHost: () => 'docmost.example',
};
const authorizationUrl = 'https://provider.example/authorize';
const fallbackRedirect = '/settings/account/connections?error=oauth_failed';
function userWithRole(role: UserRole): User {
return { id: `user-${role}`, role } as User;
}
function cookieReply() {
return { setCookie: jest.fn() } as any;
}
function redirectReply() {
return { redirect: jest.fn(), clearCookie: jest.fn() } as any;
}
const signedStatePayload: OAuthStatePayload = {
flow: 'connect',
integrationId: 'integration-1',
type: 'slack',
userId: 'user-1',
workspaceId: 'workspace-1',
returnUrl: 'https://acme.example',
returnPath: '/s/general/p/roadmap',
nonce: 'nonce-1',
exp: Date.now() + 10 * 60_000,
};
function buildController(
state: Partial<OAuthStatePayload> = {},
environment = environmentService,
) {
const getInstallAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, nonce: 'install-nonce' });
const getAuthorizationUrl = jest.fn().mockResolvedValue({ authorizationUrl, type: 'github', nonce: 'connect-nonce' });
const exchangeCodeForTokens = jest.fn().mockResolvedValue({});
const verifySignedState = jest.fn((signed: string) =>
signed === 'signed-state' ? { ...signedStatePayload, ...state } : null,
);
const controller = new OAuthController(
{ getInstallAuthorizationUrl, getAuthorizationUrl, exchangeCodeForTokens, verifySignedState } as any,
{} as any,
new WorkspaceAbilityFactory(),
{ hasFeature: jest.fn().mockReturnValue(true) } as any,
{ getProvider: jest.fn().mockReturnValue({ definition: {} }) } as any,
encryptionService,
environment,
);
return { controller, exchangeCodeForTokens, getInstallAuthorizationUrl, getAuthorizationUrl };
}
function issueTicket(fields: Record<string, unknown> = {}, purpose = EncryptionPurpose.OAUTH_COMPLETION) {
return encryptionService.encrypt(
JSON.stringify({
purpose: 'oauth-completion',
state: 'signed-state',
code: 'auth-code',
exp: Date.now() + 60_000,
...fields,
}),
purpose,
);
}
let warn: jest.SpyInstance;
beforeAll(() => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
beforeEach(() => warn.mockClear());
afterAll(() => jest.restoreAllMocks());
describe('OAuthController install authorization', () => {
it('refuses a workspace member', async () => {
const { controller, getInstallAuthorizationUrl } = buildController();
const res = cookieReply();
await expect(
controller.installAndAuthorize(
{ type: 'slack' } as any,
userWithRole(UserRole.MEMBER),
workspace,
res,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(getInstallAuthorizationUrl).not.toHaveBeenCalled();
expect(res.setCookie).not.toHaveBeenCalled();
});
it.each([UserRole.ADMIN, UserRole.OWNER])('allows a workspace %s', async (role) => {
const { controller, getInstallAuthorizationUrl } = buildController();
const res = cookieReply();
await expect(
controller.installAndAuthorize(
{ type: 'slack' } as any,
userWithRole(role),
workspace,
res,
),
).resolves.toEqual({ authorizationUrl });
expect(getInstallAuthorizationUrl).toHaveBeenCalledWith(
'slack',
workspace.id,
`user-${role}`,
);
expect(res.setCookie).toHaveBeenCalledWith('integration_oauth_slack', 'install-nonce', expect.objectContaining({
httpOnly: true, secure: true, sameSite: 'lax', path: '/api/integrations/oauth',
}));
});
});
describe('OAuthController nonce cookie', () => {
const cookieOptions = {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/api/integrations/oauth',
};
it('binds a connect flow to the browser that started it', async () => {
const { controller } = buildController();
const res = cookieReply();
await expect(
controller.authorize(
{ integrationId: 'integration-1' } as any,
userWithRole(UserRole.MEMBER),
workspace,
res,
),
).resolves.toEqual({ authorizationUrl });
expect(res.setCookie).toHaveBeenCalledWith(
'integration_oauth_github',
'connect-nonce',
expect.objectContaining(cookieOptions),
);
const { expires } = res.setCookie.mock.calls[0][2];
expect(expires.getTime() - Date.now()).toBeGreaterThan(9 * 60_000);
expect(expires.getTime() - Date.now()).toBeLessThanOrEqual(10 * 60_000);
});
});
describe('OAuthController callback', () => {
it('hands the code to the workspace host instead of exchanging it', async () => {
const { controller, exchangeCodeForTokens } = buildController();
const res = redirectReply();
const before = Date.now();
await controller.callback('slack', 'auth-code', 'signed-state', res);
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
const [location, status] = res.redirect.mock.calls[0];
expect(status).toBe(302);
const url = new URL(location);
expect(`${url.origin}${url.pathname}`).toBe(
'https://acme.example/api/integrations/oauth/complete',
);
const ticket = JSON.parse(
encryptionService.decrypt(
url.searchParams.get('ticket') ?? '',
EncryptionPurpose.OAUTH_COMPLETION,
),
);
expect(ticket).toEqual({
purpose: 'oauth-completion',
state: 'signed-state',
code: 'auth-code',
exp: expect.any(Number),
});
expect(ticket.exp).toBeGreaterThanOrEqual(before + 60_000);
expect(ticket.exp).toBeLessThanOrEqual(Date.now() + 60_000);
});
it('sends a cancelled consent back with oauth_failed', async () => {
const { controller, exchangeCodeForTokens } = buildController();
const res = redirectReply();
await controller.callback('slack', undefined, 'signed-state', res);
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith(
'https://acme.example/s/general/p/roadmap?error=oauth_failed',
302,
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('slack'));
});
it("logs the provider's error code and the first line of its description when no code comes back", async () => {
const { controller } = buildController();
const res = redirectReply();
await controller.callback(
'slack',
undefined,
'signed-state',
res,
'invalid_client',
"AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.\r\nTrace ID: 1f2e\r\nCorrelation ID: 9a8b",
);
expect(warn).toHaveBeenCalledTimes(1);
const line = warn.mock.calls[0][0] as string;
expect(line).toContain('slack');
expect(line).toContain('invalid_client');
expect(line).toContain(
"AADSTS650052: The app needs access to a service that your organization hasn't subscribed to.",
);
expect(line).not.toContain('Trace ID');
expect(line).not.toContain('Correlation ID');
expect(res.redirect).toHaveBeenCalledWith(
'https://acme.example/s/general/p/roadmap?error=oauth_failed',
302,
);
});
it('logs only a printable, bounded line from the provider error', async () => {
const { controller } = buildController();
const res = redirectReply();
await controller.callback(
'slack',
undefined,
'signed-state',
res,
'Access_Denied\nforged',
`denied\u001b[31m‮${'x'.repeat(1000)}`,
);
const line = warn.mock.calls[0][0] as string;
expect(line).not.toContain('Access_Denied');
expect(line).not.toMatch(/[^\x20-\x7e]/);
expect(line).toContain('denied[31m');
expect(line.length).toBeLessThan(400);
});
it('refuses a state signed for a different provider', async () => {
const { controller, exchangeCodeForTokens } = buildController();
const res = redirectReply();
await controller.callback('github', 'auth-code', 'signed-state', res);
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledTimes(1);
expect(res.redirect).toHaveBeenCalledWith(
'https://acme.example/s/general/p/roadmap?error=oauth_failed',
302,
);
});
it('rejects a state that does not verify', async () => {
const { controller } = buildController();
const res = redirectReply();
await expect(
controller.callback('slack', 'auth-code', 'forged-state', res),
).rejects.toBeInstanceOf(BadRequestException);
expect(res.redirect).not.toHaveBeenCalled();
});
describe('return host', () => {
it.each([
['a custom domain', cloudEnvironment, 'https://wiki.tenant.example'],
['a lookalike of the cloud domain', cloudEnvironment, 'https://acmedocmost.example'],
['a userinfo trick', cloudEnvironment, 'https://acme.docmost.example@evil.example'],
[
'a subdomain while self-hosted',
{ ...environmentService, getSubdomainHost: () => 'docmost.example' },
'https://acme.docmost.example',
],
[
'any host when the subdomain host is unset',
{ ...cloudEnvironment, getSubdomainHost: () => undefined },
'https://acme.undefined',
],
['a malformed return URL', cloudEnvironment, 'not a url'],
])('refuses to hand a ticket to %s', async (_label, environment, returnUrl) => {
const { controller, exchangeCodeForTokens } = buildController(
{ returnUrl },
environment,
);
const res = redirectReply();
await controller.callback('slack', 'auth-code', 'signed-state', res);
expect(res.redirect.mock.calls[0][0]).not.toContain('ticket=');
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledTimes(1);
expect(res.redirect).toHaveBeenCalledWith(
`${returnUrl}/s/general/p/roadmap?error=oauth_failed`,
302,
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('is not a Docmost host'),
);
});
it.each([
['the APP_URL host on cloud', cloudEnvironment, 'https://app.docmost.example'],
['a cloud workspace subdomain', cloudEnvironment, 'https://acme.docmost.example'],
[
'a cloud workspace subdomain with a port',
{
...cloudEnvironment,
getAppUrl: () => 'http://localhost:3000',
getSubdomainHost: () => 'localhost:3000',
},
'http://acme.localhost:3000',
],
])('hands the ticket to %s', async (_label, environment, returnUrl) => {
const { controller } = buildController({ returnUrl }, environment);
const res = redirectReply();
await controller.callback('slack', 'auth-code', 'signed-state', res);
const url = new URL(res.redirect.mock.calls[0][0]);
expect(`${url.origin}${url.pathname}`).toBe(
`${returnUrl}/api/integrations/oauth/complete`,
);
expect(url.searchParams.get('ticket')).toBeTruthy();
});
});
});
describe('OAuthController complete', () => {
const sessionUser = { id: 'user-1' } as User;
async function complete(
options: {
ticket?: string;
user?: User;
sessionWorkspace?: Workspace;
cookies?: Record<string, string>;
state?: Partial<OAuthStatePayload>;
error?: Error;
} = {},
) {
const {
user = sessionUser,
sessionWorkspace = workspace,
cookies = { integration_oauth_slack: 'nonce-1' },
state = {},
} = options;
const ticket = 'ticket' in options ? options.ticket : issueTicket();
const { controller, exchangeCodeForTokens } = buildController(state);
const res = redirectReply();
if (options.error) exchangeCodeForTokens.mockRejectedValue(options.error);
await controller.complete(
ticket,
user,
sessionWorkspace,
{ cookies } as any,
res,
);
return { res, exchangeCodeForTokens };
}
it('exchanges the code for the user who started the flow', async () => {
const { controller, exchangeCodeForTokens } = buildController();
const callbackRes = redirectReply();
await controller.callback('slack', 'auth-code', 'signed-state', callbackRes);
const ticket = new URL(callbackRes.redirect.mock.calls[0][0]).searchParams.get('ticket');
const res = redirectReply();
await controller.complete(
ticket,
sessionUser,
workspace,
{ cookies: { integration_oauth_slack: 'nonce-1' } } as any,
res,
);
expect(exchangeCodeForTokens).toHaveBeenCalledWith(
'slack',
'auth-code',
expect.objectContaining({ type: 'slack', userId: 'user-1', workspaceId: 'workspace-1' }),
);
expect(res.clearCookie).toHaveBeenCalledWith('integration_oauth_slack', {
path: '/api/integrations/oauth',
});
expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap', 302);
});
it.each([
['a different user', { user: { id: 'user-2' } as User }],
['a different workspace', { sessionWorkspace: { id: 'workspace-2' } as Workspace }],
['a wrong nonce cookie', { cookies: { integration_oauth_slack: 'nonce-2' } }],
['a nonce cookie of a different length', { cookies: { integration_oauth_slack: 'nonce-10' } }],
['a nonce cookie for another provider', { cookies: { integration_oauth_github: 'nonce-1' } }],
['a state without a nonce', { state: { nonce: undefined } }],
])('refuses %s', async (_label, options) => {
const { res, exchangeCodeForTokens } = await complete(options);
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith('/s/general/p/roadmap?error=oauth_failed', 302);
});
it.each([
['an expired ticket', () => issueTicket({ exp: Date.now() - 1 })],
['an undecryptable ticket', () => 'not-a-ticket'],
['a ticket whose state does not verify', () => issueTicket({ state: 'forged-state' })],
['a ticket without a code', () => issueTicket({ code: undefined })],
['a ticket without an expiry', () => issueTicket({ exp: undefined })],
['a ticket minted for another purpose', () => issueTicket({ purpose: 'session' })],
['a ticket encrypted for SIEM credentials', () => issueTicket({}, EncryptionPurpose.SIEM_CREDENTIALS)],
])('refuses %s', async (_label, ticket) => {
const { res, exchangeCodeForTokens } = await complete({ ticket: ticket() });
expect(exchangeCodeForTokens).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302);
});
it.each([
[IdentityEmailMismatchError, 'identity_mismatch'],
[IdentityTenantMismatchError, 'tenant_mismatch'],
[IntegrationTenantInUseError, 'tenant_in_use'],
[Error, 'oauth_failed'],
])('reports %p as %s', async (ErrorClass, code) => {
const { res } = await complete({ error: new ErrorClass() });
expect(res.clearCookie).toHaveBeenCalledTimes(1);
expect(res.redirect).toHaveBeenCalledWith(
`/s/general/p/roadmap?error=${code}`,
302,
);
});
});
describe('OAuthController complete without a session', () => {
it('redirects a forbidden completion without logging the ticket', () => {
const exception = new ForbiddenException();
const res = redirectReply();
const req = {
host: 'acme.example',
url: '/api/integrations/oauth/complete?ticket=sealed-ticket',
};
new OAuthCompleteAuthFilter().catch(exception, {
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
} as any);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('acme.example');
expect(warn.mock.calls[0][0]).not.toContain('sealed-ticket');
expect(res.redirect).toHaveBeenCalledTimes(1);
expect(res.redirect).toHaveBeenCalledWith(fallbackRedirect, 302);
});
it('redirects instead of returning 401 through the Fastify pipeline', async () => {
const moduleRef = await Test.createTestingModule({
controllers: [OAuthController],
providers: [
JwtStrategy,
{
provide: EnvironmentService,
useValue: { ...environmentService, getAppSecret: () => 'test-secret' },
},
{ provide: EncryptionService, useValue: encryptionService },
{ provide: WorkspaceAbilityFactory, useValue: {} },
...[
OAuthService,
IntegrationConnectionService,
LicenseCheckService,
IntegrationRegistry,
UserRepo,
WorkspaceRepo,
UserSessionRepo,
SessionActivityService,
].map((provide) => ({ provide, useValue: {} })),
],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(
new FastifyAdapter(),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
try {
const response = await app.inject({
method: 'GET',
url: `/integrations/oauth/complete?ticket=${encodeURIComponent(issueTicket())}`,
});
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe(fallbackRedirect);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('refused without a usable session'),
);
} finally {
await app.close();
}
});
});

View file

@ -0,0 +1,333 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
Query,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { addMinutes } from 'date-fns';
import * as crypto from 'crypto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { RequireSessionAuth } from '../../../common/decorators/require-session-auth.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { OAuthService } from './oauth.service';
import {
OAuthAuthorizeDto,
OAuthDisconnectDto,
OAuthInstallDto,
} from '../dto/integration.dto';
import { IntegrationConnectionService } from '../integration-connection.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import {
IdentityEmailMismatchError,
IdentityTenantMismatchError,
IntegrationTenantInUseError,
} from '../registry/integration-provider.interface';
import {
EncryptionPurpose,
EncryptionService,
} from '../../../integrations/encryption/encryption.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { COMPLETION_FALLBACK_REDIRECT, nonceCookieName } from './oauth.utils';
import { OAuthCompleteAuthFilter } from './oauth-complete-auth.filter';
const OAUTH_ROUTE_PREFIX = '/api/integrations/oauth';
const COMPLETION_TICKET_PURPOSE = 'oauth-completion';
const COMPLETION_TICKET_TTL_MS = 60_000;
type CompletionTicket = {
purpose: typeof COMPLETION_TICKET_PURPOSE;
state: string;
code: string;
exp: number;
};
@Controller('integrations/oauth')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor(
private readonly oauthService: OAuthService,
private readonly connectionService: IntegrationConnectionService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly licenseCheckService: LicenseCheckService,
private readonly registry: IntegrationRegistry,
private readonly encryptionService: EncryptionService,
private readonly environmentService: EnvironmentService,
) {}
@RequireSessionAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('authorize')
async authorize(
@Body() dto: OAuthAuthorizeDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Res({ passthrough: true }) res: FastifyReply,
) {
const { authorizationUrl, type, nonce } =
await this.oauthService.getAuthorizationUrl(
dto.integrationId,
workspace.id,
user.id,
dto.returnPath,
);
this.setNonceCookie(res, type, nonce);
return { authorizationUrl };
}
@RequireSessionAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('install')
async installAndAuthorize(
@Body() dto: OAuthInstallDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Res({ passthrough: true }) res: FastifyReply,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
const requiredFeature =
this.registry.getProvider(dto.type)?.definition.requiresFeature;
if (
requiredFeature &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
requiredFeature,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
const { authorizationUrl, nonce } =
await this.oauthService.getInstallAuthorizationUrl(
dto.type,
workspace.id,
user.id,
);
this.setNonceCookie(res, dto.type, nonce);
return { authorizationUrl };
}
@Get(':type/callback')
async callback(
@Param('type') type: string,
@Query('code') code: string,
@Query('state') state: string,
@Res() res: FastifyReply,
@Query('error') providerError?: string,
@Query('error_description') providerErrorDescription?: string,
) {
if (!state) {
throw new BadRequestException('Missing state parameter');
}
const statePayload = this.oauthService.verifySignedState(state);
if (!statePayload) {
throw new BadRequestException('Invalid or expired OAuth state');
}
// Built server-side and signed into the state, never user input.
const returnUrl = statePayload.returnUrl;
const returnPath = statePayload.returnPath;
// No code means the provider refused or the user cancelled.
if (!code) {
const errorCode = /^[a-z_]{1,40}$/.test(providerError ?? '')
? providerError
: 'none';
const reason =
typeof providerErrorDescription === 'string'
? providerErrorDescription
.split(/[\r\n]/)[0]
.replace(/[^\x20-\x7e]/g, '')
.slice(0, 200)
: '';
this.logger.warn(
`OAuth callback for ${statePayload.type} returned no code: error=${errorCode}${reason ? ` ${reason}` : ''}`,
);
return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302);
}
if (statePayload.type !== type) {
this.logger.error(
`OAuth callback error for ${type}: OAuth state does not match this provider`,
);
return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302);
}
// A customDomain is tenant-controlled DNS, so the ticket only goes to hosts Docmost serves.
let returnHost: string | undefined;
try {
returnHost = new URL(returnUrl).host;
} catch {
returnHost = undefined;
}
const subdomainHost = this.environmentService.getSubdomainHost();
const isDocmostHost =
returnHost === new URL(this.environmentService.getAppUrl()).host ||
(this.environmentService.isCloud() &&
!!subdomainHost &&
!!returnHost?.endsWith(`.${subdomainHost}`));
if (!isDocmostHost) {
this.logger.warn(
`OAuth callback refused for ${type}: ${returnUrl} is not a Docmost host`,
);
return res.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302);
}
// The code is redeemed on the workspace host, where the session and nonce cookie prove who started the flow.
const ticket = this.encryptionService.encrypt(
JSON.stringify({
purpose: COMPLETION_TICKET_PURPOSE,
state,
code,
exp: Date.now() + COMPLETION_TICKET_TTL_MS,
} satisfies CompletionTicket),
EncryptionPurpose.OAUTH_COMPLETION,
);
return res.redirect(
`${returnUrl}${OAUTH_ROUTE_PREFIX}/complete?ticket=${encodeURIComponent(ticket)}`,
302,
);
}
@RequireSessionAuth()
@UseGuards(JwtAuthGuard)
@UseFilters(OAuthCompleteAuthFilter)
@Get('complete')
async complete(
@Query('ticket') ticket: string,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
) {
let completion: Partial<CompletionTicket> | null;
try {
completion = JSON.parse(
this.encryptionService.decrypt(ticket, EncryptionPurpose.OAUTH_COMPLETION),
);
} catch {
completion = null;
}
const { purpose, state, code, exp } = completion ?? {};
const statePayload =
purpose === COMPLETION_TICKET_PURPOSE &&
typeof state === 'string' &&
typeof code === 'string' &&
typeof exp === 'number' &&
exp >= Date.now()
? this.oauthService.verifySignedState(state)
: null;
if (!statePayload) {
this.logger.warn('OAuth completion refused: invalid or expired ticket');
return res.redirect(COMPLETION_FALLBACK_REDIRECT, 302);
}
const returnPath = statePayload.returnPath;
const cookieName = nonceCookieName(statePayload.type);
const cookieBuf = Buffer.from(req.cookies?.[cookieName] ?? '');
const nonceBuf = Buffer.from(statePayload.nonce ?? '');
const nonceMatches =
nonceBuf.length > 0 &&
cookieBuf.length === nonceBuf.length &&
crypto.timingSafeEqual(cookieBuf, nonceBuf);
if (
statePayload.userId !== user.id ||
statePayload.workspaceId !== workspace.id ||
!nonceMatches
) {
this.logger.warn(
`OAuth completion refused for ${statePayload.type}: the session did not start this flow`,
);
return res.redirect(`${returnPath}?error=oauth_failed`, 302);
}
res.clearCookie(cookieName, { path: OAUTH_ROUTE_PREFIX });
try {
await this.oauthService.exchangeCodeForTokens(
statePayload.type,
code,
statePayload,
);
return res.redirect(returnPath, 302);
} catch (err) {
this.logger.error(
`OAuth completion error for ${statePayload.type}: ${(err as Error).message}`,
);
let errorCode = 'oauth_failed';
if (err instanceof IdentityTenantMismatchError) {
errorCode = 'tenant_mismatch';
} else if (err instanceof IdentityEmailMismatchError) {
errorCode = 'identity_mismatch';
} else if (err instanceof IntegrationTenantInUseError) {
errorCode = 'tenant_in_use';
}
return res.redirect(`${returnPath}?error=${errorCode}`, 302);
}
}
@RequireSessionAuth()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('disconnect')
async disconnect(
@Body() dto: OAuthDisconnectDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.connectionService.disconnect(
dto.integrationId,
user.id,
workspace.id,
);
return { success: true };
}
private setNonceCookie(res: FastifyReply, type: string, nonce: string) {
res.setCookie(nonceCookieName(type), nonce, {
httpOnly: true,
path: OAUTH_ROUTE_PREFIX,
secure: this.environmentService.isHttps(),
sameSite: 'lax',
expires: addMinutes(new Date(), 10),
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,972 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { DomainService } from '../../../integrations/environment/domain.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { IntegrationRepo } from '../repos/integration.repo';
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
EncryptionPurpose,
EncryptionService,
} from '../../../integrations/encryption/encryption.service';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import {
IdentityEmailMismatchError,
IntegrationProvider,
OAuthConfig,
ProviderAccount,
TokenExpiredError,
TokenInvalidError,
} from '../registry/integration-provider.interface';
import { proxyFetch } from '../../../common/proxy-fetch';
import { readCappedResponse } from '../utils/provider-fetch';
import { isUserDisabled } from '../../../common/helpers/utils';
import { credentialEnvKey } from './credential-env';
import { supportsOAuthConnect } from './oauth-connect';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import * as crypto from 'crypto';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import {
AuditEvent,
AuditResource,
} from '../../../common/events/audit-events';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { RefreshLock } from '../utils/refresh-lock';
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
// Room for the provider call that follows, and far inside the refresh job's 15-minute window.
const REQUEST_REFRESH_WINDOW_MS = 60_000;
const REFRESH_WAIT_TIMEOUT_MS = 5_000;
const REFRESH_WAIT_POLL_MS = 200;
const CONNECT_UNSUPPORTED_MESSAGE =
'This integration does not support linking your account from Docmost';
export type OAuthFlow = 'install' | 'connect';
// RFC 6749 token charset; a control character would make undici echo the token in a header error.
const isWellFormedToken = (token: unknown): token is string =>
typeof token === 'string' && /^[\x21-\x7e]+$/.test(token);
// Only the error code is safe to log; the rest of the body can name the tenant, app and user.
function tokenErrorCode(body: { error?: unknown } | null): string {
return typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error)
? body.error
: '';
}
type OAuthTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
scope?: string;
error?: string;
};
type CompletedConnection = {
connection: IntegrationConnection;
account?: ProviderAccount;
};
export type OAuthStatePayload = {
// Only admin-gated install states may write the shared workspace connection.
flow: OAuthFlow;
// null for installs, since completion creates the row.
integrationId: string | null;
type: string;
userId: string;
workspaceId: string;
// Workspace URL the central callback hands the code back to.
returnUrl: string;
// Path on returnUrl to land on after the callback.
returnPath: string;
// Bound to a cookie on the browser that started the flow.
nonce: string;
exp: number;
};
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
private readonly refreshLock: RefreshLock;
// Concurrent requests for one connection share a single refresh in this process.
private readonly pendingRefreshes = new Map<string, Promise<string>>();
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly environmentService: EnvironmentService,
private readonly domainService: DomainService,
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly encryptionService: EncryptionService,
private readonly userRepo: UserRepo,
private readonly workspaceAbility: WorkspaceAbilityFactory,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private readonly redisService: RedisService,
) {
this.refreshLock = new RefreshLock(this.redisService.getOrThrow());
}
async getAuthorizationUrl(
integrationId: string,
workspaceId: string,
userId: string,
returnPathOverride?: string,
): Promise<{ authorizationUrl: string; type: string; nonce: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const provider = this.registry.getProvider(integration.type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
: provider.definition.oauth;
if (!supportsOAuthConnect(oauthConfig)) {
throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE);
}
// Workspace-scoped providers link a member's identity instead of re-running the install.
const identity =
(oauthConfig.connectionScope ?? 'user') === 'workspace'
? oauthConfig.identity
: undefined;
const callbackUrl = this.buildCallbackUrl(integration.type);
const workspace = await this.workspaceRepo.findById(workspaceId);
const returnUrl = this.domainService.getWorkspaceUrl(
workspace ?? { hostname: null, customDomain: null },
);
const returnPath = returnPathOverride ?? '/settings/account/connections';
const nonce = crypto.randomBytes(32).toString('base64url');
const state = this.createSignedState({
flow: 'connect',
integrationId,
type: integration.type,
userId,
workspaceId,
returnUrl,
returnPath,
nonce,
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
...((identity ? identity.authParams : oauthConfig.authParams) ?? {}),
client_id: this.getClientId(integration.type, oauthConfig),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = (identity?.scopes ?? oauthConfig.scopes)
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${identity?.authUrl ?? oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
type: integration.type,
nonce,
};
}
async getInstallAuthorizationUrl(
type: string,
workspaceId: string,
userId: string,
): Promise<{ authorizationUrl: string; nonce: string }> {
const provider = this.registry.getProvider(type);
if (!provider || provider.definition.hidden) {
throw new BadRequestException(`Unknown integration type: ${type}`);
}
if (!provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const existing = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
type,
);
if (existing) {
throw new BadRequestException(
`Integration "${type}" is already installed`,
);
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig({})
: provider.definition.oauth;
const callbackUrl = this.buildCallbackUrl(type);
const workspace = await this.workspaceRepo.findById(workspaceId);
const returnUrl = this.domainService.getWorkspaceUrl(
workspace ?? { hostname: null, customDomain: null },
);
const nonce = crypto.randomBytes(32).toString('base64url');
const state = this.createSignedState({
flow: 'install',
integrationId: null,
type,
userId,
workspaceId,
returnUrl,
returnPath: '/settings/integrations',
nonce,
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
...(oauthConfig.authParams ?? {}),
client_id: this.getClientId(type, oauthConfig),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = oauthConfig.scopes
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
nonce,
};
}
verifySignedState(state: string): OAuthStatePayload | null {
const dotIndex = state.lastIndexOf('.');
if (dotIndex === -1) return null;
const data = state.substring(0, dotIndex);
const signature = state.substring(dotIndex + 1);
const secret = this.stateKey();
const expected = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return null;
}
try {
const payload: OAuthStatePayload = JSON.parse(
Buffer.from(data, 'base64url').toString(),
);
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
async exchangeCodeForTokens(
type: string,
code: string,
state: Pick<
OAuthStatePayload,
'flow' | 'integrationId' | 'type' | 'userId' | 'workspaceId'
>,
): Promise<IntegrationConnection> {
if (state.type !== type) {
throw new BadRequestException('OAuth state does not match this provider');
}
const provider = this.registry.getProvider(type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
if (state.flow === 'install') {
if (provider.definition.hidden) {
throw new BadRequestException(`Unknown integration type: ${type}`);
}
await this.assertCanManageWorkspace(state.userId, state.workspaceId);
const { connection, account } = await this.completeInstall(
provider,
type,
code,
state,
);
this.auditService.log({
event: AuditEvent.INTEGRATION_INSTALLED,
resourceType: AuditResource.INTEGRATION,
resourceId: connection.integrationId,
changes: { after: { provider: type, ...(account && { account }) } },
});
return connection;
}
const { connection, account } = await this.completeConnect(
provider,
type,
code,
state,
);
this.auditService.log({
event: AuditEvent.INTEGRATION_CONNECTED,
resourceType: AuditResource.INTEGRATION,
resourceId: state.integrationId,
changes: { after: { provider: type, ...(account && { account }) } },
});
return connection;
}
// Catches an admin role lost between authorize and completion.
private async assertCanManageWorkspace(
userId: string,
workspaceId: string,
): Promise<void> {
const [user, workspace] = await Promise.all([
this.userRepo.findById(userId, workspaceId),
this.workspaceRepo.findById(workspaceId),
]);
if (!user || isUserDisabled(user) || !workspace) {
throw new ForbiddenException();
}
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
// The integration row is only created after a successful token exchange.
private async completeInstall(
provider: IntegrationProvider,
type: string,
code: string,
state: Pick<OAuthStatePayload, 'integrationId' | 'userId' | 'workspaceId'>,
): Promise<CompletedConnection> {
const { userId, workspaceId } = state;
const integration = state.integrationId
? await this.integrationRepo.findById(state.integrationId)
: null;
if (integration && integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const settings = (integration?.settings as Record<string, any>) ?? {};
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig(settings)
: provider.definition.oauth;
const tokenResponse = await this.requestTokens(oauthConfig, type, code);
// Read before the transaction so no provider call runs inside it.
const account = await this.resolveAccount(provider, type, tokenResponse);
// A failing onConnected must not leave the row and bot token behind.
const connection = await executeTx(this.db, async (trx) => {
const installed =
integration ??
(await this.integrationRepo.insertOrRestore(
{ type, workspaceId, installedById: userId },
trx,
));
const stored = await this.storeConnection(
installed.id,
userId,
workspaceId,
tokenResponse,
provider.definition.oauth?.connectionScope ?? 'user',
account,
trx,
);
// Only the install flow fires onConnected.
if (provider.onConnected) {
await provider.onConnected({
integrationId: installed.id,
workspaceId,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
userId,
metadata: tokenResponse,
account,
trx,
});
}
return stored;
});
return { connection, account };
}
// Writes only the caller's own row, never the shared workspace connection.
private async completeConnect(
provider: IntegrationProvider,
type: string,
code: string,
state: Pick<OAuthStatePayload, 'integrationId' | 'userId' | 'workspaceId'>,
): Promise<CompletedConnection> {
const { userId, workspaceId } = state;
const integration = state.integrationId
? await this.integrationRepo.findById(state.integrationId)
: null;
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const settings = (integration.settings as Record<string, any>) ?? {};
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig(settings)
: provider.definition.oauth;
if (!supportsOAuthConnect(oauthConfig)) {
throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE);
}
if ((oauthConfig.connectionScope ?? 'user') === 'user') {
const tokenResponse = await this.requestTokens(oauthConfig, type, code);
const account = await this.resolveAccount(provider, type, tokenResponse);
const connection = await this.storeConnection(
integration.id,
userId,
workspaceId,
tokenResponse,
'user',
account,
);
return { connection, account };
}
const identity = oauthConfig.identity;
if (!identity || !provider.resolveIdentity) {
throw new BadRequestException(CONNECT_UNSUPPORTED_MESSAGE);
}
const tokenResponse = await this.requestTokens(
oauthConfig,
type,
code,
identity.tokenUrl,
);
const resolved = await provider.resolveIdentity({
integrationId: integration.id,
workspaceId,
userId,
tokenResponse,
settings,
});
// Stops a phished completion from binding someone else's provider account.
if (resolved.email) {
const user = await this.userRepo.findById(userId, workspaceId);
const userEmail = user?.email?.trim().toLowerCase();
if (!userEmail || userEmail !== resolved.email.trim().toLowerCase()) {
throw new IdentityEmailMismatchError();
}
}
await this.assertAccountNotLinkedElsewhere(
integration.id,
resolved.account.id,
userId,
);
const connection = await this.connectionRepo.upsertUserLink({
integrationId: integration.id,
workspaceId,
userId,
providerUserId: resolved.account.id,
metadata: { ...resolved.metadata, account: resolved.account },
});
return { connection, account: resolved.account };
}
private async storeConnection(
integrationId: string,
userId: string,
workspaceId: string,
tokenResponse: OAuthTokenResponse,
connectionScope: 'workspace' | 'user',
account: ProviderAccount | undefined,
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const encryptedAccessToken = this.encryptionService.encrypt(
tokenResponse.access_token,
EncryptionPurpose.INTEGRATION_TOKENS,
);
const encryptedRefreshToken = tokenResponse.refresh_token
? this.encryptionService.encrypt(
tokenResponse.refresh_token,
EncryptionPurpose.INTEGRATION_TOKENS,
)
: null;
const tokenExpiresAt = tokenResponse.expires_in
? new Date(Date.now() + tokenResponse.expires_in * 1000)
: null;
const values = {
integrationId,
userId,
workspaceId,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
scopes: tokenResponse.scope ?? null,
};
// The installer's account never goes on the shared bot row.
if (connectionScope === 'workspace') {
return this.connectionRepo.upsertWorkspaceConnection(values, trx);
}
if (account) {
await this.assertAccountNotLinkedElsewhere(
integrationId,
account.id,
userId,
trx,
);
}
// A missing account clears the old one, so a reconnect never keeps a stale identity.
return this.connectionRepo.upsert(
{
...values,
providerUserId: account?.id ?? null,
metadata: account ? { account } : null,
},
trx,
);
}
private async resolveAccount(
provider: IntegrationProvider,
type: string,
tokenResponse: OAuthTokenResponse,
): Promise<ProviderAccount | undefined> {
if (!provider.resolveAccount) return undefined;
try {
return await provider.resolveAccount({
accessToken: tokenResponse.access_token,
tokenResponse,
});
} catch (err) {
this.logger.warn(
`Could not read the connected ${type} account: ${(err as Error).message}`,
);
return undefined;
}
}
// Checked up front so a rebind gets a clear error, not a unique-index violation.
private async assertAccountNotLinkedElsewhere(
integrationId: string,
accountId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<void> {
const existingLink = await this.connectionRepo.findUserLink(
integrationId,
accountId,
trx,
);
if (existingLink && existingLink.userId !== userId) {
throw new BadRequestException(
'This account is already connected to a different Docmost user. Disconnect it there first.',
);
}
}
async getValidAccessToken(
connection: IntegrationConnection,
): Promise<string> {
if (connection.invalidatedAt) {
throw new TokenInvalidError();
}
const expiresAt = connection.tokenExpiresAt
? new Date(connection.tokenExpiresAt).getTime()
: null;
if (
expiresAt === null ||
expiresAt - Date.now() > REQUEST_REFRESH_WINDOW_MS
) {
return this.encryptionService.decrypt(
connection.accessToken,
EncryptionPurpose.INTEGRATION_TOKENS,
);
}
if (!connection.refreshToken) {
// Not every provider answers an expired token with a 401.
if (expiresAt < Date.now()) {
throw new TokenInvalidError(
'Access token expired and cannot be refreshed',
);
}
return this.encryptionService.decrypt(
connection.accessToken,
EncryptionPurpose.INTEGRATION_TOKENS,
);
}
let pending = this.pendingRefreshes.get(connection.id);
if (!pending) {
pending = this.refreshForRequest(connection).finally(() =>
this.pendingRefreshes.delete(connection.id),
);
this.pendingRefreshes.set(connection.id, pending);
}
return pending;
}
private async refreshForRequest(
connection: IntegrationConnection,
): Promise<string> {
const lockToken = await this.refreshLock.acquire(connection.id);
if (!lockToken) {
await this.refreshLock.waitForRelease(connection.id, {
timeoutMs: REFRESH_WAIT_TIMEOUT_MS,
pollMs: REFRESH_WAIT_POLL_MS,
});
const accessToken = this.usableAccessToken(
await this.connectionRepo.findById(connection.id),
);
if (!accessToken) {
throw new TokenExpiredError();
}
return accessToken;
}
try {
return await this.refreshAccessToken(connection);
} catch (err) {
if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) {
throw err;
}
// A transient failure keeps the connection; a later request tries again.
throw new TokenExpiredError();
} finally {
await this.refreshLock.release(connection.id, lockToken);
}
}
private usableAccessToken(
row: IntegrationConnection | undefined,
): string | null {
if (!row?.accessToken || row.invalidatedAt) {
return null;
}
if (
row.tokenExpiresAt &&
new Date(row.tokenExpiresAt).getTime() <= Date.now()
) {
return null;
}
return this.encryptionService.decrypt(
row.accessToken,
EncryptionPurpose.INTEGRATION_TOKENS,
);
}
// Callers must hold the connection's refresh lock.
async refreshAccessToken(connection: IntegrationConnection): Promise<string> {
const integration = await this.integrationRepo.findById(
connection.integrationId,
);
if (!integration) {
throw new NotFoundException('Integration not found');
}
const provider = this.registry.getProvider(integration.type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
: provider.definition.oauth;
const clientId = this.getClientId(integration.type, oauthConfig);
const clientSecret = this.getClientSecret(integration.type, oauthConfig);
try {
const current = await this.connectionRepo.findById(connection.id);
// Gone, retired, refreshed or reconnected since the caller read it.
if (
!current?.refreshToken ||
current.invalidatedAt ||
current.accessToken !== connection.accessToken
) {
const accessToken = this.usableAccessToken(current);
if (!accessToken) {
throw new TokenExpiredError();
}
return accessToken;
}
// A reconnect or another refresh meanwhile turns the writes below into no-ops.
const expected = { refreshToken: current.refreshToken };
const basicAuth = oauthConfig.clientAuth === 'basic';
const params = new URLSearchParams({
grant_type: 'refresh_token',
...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }),
refresh_token: this.encryptionService.decrypt(
current.refreshToken,
EncryptionPurpose.INTEGRATION_TOKENS,
),
});
if (oauthConfig.scopeOnRefresh) {
params.set('scope', oauthConfig.scopes.join(' '));
}
const response = await this.postTokenRequest(
integration.type,
oauthConfig.refreshUrl ?? oauthConfig.tokenUrl,
params,
basicAuth ? { clientId, clientSecret } : undefined,
);
// A parse error would quote the body.
const data: OAuthTokenResponse | null = await response
.json()
.catch(() => null);
// GitHub reports token errors with a 200 and an error body.
if (!response.ok || data?.error !== undefined) {
const errorCode = tokenErrorCode(data);
this.logger.error(
`Token refresh failed for ${integration.type}: ${response.status} ${errorCode}`.trimEnd(),
);
// invalid_client etc. are config errors, not a dead grant
if (
errorCode === 'invalid_grant' ||
errorCode === 'interaction_required' ||
errorCode === 'bad_refresh_token'
) {
await this.connectionRepo.invalidate(current.id, expected);
throw new TokenInvalidError(
`Refresh token rejected for ${integration.type}`,
);
}
throw new BadRequestException('Token refresh failed');
}
if (!data) {
this.logger.error(
`Token refresh for ${integration.type} returned a response that is not JSON`,
);
throw new BadRequestException('Token refresh failed');
}
if (
!isWellFormedToken(data.access_token) ||
(data.refresh_token && !isWellFormedToken(data.refresh_token))
) {
this.logger.error(
`Token refresh for ${integration.type} returned a malformed token`,
);
throw new BadRequestException('Token refresh failed');
}
const encryptedAccessToken = this.encryptionService.encrypt(
data.access_token,
EncryptionPurpose.INTEGRATION_TOKENS,
);
const encryptedRefreshToken = data.refresh_token
? this.encryptionService.encrypt(
data.refresh_token,
EncryptionPurpose.INTEGRATION_TOKENS,
)
: current.refreshToken;
const tokenExpiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000)
: null;
const saved = await this.connectionRepo.updateIfTokensMatch(
current.id,
expected,
{
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
invalidatedAt: null,
},
);
if (saved) {
return data.access_token;
}
// Reconnected meanwhile; the response belongs to the old grant.
const accessToken = this.usableAccessToken(
await this.connectionRepo.findById(current.id),
);
if (!accessToken) {
throw new TokenExpiredError();
}
return accessToken;
} catch (err) {
if (err instanceof TokenInvalidError || err instanceof TokenExpiredError) {
throw err;
}
this.logger.error(`Token refresh error: ${(err as Error).message}`);
throw new BadRequestException('Failed to refresh token');
}
}
private async requestTokens(
oauthConfig: OAuthConfig,
type: string,
code: string,
tokenUrl = oauthConfig.tokenUrl,
): Promise<OAuthTokenResponse> {
const clientId = this.getClientId(type, oauthConfig);
const clientSecret = this.getClientSecret(type, oauthConfig);
const basicAuth = oauthConfig.clientAuth === 'basic';
const params = new URLSearchParams({
grant_type: 'authorization_code',
...(basicAuth ? {} : { client_id: clientId, client_secret: clientSecret }),
code,
redirect_uri: this.buildCallbackUrl(type),
});
const response = await this.postTokenRequest(
type,
tokenUrl,
params,
basicAuth ? { clientId, clientSecret } : undefined,
);
if (!response.ok) {
const errorCode = tokenErrorCode(await response.json().catch(() => null));
this.logger.error(
`Token exchange failed for ${type}: ${response.status} ${errorCode}`.trimEnd(),
);
throw new BadRequestException('OAuth token exchange failed');
}
// A parse error would quote the body.
const data: OAuthTokenResponse | null = await response
.json()
.catch(() => null);
if (!data) {
this.logger.error(
`Token exchange for ${type} returned a response that is not JSON`,
);
throw new BadRequestException('OAuth token exchange failed');
}
if (!data.access_token) {
this.logger.error(`Token exchange for ${type} returned no access token`);
throw new BadRequestException('OAuth token exchange failed');
}
if (
!isWellFormedToken(data.access_token) ||
(data.refresh_token && !isWellFormedToken(data.refresh_token))
) {
this.logger.error(`Token exchange for ${type} returned a malformed token`);
throw new BadRequestException('OAuth token exchange failed');
}
return data;
}
private async postTokenRequest(
type: string,
tokenUrl: string,
params: URLSearchParams,
basicAuth?: { clientId: string; clientSecret: string },
): Promise<Response> {
const response = await proxyFetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
...(basicAuth
? {
Authorization: `Basic ${Buffer.from(`${basicAuth.clientId}:${basicAuth.clientSecret}`).toString('base64')}`,
}
: {}),
},
body: params.toString(),
// Never follow: a 3xx would re-POST the client secret and code to the Location target.
redirect: 'manual',
signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS),
});
return readCappedResponse(`${type} token endpoint`, response);
}
buildCallbackUrl(type: string): string {
const appUrl = this.environmentService.getAppUrl();
return `${appUrl}/api/integrations/oauth/${type}/callback`;
}
// State gets its own key so a signature made with APP_SECRET for anything else never verifies here.
private stateKey(): Buffer {
return Buffer.from(
crypto.hkdfSync(
'sha256',
this.environmentService.getAppSecret(),
'',
'integration-oauth-state',
32,
),
);
}
private createSignedState(payload: OAuthStatePayload): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const secret = this.stateKey();
const signature = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
return `${data}.${signature}`;
}
private getClientId(type: string, oauthConfig: OAuthConfig): string {
return this.requireEnv(credentialEnvKey('CLIENT_ID', type, oauthConfig));
}
private getClientSecret(type: string, oauthConfig: OAuthConfig): string {
return this.requireEnv(
credentialEnvKey('CLIENT_SECRET', type, oauthConfig),
);
}
private requireEnv(envKey: string): string {
const value = process.env[envKey];
if (!value) {
throw new BadRequestException(
`Missing environment variable: ${envKey}`,
);
}
return value;
}
}

View file

@ -0,0 +1,6 @@
export const COMPLETION_FALLBACK_REDIRECT =
'/settings/account/connections?error=oauth_failed';
export function nonceCookieName(type: string): string {
return `integration_oauth_${type}`;
}

View file

@ -0,0 +1,9 @@
import { UnfurlPattern } from '../../registry/integration-provider.interface';
export const figmaPatterns: UnfurlPattern[] = [
{
regex:
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
type: 'figma-file',
},
];

View file

@ -0,0 +1,21 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { FigmaProvider } from './figma.provider';
import { FigmaService } from './figma.service';
import { IntegrationRegistry } from '../../registry/integration-registry';
import { IntegrationModule } from '../../integration.module';
@Module({
imports: [IntegrationModule],
providers: [FigmaProvider, FigmaService],
exports: [FigmaProvider],
})
export class FigmaModule implements OnModuleInit {
constructor(
private readonly registry: IntegrationRegistry,
private readonly figmaProvider: FigmaProvider,
) {}
onModuleInit() {
this.registry.register(this.figmaProvider);
}
}

View file

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationProvider,
IntegrationDefinition,
ProviderAccount,
ResolveAccountOpts,
UnfurlOpts,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { FigmaService } from './figma.service';
import { figmaPatterns } from './figma-patterns';
@Injectable()
export class FigmaProvider extends IntegrationProvider {
definition: IntegrationDefinition = {
type: 'figma',
name: 'Figma',
description: 'Link previews for Figma designs, prototypes, and FigJam boards',
icon: 'figma',
capabilities: ['oauth', 'unfurl'],
oauth: {
authUrl: 'https://www.figma.com/oauth',
tokenUrl: 'https://api.figma.com/v1/oauth/token',
refreshUrl: 'https://api.figma.com/v1/oauth/refresh',
scopes: ['file_metadata:read', 'current_user:read'],
clientAuth: 'basic',
},
unfurlPatterns: figmaPatterns,
};
constructor(private readonly figmaService: FigmaService) {
super();
}
async resolveAccount(opts: ResolveAccountOpts): Promise<ProviderAccount> {
return this.figmaService.getAccount(opts.accessToken);
}
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
const { match, accessToken, url } = opts;
const fileType = match[2];
const fileKey = match[3];
return this.figmaService.unfurlFile(accessToken, fileKey, fileType, url);
}
}

View file

@ -0,0 +1,46 @@
import { proxyFetch } from '../../../../common/proxy-fetch';
import { FigmaProvider } from './figma.provider';
import { FigmaService } from './figma.service';
// Mock only proxyFetch so the real providerApiFetch handles statuses.
jest.mock('../../../../common/proxy-fetch', () => ({
proxyFetch: jest.fn(),
}));
const fetchMock = proxyFetch as jest.Mock;
function json(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('Figma connected account', () => {
const provider = new FigmaProvider(new FigmaService());
beforeEach(() => fetchMock.mockReset());
it('asks for the profile scope beside file metadata', () => {
expect(provider.definition.oauth?.scopes).toEqual([
'file_metadata:read',
'current_user:read',
]);
});
it('reads the connected account from /v1/me and keeps no email', async () => {
fetchMock.mockResolvedValue(
json({
id: '1234567890',
handle: 'Philip Okugbe',
email: 'philip@docmost.com',
img_url: 'https://s3-alpha.figma.com/profile/1234567890',
}),
);
await expect(
provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }),
).resolves.toEqual({ id: '1234567890', displayName: 'Philip Okugbe' });
expect(fetchMock.mock.calls[0][0]).toBe('https://api.figma.com/v1/me');
});
});

View file

@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import {
ProviderAccount,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { providerApiFetch } from '../../utils/provider-fetch';
import { toProviderAccount } from '../../utils/integration.utils';
const FIGMA_API = 'https://api.figma.com/v1';
const FILE_TYPE_LABELS: Record<string, string> = {
design: 'Design file',
file: 'Design file',
proto: 'Prototype',
board: 'FigJam board',
};
@Injectable()
export class FigmaService {
async getAccount(accessToken: string): Promise<ProviderAccount> {
const data = await this.apiGet(accessToken, '/me');
return toProviderAccount({ id: data.id, displayName: data.handle });
}
async unfurlFile(
accessToken: string,
fileKey: string,
fileType: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(accessToken, `/files/${fileKey}/meta`);
const file = data.file;
const typeLabel = FILE_TYPE_LABELS[fileType] ?? 'Figma file';
return {
title: file.name,
description: typeLabel,
url,
provider: 'figma',
providerIcon: 'figma',
author: file.last_touched_by?.handle,
authorAvatarUrl: file.last_touched_by?.img_url,
metadata: {
type: fileType,
fileKey,
thumbnailUrl: file.thumbnail_url,
lastModified: file.last_touched_at,
},
};
}
private async apiGet(accessToken: string, path: string): Promise<any> {
const response = await providerApiFetch('Figma', `${FIGMA_API}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
return response.json();
}
}

View file

@ -0,0 +1,143 @@
import { integrationLinkPatterns } from '@docmost/editor-ext';
import { buildGitHubPatterns } from './github-patterns';
function firstMatch(patterns: { type: string; regex: RegExp }[], url: string) {
for (const pattern of patterns) {
const match = url.match(pattern.regex);
if (match) return { type: pattern.type, groups: match.slice(1) };
}
return null;
}
// Each of these matched before the guard; the browser resolves them to a different path.
const RESOLVED_ELSEWHERE = [
'https://github.com/a/b/pull/1/../../../../evil/x/pull/2',
'https://github.com/../user?x=/pull/1',
'https://github.com/a/b/pull/1/%2e%2e/%2E%2E/%2e./.%2e/evil/x/pull/2',
'https://github.com/a/b/pull/1/commits/abc123/../../../../../evil/x/pull/2',
'https://github.com/a/b/issues/1/..',
'https://github.com/a/b/issues/1/..?x=1',
'https://github.com/a/b/commit/abc123/./x',
'https://github.com/a/b/blob/main/../../../evil/x/blob/main/secret.txt',
'https://github.com/a/b/pulls/../../evil/x/pulls',
'https://github.com/a/b/releases/./tag/v1',
'https://github.com/a/b/issues/created_by/../../../evil/x/issues',
'https://github.com/a/b/pull/1\\..\\..\\evil',
'https://github.com/a\\b/c/pull/1',
];
const STILL_MATCHED: [string, string, (string | undefined)[]][] = [
['https://github.com/o/.github/pull/1', 'github-pr', ['o', '.github', '1']],
['https://github.com/o/my..repo/issues/2', 'github-issue', ['o', 'my..repo', '2']],
['https://github.com/o/.../issues/3', 'github-issue', ['o', '...', '3']],
['https://github.com/o/r/pull/1#discussion_r1/../..', 'github-pr', ['o', 'r', '1']],
['https://github.com/o/r/pulls?q=../../x', 'github-pulls-list', ['o', 'r']],
[
'https://github.com/o/r/blob/main/.github/workflows/ci.yml#L1-L3',
'github-file',
['o', 'r', 'main', '.github/workflows/ci.yml', '1', '3'],
],
[
'https://github.com/o/r/blob/main/src/..hidden/a.ts',
'github-file',
['o', 'r', 'main', 'src/..hidden/a.ts', undefined, undefined],
],
['https://github.com/o/r/commit/abc123', 'github-commit', ['o', 'r', 'abc123']],
['https://github.com/o/r', 'github-repo', ['o', 'r']],
['https://github.com/my-org_1/repo.name-x/pull/1', 'github-pr', ['my-org_1', 'repo.name-x', '1']],
];
// The browser opens the repo or owner for these; the rest of the route is query or fragment.
const ROUTE_IN_QUERY_OR_FRAGMENT = [
'https://github.com/o/r?x=/pull/1',
'https://github.com/o/r#x/pull/1',
'https://github.com/o?x=/r/issues/1',
'https://github.com/o/r?/commit/abc123',
'https://github.com/o/r?x=/pull/1/commits/abc123',
'https://github.com/o/r#/blob/main/a.ts',
'https://github.com/o/r?x=/pulls',
'https://github.com/o/r#/releases',
'https://github.com/o/r?x=/issues',
];
describe('buildGitHubPatterns', () => {
const patterns = buildGitHubPatterns('https://github.com');
const byType = (type: string) => patterns.find((p) => p.type === type)!;
const crafted = 'https://github.com/o/r/pulls/' + '?'.repeat(40000) + '\n';
it('keeps the pulls and releases list patterns linear on a crafted input', () => {
for (const type of ['github-pulls-list', 'github-releases-list']) {
const started = process.hrtime.bigint();
expect(byType(type).regex.test(crafted)).toBe(false);
expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100);
}
});
it.each([
['https://github.com/o/r/pulls', 'github-pulls-list'],
['https://github.com/o/r/pulls/', 'github-pulls-list'],
['https://github.com/o/r/pulls?q=is%3Aopen', 'github-pulls-list'],
['https://github.com/o/r/pulls/extra', 'github-pulls-list'],
['https://github.com/o/r/releases', 'github-releases-list'],
['https://github.com/o/r/releases/tag/v1.0.0?x=1', 'github-releases-list'],
])('still matches %s as %s', (url, type) => {
expect(byType(type).regex.test(url)).toBe(true);
});
it('does not match a longer path segment as the list', () => {
expect(byType('github-pulls-list').regex.test('https://github.com/o/r/pullsx')).toBe(false);
});
it.each(ROUTE_IN_QUERY_OR_FRAGMENT)('does not match %s', (url) => {
expect(firstMatch(patterns, url)).toBeNull();
});
describe('paths the browser would resolve elsewhere', () => {
it.each(RESOLVED_ELSEWHERE)('does not match %s', (url) => {
expect(firstMatch(patterns, url)).toBeNull();
});
it.each(STILL_MATCHED)('still matches %s as %s', (url, type, groups) => {
expect(firstMatch(patterns, url)).toEqual({ type, groups });
});
it('guards a GitHub Enterprise host the same way', () => {
const enterprise = buildGitHubPatterns('https://github.acme.com');
expect(
firstMatch(enterprise, 'https://github.acme.com/a/b/pull/1/../../../../evil/x/pull/2'),
).toBeNull();
expect(firstMatch(enterprise, 'https://github.acme.com/../user?x=/pull/1')).toBeNull();
expect(firstMatch(enterprise, 'https://github.acme.com/o/r/pull/1')).toEqual({
type: 'github-pr',
groups: ['o', 'r', '1'],
});
});
it('stays linear on hostile input', () => {
const crafted = [
'https://github.com/' + 'a/'.repeat(30000),
'https://github.com/o/r/pull/1' + '/.x'.repeat(10000),
'https://github.com/o/r/blob/main/' + '%2e'.repeat(10000) + '\n',
];
for (const input of crafted) {
const started = process.hrtime.bigint();
for (const pattern of patterns) pattern.regex.test(input);
expect(Number(process.hrtime.bigint() - started) / 1e6).toBeLessThan(100);
}
});
it.each([
...RESOLVED_ELSEWHERE,
...ROUTE_IN_QUERY_OR_FRAGMENT,
...STILL_MATCHED.map(([url]) => url),
])(
'agrees with the editor patterns on %s',
(url) => {
const editorPatterns = integrationLinkPatterns.filter(
(pattern) => pattern.provider === 'github',
);
expect(firstMatch(editorPatterns, url)).toEqual(firstMatch(patterns, url));
},
);
});
});

View file

@ -0,0 +1,70 @@
import { UnfurlPattern } from '../../registry/integration-provider.interface';
import { CANONICAL_PATH } from '../../utils/canonical-path';
import { escapeForRegex } from '../../utils/integration.utils';
export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] {
const prefix = `^${escapeForRegex(baseUrl)}${CANONICAL_PATH}`;
return [
// Commit within a PR: /:owner/:repo/pull/:num/commits/:sha
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`,
),
type: 'github-pr-commit',
},
// PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)?
{
regex: new RegExp(`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pull\\/(\\d+)`),
type: 'github-pr',
},
// Single issue: /:owner/:repo/issues/:num
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues\\/(\\d+)`,
),
type: 'github-issue',
},
// Commit: /:owner/:repo/commit(s)/:sha
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/commits?\\/([a-f0-9]+)`,
),
type: 'github-commit',
},
// File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))?
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`,
),
type: 'github-file',
},
// Pulls list: /:owner/:repo/pulls
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/pulls(?:[\\/?].*)?$`,
),
type: 'github-pulls-list',
},
// Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)?
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`,
),
type: 'github-issues-list',
},
// Releases: /:owner/:repo/releases
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/releases(?:[\\/?].*)?$`,
),
type: 'github-releases-list',
},
// Repo: /:owner/:repo
{
regex: new RegExp(
`${prefix}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/?$`,
),
type: 'github-repo',
},
];
}

View file

@ -0,0 +1,21 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { GitHubProvider } from './github.provider';
import { GitHubService } from './github.service';
import { IntegrationRegistry } from '../../registry/integration-registry';
import { IntegrationModule } from '../../integration.module';
@Module({
imports: [IntegrationModule],
providers: [GitHubProvider, GitHubService],
exports: [GitHubProvider],
})
export class GitHubModule implements OnModuleInit {
constructor(
private readonly registry: IntegrationRegistry,
private readonly githubProvider: GitHubProvider,
) {}
onModuleInit() {
this.registry.register(this.githubProvider);
}
}

View file

@ -0,0 +1,167 @@
import { Logger } from '@nestjs/common';
import { GitHubProvider } from './github.provider';
import { IntegrationRegistry } from '../../registry/integration-registry';
import { UnfurlPattern } from '../../registry/integration-provider.interface';
const GHE_BASE = 'https://github.acme.com';
const PUBLIC_PR = 'https://github.com/o/r/pull/1';
const GHE_PR = `${GHE_BASE}/o/r/pull/1`;
function buildProvider() {
const unfurlPullRequest = jest.fn().mockResolvedValue({});
const provider = new GitHubProvider({ unfurlPullRequest } as any);
return { provider, unfurlPullRequest };
}
function firstMatch(patterns: UnfurlPattern[], url: string) {
for (const pattern of patterns) {
const match = url.match(pattern.regex);
if (match) return { match, patternType: pattern.type };
}
return null;
}
function matches(provider: GitHubProvider, url: string): boolean {
return firstMatch(provider.getUnfurlPatterns({}), url) !== null;
}
async function unfurlWith(provider: GitHubProvider, url: string) {
const found = firstMatch(provider.getUnfurlPatterns({}), url)!;
await provider.unfurl({ ...found, url, accessToken: 'token' } as any);
}
describe('GitHubProvider host matching', () => {
const original = process.env.INTEGRATION_GITHUB_URL;
beforeEach(() => delete process.env.INTEGRATION_GITHUB_URL);
afterEach(() => {
if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL;
else process.env.INTEGRATION_GITHUB_URL = original;
});
it('declares no static unfurl patterns', () => {
const { provider } = buildProvider();
expect(provider.definition.unfurlPatterns).toBeUndefined();
const registry = new IntegrationRegistry();
registry.register(provider);
expect(registry.findUnfurlProvider(PUBLIC_PR)).toBeNull();
});
describe('github.com (no INTEGRATION_GITHUB_URL)', () => {
it('matches github.com links', () => {
const { provider } = buildProvider();
expect(matches(provider, PUBLIC_PR)).toBe(true);
expect(matches(provider, 'https://github.com/o/r')).toBe(true);
expect(matches(provider, GHE_PR)).toBe(false);
});
it('calls api.github.com', async () => {
const { provider, unfurlPullRequest } = buildProvider();
await unfurlWith(provider, PUBLIC_PR);
expect(unfurlPullRequest).toHaveBeenCalledWith(
'token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR,
);
});
});
describe('GitHub Enterprise (INTEGRATION_GITHUB_URL set)', () => {
beforeEach(() => {
process.env.INTEGRATION_GITHUB_URL = GHE_BASE;
});
it('does not match github.com links', () => {
const { provider } = buildProvider();
expect(matches(provider, PUBLIC_PR)).toBe(false);
expect(matches(provider, 'https://github.com/o/r')).toBe(false);
});
it('matches links on the configured host', () => {
const { provider } = buildProvider();
expect(matches(provider, GHE_PR)).toBe(true);
expect(matches(provider, `${GHE_BASE}/o/r`)).toBe(true);
});
it('sends the token to the configured instance api', async () => {
const { provider, unfurlPullRequest } = buildProvider();
await unfurlWith(provider, GHE_PR);
expect(unfurlPullRequest).toHaveBeenCalledWith(
'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR,
);
});
it.each(['https://GitHub.Acme.com', 'https://github.acme.com/', 'HTTPS://GITHUB.ACME.COM//'])(
'normalizes %s to match lowercase pasted links',
async (configured) => {
process.env.INTEGRATION_GITHUB_URL = configured;
const { provider, unfurlPullRequest } = buildProvider();
expect(matches(provider, GHE_PR)).toBe(true);
await unfurlWith(provider, GHE_PR);
expect(unfurlPullRequest.mock.calls[0][1]).toBe(`${GHE_BASE}/api/v3`);
},
);
});
describe('INTEGRATION_GITHUB_URL validation', () => {
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => warn.mockRestore());
it.each(['github.acme.com', 'github.acme.com:8443', 'ftp://github.acme.com'])(
'warns once and falls back to github.com for %s',
async (configured) => {
process.env.INTEGRATION_GITHUB_URL = configured;
const { provider, unfurlPullRequest } = buildProvider();
expect(matches(provider, PUBLIC_PR)).toBe(true);
expect(matches(provider, GHE_PR)).toBe(false);
expect(provider.getOAuthConfig({})).toMatchObject({
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
});
await unfurlWith(provider, PUBLIC_PR);
expect(unfurlPullRequest.mock.calls[0][1]).toBe('https://api.github.com');
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to https://github.com',
);
},
);
it('does not warn for a valid url', async () => {
process.env.INTEGRATION_GITHUB_URL = GHE_BASE;
const { provider } = buildProvider();
provider.getOAuthConfig({});
await unfurlWith(provider, GHE_PR);
expect(warn).not.toHaveBeenCalled();
});
});
describe('getUnfurlHosts', () => {
it('reports github.com when INTEGRATION_GITHUB_URL is unset', () => {
const { provider } = buildProvider();
expect(provider.getUnfurlHosts({})).toEqual(['github.com']);
});
it('reports the configured host', () => {
process.env.INTEGRATION_GITHUB_URL = GHE_BASE;
const { provider } = buildProvider();
expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']);
});
it.each(['https://GitHub.Acme.com', 'https://github.acme.com/'])(
'reports %s as a lowercase hostname',
(configured) => {
process.env.INTEGRATION_GITHUB_URL = configured;
const { provider } = buildProvider();
expect(provider.getUnfurlHosts({})).toEqual(['github.acme.com']);
},
);
});
});

View file

@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import {
IntegrationProvider,
IntegrationDefinition,
LinkDescription,
OAuthConfig,
ProviderAccount,
ResolveAccountOpts,
UnfurlPattern,
UnfurlOpts,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { GitHubService } from './github.service';
import { buildGitHubPatterns } from './github-patterns';
import { normalizeBaseUrl } from '../../utils/integration.utils';
const DEFAULT_BASE_URL = 'https://github.com';
@Injectable()
export class GitHubProvider extends IntegrationProvider {
private readonly logger = new Logger(GitHubProvider.name);
definition: IntegrationDefinition = {
type: 'github',
name: 'GitHub',
description: 'Link previews for repos, pull requests, issues, commits, and files',
icon: 'github',
capabilities: ['oauth', 'unfurl'],
oauth: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
scopes: ['repo', 'read:user'],
},
};
constructor(private readonly githubService: GitHubService) {
super();
const configured = process.env.INTEGRATION_GITHUB_URL;
if (configured && !normalizeBaseUrl(configured)) {
this.logger.warn(
`INTEGRATION_GITHUB_URL is not a valid http(s) URL; falling back to ${DEFAULT_BASE_URL}`,
);
}
}
getOAuthConfig(settings: Record<string, any>): OAuthConfig {
const baseUrl = this.resolveBaseUrl();
return {
authUrl: `${baseUrl}/login/oauth/authorize`,
tokenUrl: `${baseUrl}/login/oauth/access_token`,
scopes: ['repo', 'read:user'],
};
}
// Configured host only, so a GitHub Enterprise token never reaches github.com.
getUnfurlPatterns(settings: Record<string, any>): UnfurlPattern[] {
return buildGitHubPatterns(this.resolveBaseUrl());
}
getUnfurlHosts(settings: Record<string, any>): string[] {
return [new URL(this.resolveBaseUrl()).hostname];
}
async resolveAccount(opts: ResolveAccountOpts): Promise<ProviderAccount> {
return this.githubService.getAccount(
opts.accessToken,
this.resolveApiBaseUrl(),
);
}
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
const { match, patternType, accessToken, url } = opts;
const apiBaseUrl = this.resolveApiBaseUrl();
const owner = match[1];
const repo = match[2];
switch (patternType) {
case 'github-pr': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlPullRequest(
accessToken, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-issue': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlIssue(
accessToken, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-repo':
return this.githubService.unfurlRepo(
accessToken, apiBaseUrl, owner, repo, url,
);
case 'github-commit': {
const sha = match[3];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
);
}
case 'github-pr-commit': {
const sha = match[4];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
);
}
case 'github-file': {
const ref = match[3];
const path = match[4];
const startLine = match[5] ? parseInt(match[5], 10) : undefined;
const endLine = match[6] ? parseInt(match[6], 10) : undefined;
return this.githubService.unfurlFile(
owner, repo, ref, path, startLine, endLine, url,
);
}
case 'github-pulls-list':
case 'github-issues-list':
case 'github-releases-list':
return this.githubService.unfurlCollectionPage(
accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
);
default:
throw new Error(`Unknown GitHub pattern type: ${patternType}`);
}
}
describeLink(
patternType: string,
match: RegExpMatchArray,
): LinkDescription | null {
const repo = `${match[1]}/${match[2]}`;
switch (patternType) {
case 'github-pr':
return { title: `Pull Request #${match[3]}`, description: repo };
case 'github-pr-commit':
return { title: `Commit ${match[4].slice(0, 7)}`, description: repo };
case 'github-issue':
return { title: `Issue #${match[3]}`, description: repo };
case 'github-commit':
return { title: `Commit ${match[3].slice(0, 7)}`, description: repo };
case 'github-file':
return { title: match[4], description: repo };
case 'github-pulls-list':
return { title: 'Pull Requests', description: repo };
case 'github-issues-list':
return { title: 'Issues', description: repo };
case 'github-releases-list':
return { title: 'Releases', description: repo };
case 'github-repo':
return { title: repo };
default:
return null;
}
}
private resolveBaseUrl(): string {
const baseUrl = process.env.INTEGRATION_GITHUB_URL;
return (baseUrl && normalizeBaseUrl(baseUrl)) || DEFAULT_BASE_URL;
}
private resolveApiBaseUrl(): string {
const baseUrl = this.resolveBaseUrl();
return baseUrl === DEFAULT_BASE_URL
? 'https://api.github.com'
: `${baseUrl}/api/v3`;
}
}

View file

@ -0,0 +1,146 @@
import { proxyFetch } from '../../../../common/proxy-fetch';
import { UnfurlForbiddenError } from '../../registry/integration-provider.interface';
import { GitHubProvider } from './github.provider';
import { GitHubService } from './github.service';
// Mock only proxyFetch so the real providerApiFetch handles statuses.
jest.mock('../../../../common/proxy-fetch', () => ({
proxyFetch: jest.fn(),
}));
const fetchMock = proxyFetch as jest.Mock;
const API = 'https://api.github.com';
const ENTERPRISE_API = 'https://github.acme.com/api/v3';
const PAGE = 'https://github.com/o/r/pull/1';
const WHEN = '2026-10-01T10:00:00.000Z';
const BODY = {
number: 1,
title: 'Add dark mode',
created_at: WHEN,
updated_at: WHEN,
full_name: 'o/r',
sha: 'abc1234',
commit: { message: 'Fix header', author: { date: WHEN } },
};
const service = new GitHubService();
const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise<unknown>, string][] = [
['pull request', (api, owner, repo) => service.unfurlPullRequest('token', api, owner, repo, 1, PAGE), '/pulls/1'],
['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'],
['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''],
['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'],
['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''],
];
function requestedUrls(): string[] {
return fetchMock.mock.calls.map(([url]) => url);
}
describe('GitHubService API paths', () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify(BODY), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
it.each(REQUESTS)('requests a %s under the plain repo path', async (_, request, suffix) => {
await request(API, 'o', 'r');
expect(requestedUrls()).toEqual([`${API}/repos/o/r${suffix}`]);
});
it.each(REQUESTS)(
'percent-encodes owner and repo for a %s, so neither can add a query or path',
async (_, request, suffix) => {
await request(API, 'a b', 'r?x=/#');
const requested = new URL(requestedUrls()[0]);
expect(requested.pathname).toBe(`/repos/a%20b/r%3Fx%3D%2F%23${suffix}`);
expect(requested.search).toBe('');
},
);
it.each(REQUESTS)(
'keeps an encoded dot segment for a %s inside the Enterprise api root',
async (_, request, suffix) => {
await request(ENTERPRISE_API, '%2e%2e', '..x');
expect(new URL(requestedUrls()[0]).pathname).toBe(
`/api/v3/repos/%252e%252e/..x${suffix}`,
);
},
);
describe.each([
['.', 'r'],
['..', 'r'],
['o', '.'],
['o', '..'],
])('with owner %j and repo %j', (owner, repo) => {
it.each(REQUESTS)('refuses a %s without calling GitHub', async (_, request) => {
await expect(request(ENTERPRISE_API, owner, repo)).rejects.toBeInstanceOf(
UnfurlForbiddenError,
);
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
describe('GitHub connected account', () => {
const original = process.env.INTEGRATION_GITHUB_URL;
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
id: 583231,
login: 'Philipinho',
name: 'Philip Okugbe',
email: 'philip@docmost.com',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
});
afterEach(() => {
if (original === undefined) delete process.env.INTEGRATION_GITHUB_URL;
else process.env.INTEGRATION_GITHUB_URL = original;
});
it('reads the account from api.github.com and keeps no email', async () => {
delete process.env.INTEGRATION_GITHUB_URL;
const provider = new GitHubProvider(new GitHubService());
await expect(
provider.resolveAccount({ accessToken: 'token', tokenResponse: {} }),
).resolves.toEqual({
id: '583231',
displayName: 'Philip Okugbe',
username: 'Philipinho',
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.github.com/user');
expect(init.headers.Authorization).toBe('Bearer token');
});
it('reads the account from the GitHub Enterprise API', async () => {
process.env.INTEGRATION_GITHUB_URL = 'https://github.acme.com';
const provider = new GitHubProvider(new GitHubService());
await provider.resolveAccount({ accessToken: 'token', tokenResponse: {} });
expect(fetchMock.mock.calls[0][0]).toBe(
'https://github.acme.com/api/v3/user',
);
});
});

View file

@ -0,0 +1,282 @@
import { Injectable } from '@nestjs/common';
import {
ProviderAccount,
UnfurlResult,
} from '../../registry/integration-provider.interface';
import { relativeTime } from '../../utils/relative-time';
import { providerApiFetch } from '../../utils/provider-fetch';
import { toProviderAccount } from '../../utils/integration.utils';
import { repoApiPath } from './github.utils';
@Injectable()
export class GitHubService {
async getAccount(
accessToken: string,
apiBaseUrl: string,
): Promise<ProviderAccount> {
const data = await this.apiGet(accessToken, apiBaseUrl, '/user');
return toProviderAccount({
id: data.id,
displayName: data.name,
username: data.login,
});
}
async unfurlPullRequest(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
number: number,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`${repoApiPath(owner, repo)}/pulls/${number}`,
);
const prAuthor = data.user?.login;
const prDesc = [
`#${data.number}`,
relativeTime(data.updated_at ?? data.created_at),
prAuthor,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: prDesc,
url,
provider: 'github',
providerIcon: 'github',
status: this.formatPrStatus(data),
statusColor: this.getPrStatusColor(data),
author: prAuthor,
authorAvatarUrl: data.user?.avatar_url,
metadata: {
type: 'pr',
number: data.number,
repo: `${owner}/${repo}`,
labels: data.labels?.map((l: any) => l.name) ?? [],
draft: data.draft,
additions: data.additions,
deletions: data.deletions,
},
};
}
async unfurlIssue(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
number: number,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`${repoApiPath(owner, repo)}/issues/${number}`,
);
const issueAuthor = data.user?.login;
const issueDesc = [
`#${data.number}`,
relativeTime(data.updated_at ?? data.created_at),
issueAuthor,
].filter(Boolean).join(' · ');
return {
title: data.title,
description: issueDesc,
url,
provider: 'github',
providerIcon: 'github',
status: data.state,
statusColor: data.state === 'open' ? 'green' : 'violet',
author: issueAuthor,
authorAvatarUrl: data.user?.avatar_url,
metadata: {
type: 'issue',
number: data.number,
repo: `${owner}/${repo}`,
labels: data.labels?.map((l: any) => l.name) ?? [],
assignees: data.assignees?.map((a: any) => a.login) ?? [],
},
};
}
async unfurlRepo(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
repoApiPath(owner, repo),
);
const visibility = data.private ? 'Private' : 'Public';
return {
title: data.full_name,
description: data.description?.slice(0, 200) ?? undefined,
url,
provider: 'github',
providerIcon: 'github',
status: visibility,
statusColor: data.private ? 'gray' : 'green',
author: data.owner?.login,
authorAvatarUrl: data.owner?.avatar_url,
metadata: {
type: 'repo',
repo: `${owner}/${repo}`,
stars: data.stargazers_count,
forks: data.forks_count,
language: data.language,
defaultBranch: data.default_branch,
},
};
}
async unfurlCommit(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
sha: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
`${repoApiPath(owner, repo)}/commits/${sha}`,
);
const shortSha = data.sha?.slice(0, 7);
const commitAuthor = data.author?.login ?? data.commit?.author?.name;
const commitDesc = [
shortSha,
relativeTime(data.commit?.author?.date ?? data.commit?.committer?.date),
commitAuthor,
].filter(Boolean).join(' · ');
return {
title: data.commit?.message?.split('\n')[0] ?? shortSha,
description: commitDesc,
url,
provider: 'github',
providerIcon: 'github',
author: commitAuthor,
authorAvatarUrl: data.author?.avatar_url,
metadata: {
type: 'commit',
sha: data.sha,
shortSha,
repo: `${owner}/${repo}`,
stats: data.stats,
},
};
}
unfurlFile(
owner: string,
repo: string,
ref: string,
path: string,
startLine: number | undefined,
endLine: number | undefined,
url: string,
): UnfurlResult {
const fileName = path.split('/').pop() ?? path;
const lineRange = startLine
? endLine
? `L${startLine}-L${endLine}`
: `L${startLine}`
: undefined;
return {
title: lineRange ? `${fileName}#${lineRange}` : fileName,
description: `${owner}/${repo} · ${ref.slice(0, 7)}`,
url,
provider: 'github',
providerIcon: 'github',
metadata: {
type: 'file',
repo: `${owner}/${repo}`,
ref,
path,
startLine,
endLine,
},
};
}
async unfurlCollectionPage(
accessToken: string,
apiBaseUrl: string,
owner: string,
repo: string,
collectionType: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
apiBaseUrl,
repoApiPath(owner, repo),
);
const labels: Record<string, string> = {
'pulls-list': 'Pull Requests',
'issues-list': 'Issues',
'releases-list': 'Releases',
};
return {
title: `${labels[collectionType] ?? collectionType} · ${data.full_name}`,
description: `${owner}/${repo}`,
url,
provider: 'github',
providerIcon: 'github',
author: data.owner?.login,
authorAvatarUrl: data.owner?.avatar_url,
metadata: {
type: collectionType,
repo: `${owner}/${repo}`,
},
};
}
private formatPrStatus(pr: any): string {
if (pr.merged) return 'merged';
if (pr.draft) return 'draft';
return pr.state;
}
private getPrStatusColor(pr: any): string {
if (pr.merged) return 'violet';
if (pr.draft) return 'gray';
if (pr.state === 'open') return 'green';
return 'red';
}
private async apiGet(
accessToken: string,
apiBaseUrl: string,
path: string,
): Promise<any> {
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Docmost',
},
});
return response.json();
}
}

View file

@ -0,0 +1,9 @@
import { UnfurlForbiddenError } from '../../registry/integration-provider.interface';
export function repoApiPath(owner: string, repo: string): string {
// encodeURIComponent keeps . and .., which the URL parser would resolve out of the path.
if ([owner, repo].some((name) => name === '.' || name === '..')) {
throw new UnfurlForbiddenError('GitHub repository path has a dot segment');
}
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
}

View file

@ -0,0 +1,55 @@
import { integrationLinkPatterns } from '@docmost/editor-ext';
import { linearPatterns } from './linear-patterns';
function firstMatch(url: string) {
for (const pattern of linearPatterns) {
const match = url.match(pattern.regex);
if (match) return { type: pattern.type, groups: match.slice(1) };
}
return null;
}
const MATCHES: [string, string, (string | undefined)[]][] = [
['https://linear.app/acme/issue/ABC-1', 'linear-issue', ['acme', 'ABC-1', undefined]],
['https://linear.app/acme/issue/ABC-1/fix-the-login', 'linear-issue', ['acme', 'ABC-1', 'fix-the-login']],
['https://linear.app/acme/issue/ABC-1#comment-1/../..', 'linear-issue', ['acme', 'ABC-1', undefined]],
['https://linear.app/acme/issue/ABC-1?q=../..', 'linear-issue', ['acme', 'ABC-1', undefined]],
['https://linear.app/acme/project/mobile-app-1b9607f47174/overview', 'linear-project', ['acme', 'mobile-app-1b9607f47174']],
['https://linear.app/acme/initiative/q4-goals-8a7b6c5d4e3f', 'linear-initiative', ['acme', 'q4-goals-8a7b6c5d4e3f']],
['https://linear.app/acme/view/..x', 'linear-view', ['acme', '..x']],
];
// Each of these matched before the guard; the browser resolves them to a different path.
const NON_MATCHES = [
'https://linear.app/acme/issue/ABC-1/../../../evil/issue/XYZ-2',
'https://linear.app/acme/issue/ABC-1/%2e%2e/%2E%2e/%2e./evil/issue/XYZ-2',
'https://linear.app/acme/issue/ABC-1/slug/..',
'https://linear.app/acme/project/p/../../../evil/project/q',
'https://linear.app/acme/initiative/i\\..\\..\\evil',
'https://linear.app/./view/v1',
'https://linear.app/acme/view/v1/.',
'https://linear.app/acme/view/v1/..?x=1',
];
describe('linearPatterns', () => {
it.each(MATCHES)('matches %s as %s', (url, type, groups) => {
expect(firstMatch(url)).toEqual({ type, groups });
});
it.each(NON_MATCHES)('does not match %s', (url) => {
expect(firstMatch(url)).toBeNull();
});
it('are the four patterns the editor matches pasted links with, in the same order', () => {
const shape = ({ type, regex }: { type: string; regex: RegExp }) => ({
type,
source: regex.source,
flags: regex.flags,
});
expect(linearPatterns).toHaveLength(4);
expect(
integrationLinkPatterns.filter((pattern) => pattern.provider === 'linear').map(shape),
).toEqual(linearPatterns.map(shape));
});
});

View file

@ -0,0 +1,29 @@
import { UnfurlPattern } from '../../registry/integration-provider.interface';
import { CANONICAL_PATH } from '../../utils/canonical-path';
const LINEAR_APP = String.raw`^https?:\/\/${CANONICAL_PATH}linear\.app`;
export const linearPatterns: UnfurlPattern[] = [
// Issue: /:team/issue/:KEY-123(/:title-slug)?(#comment)?
{
regex: new RegExp(
String.raw`${LINEAR_APP}\/([^\/]+)\/issue\/([A-Z]+-\d+)(?:\/([^\/?#]+))?`,
),
type: 'linear-issue',
},
// Project: /:team/project/:slug(/:tab)?
{
regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/project\/([^\/]+)`),
type: 'linear-project',
},
// Initiative: /:team/initiative/:slug(/:tab)?
{
regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/initiative\/([^\/]+)`),
type: 'linear-initiative',
},
// View: /:team/view/:id(/:tab)?
{
regex: new RegExp(String.raw`${LINEAR_APP}\/([^\/]+)\/view\/([^\/]+)`),
type: 'linear-view',
},
];

View file

@ -0,0 +1,21 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { LinearProvider } from './linear.provider';
import { LinearService } from './linear.service';
import { IntegrationRegistry } from '../../registry/integration-registry';
import { IntegrationModule } from '../../integration.module';
@Module({
imports: [IntegrationModule],
providers: [LinearProvider, LinearService],
exports: [LinearProvider],
})
export class LinearModule implements OnModuleInit {
constructor(
private readonly registry: IntegrationRegistry,
private readonly linearProvider: LinearProvider,
) {}
onModuleInit() {
this.registry.register(this.linearProvider);
}
}

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