This commit is contained in:
Philipinho
2026-08-10 03:41:54 +01:00
parent 311b511afb
commit 9f279d191c
29 changed files with 381 additions and 143 deletions
+1
View File
@@ -22,4 +22,5 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
} as const;
@@ -26,15 +26,14 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed + enabled providers get card treatment; anything else pastes
// as an ordinary link. The cache is prefetched when the page editor mounts;
// Only installed providers get card treatment; anything else pastes as an
// ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
const integration = installed?.find((i) => i.type === provider);
return Boolean(integration?.isEnabled);
return Boolean(installed?.some((i) => i.type === provider));
}
export const handlePaste = (
@@ -13,6 +13,18 @@
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 {
display: inline-flex;
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) {
const { node } = props;
const { url, provider } = node.attrs;
@@ -331,6 +410,10 @@ function IntegrationLinkView(props: any) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
@@ -60,10 +60,26 @@ export default function ConnectionRow({
<>
{connection ? (
<>
<Text size="xs" c="green">
{t("Connected")}
{connection.providerUserId && ` (${connection.providerUserId})`}
</Text>
{connection.invalidatedAt ? (
<>
<Text size="xs" c="orange">
{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
size="xs"
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 {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
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 = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
export default function IntegrationRow({
@@ -19,10 +29,12 @@ export default function IntegrationRow({
installation,
onInstall,
onUninstall,
onToggle,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return (
<Box
@@ -40,6 +52,11 @@ export default function IntegrationRow({
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
@@ -54,31 +71,25 @@ export default function IntegrationRow({
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{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
size="xs"
variant="light"
onClick={() => onInstall(definition.type)}
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Install")}
{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>
@@ -81,8 +81,7 @@ export default function Connections() {
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
const installation = installed?.find((i) => i.type === def.type);
return installation?.isEnabled;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
@@ -11,13 +11,12 @@ import {
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
useUpdateIntegrationSettings,
} from "../queries/integration-query";
import { Integration } from "../types/integration.types";
import {
getOAuthAuthorizeUrl,
getOAuthInstallUrl,
} from "../services/integration-service";
import { Integration } from "../types/integration.types";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
@@ -28,7 +27,6 @@ export default function Integrations() {
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const updateMutation = useUpdateIntegrationSettings();
const handleInstall = useCallback(
async (type: string) => {
@@ -50,10 +48,34 @@ export default function Integrations() {
return;
}
// Per-user OAuth providers (Linear, Jira, GitHub, ...): keep existing
// two-step flow — create the integration row, then individual users
// OAuth-connect from /settings/account/connections.
installMutation.mutate({ type });
// Per-user OAuth providers (GitLab, Jira, GitHub, ...): create the
// integration row, then send the installing admin straight into their
// own OAuth so they leave with a working connection. Other members
// 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],
);
@@ -65,16 +87,6 @@ export default function Integrations() {
[uninstallMutation],
);
const handleToggle = useCallback(
(integration: Integration, enabled: boolean) => {
updateMutation.mutate({
integrationId: integration.id,
isEnabled: enabled,
});
},
[updateMutation],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
@@ -115,7 +127,6 @@ export default function Integrations() {
installation={installation}
onInstall={handleInstall}
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() {
return useQuery({
queryKey: ["my-connections"],
@@ -35,15 +35,6 @@ export async function uninstallIntegration(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[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
@@ -14,13 +14,13 @@ export type IntegrationDefinition = {
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresLicense?: boolean;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
isEnabled: boolean;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
@@ -35,9 +35,9 @@ export type ConnectionStatus = {
export type UserConnection = {
integrationId: string;
type: string;
isEnabled: boolean;
providerUserId: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {