mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 04:22:14 +10:00
wip
This commit is contained in:
@@ -45,7 +45,9 @@
|
|||||||
"Date": "Date",
|
"Date": "Date",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
"Initiative": "Initiative",
|
"Initiative": "Initiative",
|
||||||
|
"Last modified by {{name}}": "Last modified by {{name}}",
|
||||||
"Open in Slack": "Open in Slack",
|
"Open in Slack": "Open in Slack",
|
||||||
|
"Paid": "Paid",
|
||||||
"Paste as": "Paste as",
|
"Paste as": "Paste as",
|
||||||
"Project": "Project",
|
"Project": "Project",
|
||||||
"Remove from page": "Remove from page",
|
"Remove from page": "Remove from page",
|
||||||
@@ -217,6 +219,7 @@
|
|||||||
"Theme": "Theme",
|
"Theme": "Theme",
|
||||||
"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",
|
||||||
|
"Toggle {{name}} integration": "Toggle {{name}} integration",
|
||||||
"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",
|
"Unassigned": "Unassigned",
|
||||||
"untitled": "untitled",
|
"untitled": "untitled",
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ 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',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -26,15 +26,14 @@ const ATTACHMENT_NODE_TYPES = [
|
|||||||
|
|
||||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||||
|
|
||||||
// Only installed + enabled providers get card treatment; anything else pastes
|
// Only installed providers get card treatment; anything else pastes as an
|
||||||
// as an ordinary link. The cache is prefetched when the page editor mounts;
|
// ordinary link. The cache is prefetched when the page editor mounts;
|
||||||
// a cold cache also means ordinary link.
|
// a cold cache also means ordinary link.
|
||||||
function isIntegrationInstalled(provider: string): boolean {
|
function isIntegrationInstalled(provider: string): boolean {
|
||||||
const installed = queryClient.getQueryData<Integration[]>([
|
const installed = queryClient.getQueryData<Integration[]>([
|
||||||
"installed-integrations",
|
"installed-integrations",
|
||||||
]);
|
]);
|
||||||
const integration = installed?.find((i) => i.type === provider);
|
return Boolean(installed?.some((i) => i.type === provider));
|
||||||
return Boolean(integration?.isEnabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
|
|||||||
+12
@@ -13,6 +13,18 @@
|
|||||||
background-color: var(--mantine-color-dark-5);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
.mention {
|
.mention {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -215,6 +215,85 @@ function JiraIssueCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FigmaFileCard({
|
||||||
|
url,
|
||||||
|
unfurlData,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
unfurlData: Record<string, any>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Figma thumbnail links are pre-signed and expire; drop the preview rather
|
||||||
|
// than render a broken image.
|
||||||
|
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="">
|
||||||
|
<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 IntegrationLinkView(props: any) {
|
function IntegrationLinkView(props: any) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { url, provider } = node.attrs;
|
const { url, provider } = node.attrs;
|
||||||
@@ -331,6 +410,10 @@ function IntegrationLinkView(props: any) {
|
|||||||
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
|
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "figma") {
|
||||||
|
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle="">
|
<NodeViewWrapper data-drag-handle="">
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -60,10 +60,26 @@ export default function ConnectionRow({
|
|||||||
<>
|
<>
|
||||||
{connection ? (
|
{connection ? (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" c="green">
|
{connection.invalidatedAt ? (
|
||||||
{t("Connected")}
|
<>
|
||||||
{connection.providerUserId && ` (${connection.providerUserId})`}
|
<Text size="xs" c="orange">
|
||||||
</Text>
|
{t("Connection expired")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
onClick={() => onConnect(definition.type)}
|
||||||
|
>
|
||||||
|
{t("Reconnect")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="green">
|
||||||
|
{t("Connected")}
|
||||||
|
{connection.providerUserId && ` (${connection.providerUserId})`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { Group, Text, Badge, Button, Switch, Box, Stack } from "@mantine/core";
|
import {
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
IntegrationDefinition,
|
IntegrationDefinition,
|
||||||
Integration,
|
Integration,
|
||||||
} from "../types/integration.types";
|
} from "../types/integration.types";
|
||||||
import { getIntegrationIcon } from "./integration-icons";
|
import { getIntegrationIcon } from "./integration-icons";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
type IntegrationRowProps = {
|
type IntegrationRowProps = {
|
||||||
definition: IntegrationDefinition;
|
definition: IntegrationDefinition;
|
||||||
installation?: Integration;
|
installation?: Integration;
|
||||||
onInstall: (type: string) => void;
|
onInstall: (type: string) => void;
|
||||||
onUninstall: (integrationId: string) => void;
|
onUninstall: (integrationId: string) => void;
|
||||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function IntegrationRow({
|
export default function IntegrationRow({
|
||||||
@@ -19,10 +29,12 @@ export default function IntegrationRow({
|
|||||||
installation,
|
installation,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
onToggle,
|
|
||||||
}: IntegrationRowProps) {
|
}: IntegrationRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isInstalled = !!installation;
|
const isInstalled = !!installation;
|
||||||
|
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
|
||||||
|
const locked = !!definition.requiresLicense && !hasAccess;
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -40,6 +52,11 @@ export default function IntegrationRow({
|
|||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{definition.name}
|
{definition.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{locked && (
|
||||||
|
<Badge size="xs" variant="light" color="violet">
|
||||||
|
{t("Paid")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{definition.capabilities.map((cap) => (
|
{definition.capabilities.map((cap) => (
|
||||||
<Badge key={cap} size="xs" variant="light">
|
<Badge key={cap} size="xs" variant="light">
|
||||||
{cap}
|
{cap}
|
||||||
@@ -54,31 +71,25 @@ export default function IntegrationRow({
|
|||||||
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
{isInstalled ? (
|
{isInstalled ? (
|
||||||
<>
|
|
||||||
<Switch
|
|
||||||
checked={installation.isEnabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
onToggle(installation, e.currentTarget.checked)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => onUninstall(installation.id)}
|
|
||||||
>
|
|
||||||
{t("Uninstall")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="subtle"
|
||||||
onClick={() => onInstall(definition.type)}
|
color="red"
|
||||||
|
onClick={() => onUninstall(installation.id)}
|
||||||
>
|
>
|
||||||
{t("Install")}
|
{t("Uninstall")}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Tooltip label={upgradeLabel} disabled={!locked}>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => onInstall(definition.type)}
|
||||||
|
>
|
||||||
|
{t("Install")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ export default function Connections() {
|
|||||||
{available
|
{available
|
||||||
.filter((def) => {
|
.filter((def) => {
|
||||||
if (!def.capabilities.includes("oauth")) return false;
|
if (!def.capabilities.includes("oauth")) return false;
|
||||||
const installation = installed?.find((i) => i.type === def.type);
|
return installed?.some((i) => i.type === def.type);
|
||||||
return installation?.isEnabled;
|
|
||||||
})
|
})
|
||||||
.map((def) => {
|
.map((def) => {
|
||||||
const connection = myConnections?.find(
|
const connection = myConnections?.find(
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ import {
|
|||||||
useInstalledIntegrations,
|
useInstalledIntegrations,
|
||||||
useInstallIntegration,
|
useInstallIntegration,
|
||||||
useUninstallIntegration,
|
useUninstallIntegration,
|
||||||
useUpdateIntegrationSettings,
|
|
||||||
} from "../queries/integration-query";
|
} from "../queries/integration-query";
|
||||||
import { Integration } from "../types/integration.types";
|
|
||||||
import {
|
import {
|
||||||
getOAuthAuthorizeUrl,
|
getOAuthAuthorizeUrl,
|
||||||
getOAuthInstallUrl,
|
getOAuthInstallUrl,
|
||||||
} from "../services/integration-service";
|
} from "../services/integration-service";
|
||||||
|
import { Integration } from "../types/integration.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
export default function Integrations() {
|
export default function Integrations() {
|
||||||
@@ -28,7 +27,6 @@ export default function Integrations() {
|
|||||||
useInstalledIntegrations();
|
useInstalledIntegrations();
|
||||||
const installMutation = useInstallIntegration();
|
const installMutation = useInstallIntegration();
|
||||||
const uninstallMutation = useUninstallIntegration();
|
const uninstallMutation = useUninstallIntegration();
|
||||||
const updateMutation = useUpdateIntegrationSettings();
|
|
||||||
|
|
||||||
const handleInstall = useCallback(
|
const handleInstall = useCallback(
|
||||||
async (type: string) => {
|
async (type: string) => {
|
||||||
@@ -50,10 +48,34 @@ export default function Integrations() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user OAuth providers (Linear, Jira, GitHub, ...): keep existing
|
// Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
|
||||||
// two-step flow — create the integration row, then individual users
|
// integration row, then send the installing admin straight into their
|
||||||
// OAuth-connect from /settings/account/connections.
|
// own OAuth so they leave with a working connection. Other members
|
||||||
installMutation.mutate({ type });
|
// connect for themselves from /settings/account/connections.
|
||||||
|
let integration: Integration;
|
||||||
|
try {
|
||||||
|
integration = await installMutation.mutateAsync({ type });
|
||||||
|
} catch {
|
||||||
|
return; // the mutation reports its own failure
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!definition?.capabilities?.includes("oauth")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { authorizationUrl } = await getOAuthAuthorizeUrl({
|
||||||
|
integrationId: integration.id,
|
||||||
|
returnPath: "/settings/integrations",
|
||||||
|
});
|
||||||
|
window.location.href = authorizationUrl;
|
||||||
|
} catch (err: any) {
|
||||||
|
// The integration stays installed; the admin can connect later.
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
err?.response?.data?.message ??
|
||||||
|
t("Failed to start OAuth connection"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[installMutation, available, t],
|
[installMutation, available, t],
|
||||||
);
|
);
|
||||||
@@ -65,16 +87,6 @@ export default function Integrations() {
|
|||||||
[uninstallMutation],
|
[uninstallMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(integration: Integration, enabled: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
integrationId: integration.id,
|
|
||||||
isEnabled: enabled,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = loadingAvailable || loadingInstalled;
|
const isLoading = loadingAvailable || loadingInstalled;
|
||||||
const error = new URLSearchParams(window.location.search).get("error");
|
const error = new URLSearchParams(window.location.search).get("error");
|
||||||
|
|
||||||
@@ -115,7 +127,6 @@ export default function Integrations() {
|
|||||||
installation={installation}
|
installation={installation}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -57,25 +57,6 @@ export function useUninstallIntegration() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateIntegrationSettings() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.updateIntegrationSettings,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Integration updated successfully") });
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to update integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMyConnections() {
|
export function useMyConnections() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["my-connections"],
|
queryKey: ["my-connections"],
|
||||||
|
|||||||
@@ -35,15 +35,6 @@ export async function uninstallIntegration(data: {
|
|||||||
await api.post("/integrations/uninstall", data);
|
await api.post("/integrations/uninstall", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateIntegrationSettings(data: {
|
|
||||||
integrationId: string;
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}): Promise<Integration> {
|
|
||||||
const req = await api.post<Integration>("/integrations/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMyConnections(): Promise<UserConnection[]> {
|
export async function getMyConnections(): Promise<UserConnection[]> {
|
||||||
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
|
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
|
||||||
return req.data;
|
return req.data;
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export type IntegrationDefinition = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
capabilities: IntegrationCapability[];
|
capabilities: IntegrationCapability[];
|
||||||
oauth?: OAuthConfig;
|
oauth?: OAuthConfig;
|
||||||
|
requiresLicense?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Integration = {
|
export type Integration = {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: string;
|
type: string;
|
||||||
isEnabled: boolean;
|
|
||||||
settings: Record<string, any> | null;
|
settings: Record<string, any> | null;
|
||||||
installedById: string | null;
|
installedById: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -35,9 +35,9 @@ export type ConnectionStatus = {
|
|||||||
export type UserConnection = {
|
export type UserConnection = {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
type: string;
|
type: string;
|
||||||
isEnabled: boolean;
|
|
||||||
providerUserId: string | null;
|
providerUserId: string | null;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
|
invalidatedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UnfurlResult = {
|
export type UnfurlResult = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -28,10 +27,6 @@ export class UpdateIntegrationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IntegrationIdDto {
|
export class IntegrationIdDto {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class IntegrationConnectionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connected: !!connection,
|
connected: !!connection && !connection.invalidatedAt,
|
||||||
providerUserId: connection?.providerUserId ?? undefined,
|
providerUserId: connection?.providerUserId ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -61,9 +61,9 @@ export class IntegrationConnectionService {
|
|||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
integrationId: row.integrationId,
|
integrationId: row.integrationId,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
isEnabled: row.isEnabled,
|
|
||||||
providerUserId: row.providerUserId ?? null,
|
providerUserId: row.providerUserId ?? null,
|
||||||
connectedAt: row.createdAt,
|
connectedAt: row.createdAt,
|
||||||
|
invalidatedAt: row.invalidatedAt ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ import {
|
|||||||
UpdateIntegrationDto,
|
UpdateIntegrationDto,
|
||||||
IntegrationIdDto,
|
IntegrationIdDto,
|
||||||
} from './dto/integration.dto';
|
} from './dto/integration.dto';
|
||||||
|
import { IntegrationRegistry } from './registry/integration-registry';
|
||||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||||
import {
|
import {
|
||||||
WorkspaceCaslAction,
|
WorkspaceCaslAction,
|
||||||
WorkspaceCaslSubject,
|
WorkspaceCaslSubject,
|
||||||
} from '../casl/interfaces/workspace-ability.type';
|
} from '../casl/interfaces/workspace-ability.type';
|
||||||
|
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
|
||||||
|
import { Feature } from '../../common/features';
|
||||||
|
|
||||||
@Controller('integrations')
|
@Controller('integrations')
|
||||||
export class IntegrationController {
|
export class IntegrationController {
|
||||||
@@ -31,8 +34,22 @@ export class IntegrationController {
|
|||||||
private readonly integrationService: IntegrationService,
|
private readonly integrationService: IntegrationService,
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
private readonly connectionService: IntegrationConnectionService,
|
||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
|
private readonly licenseCheckService: LicenseCheckService,
|
||||||
|
private readonly registry: IntegrationRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private assertIntegrationsLicensed(workspace: Workspace) {
|
||||||
|
if (
|
||||||
|
!this.licenseCheckService.hasFeature(
|
||||||
|
workspace.licenseKey,
|
||||||
|
Feature.INTEGRATIONS,
|
||||||
|
workspace.plan,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('This feature requires a valid license');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('available')
|
@Post('available')
|
||||||
@@ -67,6 +84,9 @@ export class IntegrationController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.registry.getProvider(dto.type)?.definition.requiresLicense) {
|
||||||
|
this.assertIntegrationsLicensed(workspace);
|
||||||
|
}
|
||||||
return this.integrationService.install(dto.type, workspace.id, user.id);
|
return this.integrationService.install(dto.type, workspace.id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +132,6 @@ export class IntegrationController {
|
|||||||
|
|
||||||
return this.integrationService.update(dto.integrationId, workspace.id, {
|
return this.integrationService.update(dto.integrationId, workspace.id, {
|
||||||
settings: dto.settings,
|
settings: dto.settings,
|
||||||
isEnabled: dto.isEnabled,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
||||||
|
import { TokenInvalidError } from './registry/integration-provider.interface';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
import { IntegrationRegistry } from './registry/integration-registry';
|
||||||
@@ -35,6 +37,13 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route worker-level errors (e.g. lock renewal after laptop sleep) through
|
||||||
|
// the logger instead of bullmq's raw console.error fallback.
|
||||||
|
@OnWorkerEvent('error')
|
||||||
|
onError(err: Error): void {
|
||||||
|
this.logger.error(`Worker error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleTokenRefresh(): Promise<void> {
|
private async handleTokenRefresh(): Promise<void> {
|
||||||
const connections = await this.connectionRepo.findExpiringTokens(
|
const connections = await this.connectionRepo.findExpiringTokens(
|
||||||
TOKEN_REFRESH_WINDOW_MS,
|
TOKEN_REFRESH_WINDOW_MS,
|
||||||
@@ -55,6 +64,15 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
|
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
|
// Dead credential or orphaned row: retire it so findExpiringTokens stops selecting it.
|
||||||
|
if (
|
||||||
|
err instanceof NotFoundException ||
|
||||||
|
err instanceof TokenInvalidError
|
||||||
|
) {
|
||||||
|
await this.connectionRepo
|
||||||
|
.invalidate(connection.id)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +85,7 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const integrations =
|
const integrations =
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
await this.integrationRepo.findAllByWorkspace(workspaceId);
|
||||||
|
|
||||||
for (const integration of integrations) {
|
for (const integration of integrations) {
|
||||||
const provider = this.registry.getProvider(integration.type);
|
const provider = this.registry.getProvider(integration.type);
|
||||||
@@ -75,12 +93,13 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let connection: IntegrationConnection | undefined;
|
||||||
try {
|
try {
|
||||||
const connections = await this.connectionRepo.findByIntegration(
|
const connections = await this.connectionRepo.findByIntegration(
|
||||||
integration.id,
|
integration.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connection = connections[0];
|
connection = connections[0];
|
||||||
let accessToken: string | undefined;
|
let accessToken: string | undefined;
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
@@ -103,6 +122,11 @@ export class IntegrationProcessor extends WorkerHost {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
|
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
|
if (err instanceof TokenInvalidError && connection) {
|
||||||
|
await this.connectionRepo
|
||||||
|
.invalidate(connection.id)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { IntegrationRepo } from './repos/integration.repo';
|
||||||
|
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||||
|
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
import { IntegrationRegistry } from './registry/integration-registry';
|
||||||
import { Integration } from '@docmost/db/types/entity.types';
|
import { Integration } from '@docmost/db/types/entity.types';
|
||||||
import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
||||||
@@ -11,7 +16,10 @@ import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class IntegrationService {
|
export class IntegrationService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
private readonly integrationRepo: IntegrationRepo,
|
||||||
|
private readonly connectionRepo: IntegrationConnectionRepo,
|
||||||
|
private readonly webhookRepo: IntegrationWebhookRepo,
|
||||||
private readonly registry: IntegrationRegistry,
|
private readonly registry: IntegrationRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -33,7 +41,7 @@ export class IntegrationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<Integration> {
|
): Promise<Integration> {
|
||||||
const provider = this.registry.getProvider(type);
|
const provider = this.registry.getProvider(type);
|
||||||
if (!provider) {
|
if (!provider || provider.definition.hidden) {
|
||||||
throw new BadRequestException(`Unknown integration type: ${type}`);
|
throw new BadRequestException(`Unknown integration type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +67,18 @@ export class IntegrationService {
|
|||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
if (!integration || integration.workspaceId !== workspaceId) {
|
||||||
throw new NotFoundException('Integration not found');
|
throw new NotFoundException('Integration not found');
|
||||||
}
|
}
|
||||||
await this.integrationRepo.softDelete(integrationId);
|
// Delete child rows first so no orphan connections keep feeding the token refresh scheduler.
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
await this.connectionRepo.deleteByIntegration(integrationId, trx);
|
||||||
|
await this.webhookRepo.deleteByIntegration(integrationId, trx);
|
||||||
|
await this.integrationRepo.softDelete(integrationId, trx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
integrationId: string,
|
integrationId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: { settings?: Record<string, any>; isEnabled?: boolean },
|
data: { settings?: Record<string, any> },
|
||||||
): Promise<Integration> {
|
): Promise<Integration> {
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
const integration = await this.integrationRepo.findById(integrationId);
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
if (!integration || integration.workspaceId !== workspaceId) {
|
||||||
@@ -85,7 +98,6 @@ export class IntegrationService {
|
|||||||
|
|
||||||
return this.integrationRepo.update(integrationId, {
|
return this.integrationRepo.update(integrationId, {
|
||||||
...(data.settings !== undefined && { settings: data.settings }),
|
...(data.settings !== undefined && { settings: data.settings }),
|
||||||
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
OAuthInstallDto,
|
OAuthInstallDto,
|
||||||
} from '../dto/integration.dto';
|
} from '../dto/integration.dto';
|
||||||
import { IntegrationConnectionService } from '../integration-connection.service';
|
import { IntegrationConnectionService } from '../integration-connection.service';
|
||||||
|
import { IntegrationRegistry } from '../registry/integration-registry';
|
||||||
|
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||||
|
import { Feature } from '../../../common/features';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller('integrations/oauth')
|
@Controller('integrations/oauth')
|
||||||
export class OAuthController {
|
export class OAuthController {
|
||||||
@@ -32,6 +36,8 @@ export class OAuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly oauthService: OAuthService,
|
private readonly oauthService: OAuthService,
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
private readonly connectionService: IntegrationConnectionService,
|
||||||
|
private readonly licenseCheckService: LicenseCheckService,
|
||||||
|
private readonly registry: IntegrationRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -66,6 +72,19 @@ export class OAuthController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
// This flow creates the integration row on callback success; gate it
|
||||||
|
// like a plain install.
|
||||||
|
if (
|
||||||
|
this.registry.getProvider(dto.type)?.definition.requiresLicense &&
|
||||||
|
!this.licenseCheckService.hasFeature(
|
||||||
|
workspace.licenseKey,
|
||||||
|
Feature.INTEGRATIONS,
|
||||||
|
workspace.plan,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('This feature requires a valid license');
|
||||||
|
}
|
||||||
|
|
||||||
const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
|
const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
|
||||||
dto.type,
|
dto.type,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
||||||
import { relativeTime } from '../../utils/relative-time';
|
import { relativeTime } from '../../utils/relative-time';
|
||||||
|
import { providerApiFetch } from '../../utils/provider-fetch';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GitHubService {
|
export class GitHubService {
|
||||||
@@ -253,7 +254,7 @@ export class GitHubService {
|
|||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/vnd.github.v3+json',
|
Accept: 'application/vnd.github.v3+json',
|
||||||
@@ -261,12 +262,6 @@ export class GitHubService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
||||||
import { relativeTime } from '../../utils/relative-time';
|
import { relativeTime } from '../../utils/relative-time';
|
||||||
|
import { providerApiFetch } from '../../utils/provider-fetch';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GitLabService {
|
export class GitLabService {
|
||||||
@@ -240,19 +241,13 @@ export class GitLabService {
|
|||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await providerApiFetch('GitLab', `${apiBaseUrl}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`GitLab API error: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export type IntegrationDefinition = {
|
|||||||
capabilities: IntegrationCapability[];
|
capabilities: IntegrationCapability[];
|
||||||
oauth?: OAuthConfig;
|
oauth?: OAuthConfig;
|
||||||
unfurlPatterns?: UnfurlPattern[];
|
unfurlPatterns?: UnfurlPattern[];
|
||||||
|
// Kept out of the available list and refused for install; existing
|
||||||
|
// installations keep unfurling.
|
||||||
|
hidden?: boolean;
|
||||||
|
// Install requires the INTEGRATIONS license feature; unset = free.
|
||||||
|
requiresLicense?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConnectedEvent = {
|
export type ConnectedEvent = {
|
||||||
@@ -84,6 +89,26 @@ export class UnfurlForbiddenError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thrown when the provider definitively rejects the stored credential (API 401,
|
||||||
|
// or invalid_grant at the token endpoint). Callers retire the connection.
|
||||||
|
export class TokenInvalidError extends Error {
|
||||||
|
constructor(message = 'Integration credential is no longer valid') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TokenInvalidError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProviderApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly provider: string,
|
||||||
|
readonly status: number,
|
||||||
|
statusText = '',
|
||||||
|
) {
|
||||||
|
super(`${provider} API error: ${status} ${statusText}`.trimEnd());
|
||||||
|
this.name = 'ProviderApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type LinkDescription = {
|
export type LinkDescription = {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export class IntegrationRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAvailableIntegrations(): IntegrationDefinition[] {
|
getAvailableIntegrations(): IntegrationDefinition[] {
|
||||||
return this.getAllProviders().map((p) => p.definition);
|
return this.getAllProviders()
|
||||||
|
.map((p) => p.definition)
|
||||||
|
.filter((definition) => !definition.hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
findUnfurlProvider(
|
findUnfurlProvider(
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class IntegrationConnectionRepo {
|
|||||||
accessToken: connection.accessToken,
|
accessToken: connection.accessToken,
|
||||||
refreshToken: connection.refreshToken,
|
refreshToken: connection.refreshToken,
|
||||||
tokenExpiresAt: connection.tokenExpiresAt,
|
tokenExpiresAt: connection.tokenExpiresAt,
|
||||||
|
invalidatedAt: null,
|
||||||
scopes: connection.scopes,
|
scopes: connection.scopes,
|
||||||
providerUserId: connection.providerUserId,
|
providerUserId: connection.providerUserId,
|
||||||
metadata: connection.metadata,
|
metadata: connection.metadata,
|
||||||
@@ -123,6 +124,7 @@ export class IntegrationConnectionRepo {
|
|||||||
accessToken: input.accessToken,
|
accessToken: input.accessToken,
|
||||||
refreshToken: input.refreshToken ?? null,
|
refreshToken: input.refreshToken ?? null,
|
||||||
tokenExpiresAt: input.tokenExpiresAt ?? null,
|
tokenExpiresAt: input.tokenExpiresAt ?? null,
|
||||||
|
invalidatedAt: null,
|
||||||
scopes: input.scopes ?? null,
|
scopes: input.scopes ?? null,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
},
|
},
|
||||||
@@ -200,9 +202,9 @@ export class IntegrationConnectionRepo {
|
|||||||
.select([
|
.select([
|
||||||
'integrationConnections.integrationId',
|
'integrationConnections.integrationId',
|
||||||
'integrations.type',
|
'integrations.type',
|
||||||
'integrations.isEnabled',
|
|
||||||
'integrationConnections.providerUserId',
|
'integrationConnections.providerUserId',
|
||||||
'integrationConnections.createdAt',
|
'integrationConnections.createdAt',
|
||||||
|
'integrationConnections.invalidatedAt',
|
||||||
])
|
])
|
||||||
.where('integrationConnections.userId', '=', userId)
|
.where('integrationConnections.userId', '=', userId)
|
||||||
.where('integrations.workspaceId', '=', workspaceId)
|
.where('integrations.workspaceId', '=', workspaceId)
|
||||||
@@ -216,10 +218,31 @@ export class IntegrationConnectionRepo {
|
|||||||
const threshold = new Date(Date.now() + expiresBeforeMs);
|
const threshold = new Date(Date.now() + expiresBeforeMs);
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('integrationConnections')
|
.selectFrom('integrationConnections')
|
||||||
.selectAll()
|
.innerJoin(
|
||||||
.where('refreshToken', 'is not', null)
|
'integrations',
|
||||||
.where('tokenExpiresAt', 'is not', null)
|
'integrations.id',
|
||||||
.where('tokenExpiresAt', '<', threshold)
|
'integrationConnections.integrationId',
|
||||||
|
)
|
||||||
|
.selectAll('integrationConnections')
|
||||||
|
.where('integrations.deletedAt', 'is', null)
|
||||||
|
.where('integrationConnections.invalidatedAt', 'is', null)
|
||||||
|
.where('integrationConnections.refreshToken', 'is not', null)
|
||||||
|
.where('integrationConnections.tokenExpiresAt', 'is not', null)
|
||||||
|
.where('integrationConnections.tokenExpiresAt', '<', threshold)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retire a rejected credential: flag for reconnect UX, drop the dead refresh token; no-op if the row is gone.
|
||||||
|
async invalidate(connectionId: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.updateTable('integrationConnections')
|
||||||
|
.set({
|
||||||
|
invalidatedAt: new Date(),
|
||||||
|
refreshToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id', '=', connectionId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,20 +41,6 @@ export class IntegrationRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findEnabledByWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllByWorkspace(
|
async findAllByWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
@@ -91,7 +77,6 @@ export class IntegrationRepo {
|
|||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.columns(['type', 'workspaceId']).doUpdateSet({
|
oc.columns(['type', 'workspaceId']).doUpdateSet({
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
isEnabled: true,
|
|
||||||
installedById: integration.installedById,
|
installedById: integration.installedById,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
@@ -135,7 +120,6 @@ export class IntegrationRepo {
|
|||||||
.selectFrom('integrations')
|
.selectFrom('integrations')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('type', '=', type)
|
.where('type', '=', type)
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where(sql<string>`settings->>${sql.lit(key)}`, '=', value)
|
.where(sql<string>`settings->>${sql.lit(key)}`, '=', value)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
UnfurlResult,
|
UnfurlResult,
|
||||||
UnfurlNeedsConnection,
|
UnfurlNeedsConnection,
|
||||||
UnfurlForbiddenError,
|
UnfurlForbiddenError,
|
||||||
|
TokenInvalidError,
|
||||||
|
ProviderApiError,
|
||||||
IntegrationProvider,
|
IntegrationProvider,
|
||||||
} from '../registry/integration-provider.interface';
|
} from '../registry/integration-provider.interface';
|
||||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||||
@@ -14,6 +16,9 @@ import type { Redis } from 'ioredis';
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
||||||
|
// Transient failures get a short negative cache so a broken provider is not
|
||||||
|
// re-fetched on every view; 404s cache at the normal TTL (the target is gone).
|
||||||
|
const UNFURL_ERROR_CACHE_TTL = 60;
|
||||||
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -66,11 +71,12 @@ export class UnfurlService {
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection || connection.invalidatedAt) {
|
||||||
|
// Dead workspace connections need an admin re-install; members get no card.
|
||||||
if (connectionScope === 'workspace') {
|
if (connectionScope === 'workspace') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Not cached: the card should load as soon as the user connects.
|
// Not cached: the card should load as soon as the user (re)connects.
|
||||||
return this.buildNeedsConnection(
|
return this.buildNeedsConnection(
|
||||||
provider,
|
provider,
|
||||||
integration.id,
|
integration.id,
|
||||||
@@ -105,14 +111,45 @@ export class UnfurlService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Not-authorized is an expected outcome (no card), not an error.
|
// Not-authorized is an expected outcome (no card), not an error.
|
||||||
if (err instanceof UnfurlForbiddenError) {
|
if (err instanceof UnfurlForbiddenError) {
|
||||||
this.logger.debug(`Unfurl not authorized for ${url}`);
|
this.logger.debug(
|
||||||
|
`Unfurl not authorized for ${url}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (err instanceof TokenInvalidError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Retiring connection ${connection.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
await this.connectionRepo
|
||||||
|
.invalidate(connection.id)
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (connectionScope === 'workspace') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Not cached so the card heals the moment the user reconnects.
|
||||||
|
return this.buildNeedsConnection(
|
||||||
|
provider,
|
||||||
|
integration.id,
|
||||||
|
patternType,
|
||||||
|
match,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
}
|
||||||
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
||||||
|
const ttl =
|
||||||
|
err instanceof ProviderApiError && err.status === 404
|
||||||
|
? UNFURL_CACHE_TTL
|
||||||
|
: UNFURL_ERROR_CACHE_TTL;
|
||||||
|
await this.cacheNull(cacheKey, ttl);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cacheNull(cacheKey: string, ttl: number): Promise<void> {
|
||||||
|
await this.redis.set(cacheKey, 'null', 'EX', ttl);
|
||||||
|
}
|
||||||
|
|
||||||
async purgeUserCache(workspaceId: string, userId: string): Promise<void> {
|
async purgeUserCache(workspaceId: string, userId: string): Promise<void> {
|
||||||
const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`;
|
const pattern = `${UNFURL_CACHE_PREFIX}${workspaceId}:${userId}:*`;
|
||||||
try {
|
try {
|
||||||
@@ -167,7 +204,6 @@ export class UnfurlService {
|
|||||||
patternType: string;
|
patternType: string;
|
||||||
integration: {
|
integration: {
|
||||||
id: string;
|
id: string;
|
||||||
isEnabled: boolean;
|
|
||||||
type: string;
|
type: string;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
};
|
};
|
||||||
@@ -178,13 +214,13 @@ export class UnfurlService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
staticResult.provider.definition.type,
|
staticResult.provider.definition.type,
|
||||||
);
|
);
|
||||||
if (integration && integration.isEnabled) {
|
if (integration) {
|
||||||
return { ...staticResult, integration };
|
return { ...staticResult, integration };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const integrations =
|
const integrations =
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
await this.integrationRepo.findAllByWorkspace(workspaceId);
|
||||||
|
|
||||||
for (const integration of integrations) {
|
for (const integration of integrations) {
|
||||||
const provider = this.registry.getProvider(integration.type);
|
const provider = this.registry.getProvider(integration.type);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
)
|
)
|
||||||
.addColumn('type', 'text', (col) => col.notNull())
|
.addColumn('type', 'text', (col) => col.notNull())
|
||||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
|
||||||
.addColumn('settings', 'jsonb')
|
.addColumn('settings', 'jsonb')
|
||||||
.addColumn('installed_by_id', 'uuid', (col) =>
|
.addColumn('installed_by_id', 'uuid', (col) =>
|
||||||
col.references('users.id').onDelete('set null'),
|
col.references('users.id').onDelete('set null'),
|
||||||
@@ -50,6 +49,8 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn('access_token', 'text')
|
.addColumn('access_token', 'text')
|
||||||
.addColumn('refresh_token', 'text')
|
.addColumn('refresh_token', 'text')
|
||||||
.addColumn('token_expires_at', 'timestamptz')
|
.addColumn('token_expires_at', 'timestamptz')
|
||||||
|
// Set when the provider definitively rejects the credential; a reconnect clears it.
|
||||||
|
.addColumn('invalidated_at', 'timestamptz')
|
||||||
.addColumn('scopes', 'text')
|
.addColumn('scopes', 'text')
|
||||||
.addColumn('metadata', 'jsonb')
|
.addColumn('metadata', 'jsonb')
|
||||||
// 'workspace' = one shared bot/app connection per integration (Slack);
|
// 'workspace' = one shared bot/app connection per integration (Slack);
|
||||||
|
|||||||
+1
-1
@@ -510,7 +510,6 @@ export interface Integrations {
|
|||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: string;
|
type: string;
|
||||||
isEnabled: Generated<boolean>;
|
|
||||||
settings: Json | null;
|
settings: Json | null;
|
||||||
installedById: string | null;
|
installedById: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
@@ -527,6 +526,7 @@ export interface IntegrationConnections {
|
|||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
tokenExpiresAt: Timestamp | null;
|
tokenExpiresAt: Timestamp | null;
|
||||||
|
invalidatedAt: Timestamp | null;
|
||||||
scopes: string | null;
|
scopes: string | null;
|
||||||
kind: string;
|
kind: string;
|
||||||
metadata: Json | null;
|
metadata: Json | null;
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 8a2b127afd...592ef10a71
Reference in New Issue
Block a user