feat: add GitHub, GitLab, Jira, Linear, Figma, Azure Devops integrations (#2547)
This commit is contained in:
parent
b81872c003
commit
6852e0427e
152 changed files with 15891 additions and 142 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
29
apps/client/src/components/icons/azure-devops-icon.tsx
Normal file
29
apps/client/src/components/icons/azure-devops-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/client/src/components/icons/github-icon.tsx
Normal file
22
apps/client/src/components/icons/github-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/client/src/components/icons/gitlab-icon.tsx
Normal file
32
apps/client/src/components/icons/gitlab-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/client/src/components/icons/google-docs-icon.tsx
Normal file
28
apps/client/src/components/icons/google-docs-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
38
apps/client/src/components/icons/jira-icon.tsx
Normal file
38
apps/client/src/components/icons/jira-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/client/src/components/icons/linear-icon.tsx
Normal file
21
apps/client/src/components/icons/linear-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
apps/client/src/components/icons/slack-icon.tsx
Normal file
32
apps/client/src/components/icons/slack-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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) && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/client/src/features/integration/pages/connections.tsx
Normal file
271
apps/client/src/features/integration/pages/connections.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/client/src/features/integration/pages/integrations.tsx
Normal file
123
apps/client/src/features/integration/pages/integrations.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
apps/client/src/features/integration/utils/connect-prompt.ts
Normal file
22
apps/client/src/features/integration/utils/connect-prompt.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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))) {
|
||||||
|
|
|
||||||
64
apps/server/src/common/proxy-fetch.spec.ts
Normal file
64
apps/server/src/common/proxy-fetch.spec.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/server/src/common/proxy-fetch.ts
Normal file
37
apps/server/src/common/proxy-fetch.ts
Normal 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);
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
57
apps/server/src/core/integration/dto/integration.dto.spec.ts
Normal file
57
apps/server/src/core/integration/dto/integration.dto.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/server/src/core/integration/dto/integration.dto.ts
Normal file
45
apps/server/src/core/integration/dto/integration.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
36
apps/server/src/core/integration/dto/unfurl.dto.spec.ts
Normal file
36
apps/server/src/core/integration/dto/unfurl.dto.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/server/src/core/integration/integration.controller.ts
Normal file
78
apps/server/src/core/integration/integration.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/server/src/core/integration/integration.listener.ts
Normal file
29
apps/server/src/core/integration/integration.listener.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/server/src/core/integration/integration.module.ts
Normal file
37
apps/server/src/core/integration/integration.module.ts
Normal 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 {}
|
||||||
238
apps/server/src/core/integration/integration.processor.spec.ts
Normal file
238
apps/server/src/core/integration/integration.processor.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
131
apps/server/src/core/integration/integration.processor.ts
Normal file
131
apps/server/src/core/integration/integration.processor.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/server/src/core/integration/integration.service.ts
Normal file
63
apps/server/src/core/integration/integration.service.ts
Normal 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 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/server/src/core/integration/oauth/credential-env.ts
Normal file
11
apps/server/src/core/integration/oauth/credential-env.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/server/src/core/integration/oauth/oauth-connect.spec.ts
Normal file
28
apps/server/src/core/integration/oauth/oauth-connect.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/server/src/core/integration/oauth/oauth-connect.ts
Normal file
9
apps/server/src/core/integration/oauth/oauth-connect.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
553
apps/server/src/core/integration/oauth/oauth.controller.spec.ts
Normal file
553
apps/server/src/core/integration/oauth/oauth.controller.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
333
apps/server/src/core/integration/oauth/oauth.controller.ts
Normal file
333
apps/server/src/core/integration/oauth/oauth.controller.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1327
apps/server/src/core/integration/oauth/oauth.service.spec.ts
Normal file
1327
apps/server/src/core/integration/oauth/oauth.service.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
972
apps/server/src/core/integration/oauth/oauth.service.ts
Normal file
972
apps/server/src/core/integration/oauth/oauth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/server/src/core/integration/oauth/oauth.utils.ts
Normal file
6
apps/server/src/core/integration/oauth/oauth.utils.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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
Loading…
Reference in a new issue