feat: Figma integration (#11044)

* OAuth

* store logo

* unfurl support

* refresh token

* support for list

* embed list

* mention menu for all embeds in a list

* multi-level list

* logo

* account level connection

* tsc

* Update Icon.tsx

* coderabbit feedback

* RFC 6749 suggestion

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Hemachandar
2026-01-16 06:57:00 +05:30
committed by GitHub
parent bffd11b593
commit 5584089441
33 changed files with 944 additions and 112 deletions
+6 -2
View File
@@ -203,7 +203,7 @@ RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -212,7 +212,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
# Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -223,6 +223,10 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
+40 -17
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import { EmailIcon, LinkIcon } from "outline-icons";
import { BrowserIcon, EmailIcon, LinkIcon } from "outline-icons";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { EmbedDescriptor } from "@shared/editor/embeds";
@@ -14,6 +14,7 @@ import { determineMentionType, isURLMentionable } from "~/utils/mention";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
type Props = Omit<
SuggestionsMenuProps,
@@ -57,18 +58,6 @@ function useItems({
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const embed = React.useMemo(() => {
if (typeof pastedText === "string") {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
}
return;
}, [embeds, pastedText]);
// single item is pasted.
if (typeof pastedText === "string") {
let mentionType: MentionType | undefined;
@@ -84,6 +73,8 @@ function useItems({
: MentionType.URL;
}
const embed = getMatchingEmbed(embeds, pastedText)?.embed;
return [
{
name: "noop",
@@ -108,14 +99,17 @@ function useItems({
{
name: "embed",
title: t("Embed"),
visible: !!embed,
icon: embed?.icon,
keywords: embed?.keywords,
},
];
}
const linksToMentionType: Record<string, MentionType> = {};
// list is pasted.
// Check if the links can be converted to mentions.
const linksToMentionType: Record<string, MentionType> = {};
const convertibleToMentionList = pastedText.every((text) => {
if (!isUrl(text)) {
return false;
@@ -128,7 +122,7 @@ function useItems({
const mentionType = integration
? determineMentionType({ url, integration })
: undefined;
: MentionType.URL;
if (mentionType) {
linksToMentionType[text] = mentionType;
@@ -137,8 +131,29 @@ function useItems({
return !!mentionType;
});
// don't render the menu when it can't be converted to mention.
if (!convertibleToMentionList) {
// Check if the links can be converted to embeds.
let embedType: string | undefined = undefined;
const convertibleToEmbedList = pastedText.every((text) => {
const embed = getMatchingEmbed(embeds, text)?.embed;
if (!embed) {
return false;
}
embedType = !embedType || embedType === embed.title ? embed.title : "mixed";
return true;
});
const embedIcon =
embedType === "mixed" ? (
<BrowserIcon />
) : (
embeds.find((e) => e.title === embedType)?.icon
);
// don't render the menu when it can't be converted to other types.
if (!convertibleToMentionList && !convertibleToEmbedList) {
return;
}
@@ -151,8 +166,16 @@ function useItems({
{
name: "mention_list",
title: t("Mention"),
visible: !!convertibleToMentionList,
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
{
name: "embed_list",
title: t("Embed"),
visible: !!convertibleToEmbedList,
icon: embedIcon,
attrs: { actorId: user?.id },
},
];
}
+24
View File
@@ -447,6 +447,25 @@ export default class PasteHandler extends Extension {
}
};
// Not a list of embeds technically, but inserts many embeds at once.
private insertEmbedList = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Embed list will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.setMeta(this.key, {
remove: { id: this.placeholderId() },
});
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private handleList(listNode: Node) {
const { view, schema } = this.editor;
const { state } = view;
@@ -547,6 +566,11 @@ export default class PasteHandler extends Extension {
this.insertMentionList();
break;
}
case "embed_list": {
this.hidePasteMenu();
this.insertEmbedList();
break;
}
default:
break;
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.3259 5.24514H9.3371C8.23873 5.24514 7.34832 6.0674 7.34832 7.08171C7.34832 8.09602 8.23873 8.91828 9.3371 8.91828H11.3259V5.24514ZM11.3259 4H12.6742H14.663C16.5061 4 18 5.37972 18 7.08171C18 8.08609 17.4798 8.97825 16.6745 9.54085C17.4798 10.1035 18 10.9956 18 12C18 13.702 16.5061 15.0817 14.663 15.0817C13.9178 15.0817 13.2296 14.8561 12.6742 14.4749V15.0817V16.9183C12.6742 18.6203 11.1801 20 9.3371 20C7.49406 20 6 18.6203 6 16.9183C6 15.9138 6.52029 15.0218 7.32556 14.4591C6.52029 13.8965 6 13.0044 6 12C6 10.9956 6.5203 10.1035 7.32559 9.54086C6.5203 8.97825 6 8.08609 6 7.08171C6 5.37972 7.49406 4 9.3371 4H11.3259ZM12.6742 5.24514V8.91828H14.663C15.7614 8.91828 16.6517 8.09602 16.6517 7.08171C16.6517 6.0674 15.7614 5.24514 14.663 5.24514H12.6742ZM9.3371 13.8366H11.3259V12.0047V12V11.9953V10.1634H9.3371C8.23873 10.1634 7.34832 10.9857 7.34832 12C7.34832 13.0119 8.23447 13.8326 9.32921 13.8366L9.3371 13.8366ZM7.34832 16.9183C7.34832 15.9064 8.23447 15.0856 9.32921 15.0817L9.3371 15.0817H11.3259V16.9183C11.3259 17.9326 10.4355 18.7549 9.3371 18.7549C8.23873 18.7549 7.34832 17.9326 7.34832 16.9183ZM12.6742 11.9963C12.6763 10.9837 13.5659 10.1634 14.663 10.1634C15.7614 10.1634 16.6517 10.9857 16.6517 12C16.6517 13.0143 15.7614 13.8366 14.663 13.8366C13.5659 13.8366 12.6763 13.0163 12.6742 12.0037V11.9963Z"
/>
</svg>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import FigmaIcon from "./Icon";
import { FigmaConnectButton } from "./components/FigmaButton";
import { IntegrationService, IntegrationType } from "@shared/types";
import type Integration from "~/models/Integration";
import Time from "~/components/Time";
function Figma() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
const linkedAccountIntegration = integrations.find({
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
}) as Integration<IntegrationType.LinkedAccount> | undefined;
const figmaAccount = linkedAccountIntegration?.settings?.figma?.account;
return (
<IntegrationScene title="Figma" icon={<FigmaIcon />}>
<Heading>Figma</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Figma to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{error === "unknown" && (
<Notice>
<Trans>
Something went wrong while processing your request. Please try
again.
</Trans>
</Notice>
)}
{env.FIGMA_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Link your {{ appName }} account to Figma to enable previews of
design files you have access to, directly within documents.
</Trans>
</Text>
{linkedAccountIntegration ? (
<List>
<ListItem
small
title={`${figmaAccount?.name} (${figmaAccount?.email})`}
subtitle={
<>
<Trans>Enabled on</Trans>{" "}
<Time
dateTime={linkedAccountIntegration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
}
image={
<TeamLogo
src={
linkedAccountIntegration.settings?.figma?.account
?.avatarUrl
}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={linkedAccountIntegration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?"
)}
/>
}
/>
</List>
) : (
<p>
<FigmaConnectButton icon={<FigmaIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The Figma integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</IntegrationScene>
);
}
export default observer(Figma);
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { FigmaUtils } from "../../shared/FigmaUtils";
export function FigmaConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(FigmaUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
import { createLazyComponent } from "~/components/LazyLoad";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Figma account to Outline to enable rich design file previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "figma",
"name": "Figma",
"priority": 15,
"description": "Adds a Figma integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+99
View File
@@ -0,0 +1,99 @@
import auth from "@server/middlewares/authentication";
import Router from "koa-router";
import * as T from "./schema";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import type { APIContext } from "@server/types";
import validate from "@server/middlewares/validate";
import { FigmaUtils } from "plugins/figma/shared/FigmaUtils";
import { transaction } from "@server/middlewares/transaction";
import Logger from "@server/logging/Logger";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration, IntegrationAuthentication } from "@server/models";
import { addSeconds } from "date-fns";
import { Figma } from "../figma";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
const router = new Router();
router.get(
"figma.callback",
auth({ optional: true }),
validate(T.FigmaCallbackSchema),
apexAuthRedirect<T.FigmaCallbackReq>({
getTeamId: (ctx) => FigmaUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
FigmaUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => FigmaUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.FigmaCallbackReq>) => {
const { code, error } = ctx.input.query;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(FigmaUtils.errorUrl(error));
return;
}
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await Figma.oauthAccess(code!);
const figmaAccount = await Figma.getInstalledAccount(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Figma,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
refreshToken: oauth.refresh_token,
expiresAt: addSeconds(Date.now(), oauth.expires_in),
scopes: FigmaUtils.oauthScopes,
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.LinkedAccount>
>(
{
service: IntegrationService.Figma,
type: IntegrationType.LinkedAccount,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
figma: {
account: {
id: figmaAccount.id,
name: figmaAccount.handle,
email: figmaAccount.email,
avatarUrl: figmaAccount.img_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
await new UploadIntegrationLogoTask().schedule({
integrationId: integration.id,
logoUrl: figmaAccount.img_url,
});
});
ctx.redirect(FigmaUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during Figma OAuth callback", err);
ctx.redirect(FigmaUtils.errorUrl("unknown"));
}
}
);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { BaseSchema } from "@server/routes/api/schema";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
export const FigmaCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
export type FigmaCallbackReq = z.infer<typeof FigmaCallbackSchema>;
+25
View File
@@ -0,0 +1,25 @@
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
import { IsOptional } from "class-validator";
class FigmaPluginEnvironment extends Environment {
/**
* Figma OAuth2 app client id. To enable integration with Figma.
*/
@Public
@IsOptional()
public FIGMA_CLIENT_ID = this.toOptionalString(environment.FIGMA_CLIENT_ID);
/**
* Figma OAuth2 app client secret. To enable integration with Figma.
*/
@IsOptional()
@CannotUseWithout("FIGMA_CLIENT_ID")
public FIGMA_CLIENT_SECRET = this.toOptionalString(
environment.FIGMA_CLIENT_SECRET
);
}
export default new FigmaPluginEnvironment();
+208
View File
@@ -0,0 +1,208 @@
import { z } from "zod";
import env from "./env";
import { FigmaUtils } from "../shared/FigmaUtils";
import type { UnfurlSignature } from "@server/types";
import isEmpty from "lodash/isEmpty";
import type { User } from "@server/models";
import { Integration } from "@server/models";
import { IntegrationType } from "@shared/types";
import { IntegrationService, UnfurlResourceType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import Logger from "@server/logging/Logger";
import { Minute } from "@shared/utils/time";
const Credentials = Buffer.from(
`${env.FIGMA_CLIENT_ID}:${env.FIGMA_CLIENT_SECRET}`
).toString("base64");
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
});
const RefreshTokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
});
const AccountResponseSchema = z.object({
id: z.string(),
handle: z.string(),
email: z.string(),
img_url: z.string(),
});
export class Figma {
private static supportedHosts = ["www.figma.com", "figma.com"];
private static supportedFileTypes = [
"design", // Design files
"board", // Figjam
"slides",
"buzz",
"site",
"make",
];
/**
* Exchange an OAuth code for an access token
*
* @param code OAuth code to exchange for an access token
* @returns An object containing the access token and refresh token
*/
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("code", code);
body.set("redirect_uri", FigmaUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(FigmaUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error exchanging Figma OAuth code; status: ${res.status}, ${await res.text()}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async refreshToken(refreshToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = new URLSearchParams();
body.set("refresh_token", refreshToken);
const res = await fetch(FigmaUtils.refreshUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while refreshing access token from Figma; status: ${res.status}, ${await res.text()}`
);
}
return RefreshTokenResponseSchema.parse(await res.json());
}
static async getInstalledAccount(accessToken: string) {
const res = await fetch(FigmaUtils.accountUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (res.status !== 200) {
throw new Error(
`Error getting Figma current account; status: ${res.status}, ${await res.text()}`
);
}
return AccountResponseSchema.parse(await res.json());
}
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = Figma.parseUrl(url);
if (!resource || !actor) {
return;
}
const integrations = (await Integration.scope("withAuthentication").findAll(
{
where: {
type: IntegrationType.LinkedAccount,
service: IntegrationService.Figma,
userId: actor.id,
teamId: actor.teamId,
},
}
)) as Integration<IntegrationType.LinkedAccount>[];
if (integrations.length === 0) {
return;
}
// Try to unfurl with any of the linked accounts
// Note: We support only one figma account per team for now.
for (const integration of integrations) {
try {
const accessToken =
await integration.authentication.refreshTokenIfNeeded(
async (refreshToken: string) => Figma.refreshToken(refreshToken),
5 * Minute.ms
);
const res = await fetch(Figma.fileMetadataUrl(resource.key), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
// This connected account has access to the file.
if (res.status === 200) {
const data = await res.json();
return {
type: UnfurlResourceType.URL,
url,
title: data.file.name,
description: `Created by ${data.file.creator.handle}`,
thumbnailUrl: data.file.thumbnail_url,
faviconUrl: cdnPath("/images/figma.png"),
transformedUnfurl: true,
};
}
} catch (err) {
Logger.error(
`Error fetching Figma file metadata for integration ${integration.id}`,
err
);
}
}
// Either no linked accounts have access to the file, or we faced an error.
// Fallback to iframely unfurl either way.
return;
};
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (!Figma.supportedHosts.includes(hostname)) {
return;
}
const parts = pathname.split("/");
const type = parts[1];
const key = parts[2];
if (!Figma.supportedFileTypes.includes(type) || isEmpty(key)) {
return;
}
return {
type,
key,
};
}
private static fileMetadataUrl(key: string) {
return `https://api.figma.com/v1/files/${key}/meta`;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/figma";
import env from "./env";
import { Figma } from "./figma";
import { Minute } from "@shared/utils/time";
const enabled = !!env.FIGMA_CLIENT_ID && !!env.FIGMA_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: Figma.unfurl, cacheExpiry: 10 * Minute.seconds },
},
]);
}
+52
View File
@@ -0,0 +1,52 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class FigmaUtils {
public static oauthScopes = ["current_user:read", "file_metadata:read"];
public static accountUrl = "https://api.figma.com/v1/me";
public static tokenUrl = "https://api.figma.com/v1/oauth/token";
public static refreshUrl = "https://api.figma.com/v1/oauth/refresh";
private static authBaseUrl = "https://www.figma.com/oauth";
private static settingsUrl = integrationSettingsPath("figma");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/figma.callback?${params}`
: `${baseUrl}/api/figma.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.FIGMA_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes.join(","),
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+3
View File
@@ -20,6 +20,9 @@ export const GitHubCallbackSchema = BaseSchema.extend({
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
})
.refine(
(req) =>
!(
+2 -3
View File
@@ -226,11 +226,10 @@ export class GitHub {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitHub Pull Request details
*/
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match GitHub pattern (before any DB queries)
public static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = GitHub.parseUrl(url);
if (!resource) {
if (!resource || !actor) {
return;
}
+20 -3
View File
@@ -4,6 +4,7 @@ import Logger from "@server/logging/Logger";
import type { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
import { cdnPath } from "@shared/utils/urls";
class Iframely {
public static defaultUrl = "https://iframe.ly";
@@ -40,9 +41,25 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.URL };
if ("error" in data) {
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
}
const parsedData = data as Record<string, any>;
return {
type: UnfurlResourceType.URL,
url: parsedData.url,
title: parsedData.meta.title,
description: parsedData.meta.description,
thumbnailUrl: (parsedData.links.thumbnail ?? [])[0]?.href ?? "",
faviconUrl:
parsedData.meta.site === "Figma"
? cdnPath("/images/figma.png")
: ((parsedData.links.icon ?? [])[0]?.href ?? ""),
transformedUnfurl: true,
};
};
}
+1 -1
View File
@@ -68,7 +68,7 @@ function Linear() {
<Text as="p">
<Trans>
Enable previews of Linear issues in documents by connecting a
Linear workspace to {appName}.
Linear workspace to {{ appName }}.
</Trans>
</Text>
{integrations.linear.length ? (
+2 -2
View File
@@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import type { APIContext } from "@server/types";
import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import * as T from "./schema";
import { LinearUtils } from "plugins/linear/shared/LinearUtils";
import { addSeconds } from "date-fns";
@@ -86,7 +86,7 @@ router.get(
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new UploadLinearWorkspaceLogoTask().schedule({
await new UploadIntegrationLogoTask().schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
+3
View File
@@ -11,6 +11,9 @@ export const LinearCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+2 -2
View File
@@ -4,7 +4,7 @@ import config from "../plugin.json";
import router from "./api/linear";
import env from "./env";
import { Linear } from "./linear";
import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask";
import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask";
import { uninstall } from "./uninstall";
const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET;
@@ -18,7 +18,7 @@ if (enabled) {
},
{
type: Hook.Task,
value: UploadLinearWorkspaceLogoTask,
value: UploadIntegrationLogoTask,
},
{
type: Hook.UnfurlProvider,
+2 -3
View File
@@ -104,11 +104,10 @@ export class Linear {
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a Linear issue details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
// Early return if URL doesn't match Linear pattern (before any DB queries)
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = Linear.parseUrl(url);
if (!resource) {
if (!resource || !actor) {
return;
}
+3
View File
@@ -11,6 +11,9 @@ export const NotionCallbackSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+3
View File
@@ -11,6 +11,9 @@ export const SlackPostSchema = BaseSchema.extend({
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine((req) => isEmpty(req.code) || isEmpty(req.error), {
message: "code and error cannot both be present",
}),
});
+3 -3
View File
@@ -188,7 +188,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create(
await Integration.create<Integration<IntegrationType.Post>>(
{
service: IntegrationService.Slack,
type: IntegrationType.Post,
@@ -226,7 +226,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
},
{ transaction }
);
await Integration.create(
await Integration.create<Integration<IntegrationType.Command>>(
{
service: IntegrationService.Slack,
type: IntegrationType.Command,
@@ -246,7 +246,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
case IntegrationType.LinkedAccount: {
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await Integration.create({
await Integration.create<Integration<IntegrationType.LinkedAccount>>({
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
userId: user.id,
+16 -8
View File
@@ -29,14 +29,22 @@ async function presentUnfurl(
const presentURL = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.URL] => ({
type: UnfurlResourceType.URL,
url: data.url,
title: data.meta.title,
description: data.meta.description,
thumbnailUrl: (data.links.thumbnail ?? [])[0]?.href ?? "",
faviconUrl: (data.links.icon ?? [])[0]?.href ?? "",
});
): UnfurlResponse[UnfurlResourceType.URL] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformedUnfurl) {
delete data.transformedUnfurl;
return data as UnfurlResponse[UnfurlResourceType.URL]; // this would have been transformed by the unfurl plugin.
}
return {
type: UnfurlResourceType.URL,
url: data.url,
title: data.meta.title,
description: data.meta.description,
thumbnailUrl: (data.links.thumbnail ?? [])[0]?.href ?? "",
faviconUrl: (data.links.icon ?? [])[0]?.href ?? "",
};
};
const presentMention = async (
data: Record<string, any>,
@@ -5,23 +5,28 @@ import { createContext } from "@server/context";
import { Integration, User } from "@server/models";
import { BaseTask, TaskPriority } from "@server/queues/tasks/base/BaseTask";
const SupportedIntegrations = [
IntegrationService.Linear,
IntegrationService.Figma,
];
type Props = {
/** The integrationId to operate on */
integrationId: string;
/** The original logoUrl from Linear */
/** The original logoUrl from third-party service */
logoUrl: string;
};
/**
* A task that uploads the provided logoUrl to storage and updates the
* Linear integration record with the new url.
* associated integration record with the new url.
*/
export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
export default class UploadIntegrationLogoTask extends BaseTask<Props> {
public async perform(props: Props) {
const integration = await Integration.scope("withAuthentication").findByPk<
Integration<IntegrationType.Embed>
>(props.integrationId);
if (!integration || integration.service !== IntegrationService.Linear) {
const integration = await Integration.scope("withAuthentication").findByPk(
props.integrationId
);
if (!integration || !SupportedIntegrations.includes(integration.service)) {
return;
}
@@ -43,11 +48,29 @@ export default class UploadLinearWorkspaceLogoTask extends BaseTask<Props> {
},
});
if (attachment) {
integration.settings.linear!.workspace.logoUrl = attachment.url;
integration.changed("settings", true);
await integration.save();
if (!attachment) {
return;
}
switch (integration.service) {
case IntegrationService.Linear:
(
integration as Integration<IntegrationType.Embed>
).settings.linear!.workspace.logoUrl = attachment.url;
break;
case IntegrationService.Figma:
(
integration as Integration<IntegrationType.LinkedAccount>
).settings.figma!.account.avatarUrl = attachment.url;
break;
default:
throw new Error(
`Unsupported integration service: ${integration.service}`
); // This should never happen
}
integration.changed("settings", true);
await integration.save();
}
public get options() {
+8 -1
View File
@@ -567,12 +567,19 @@ export type UnfurlIssueOrPR =
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR];
export type UnfurlURL = UnfurlResponse[UnfurlResourceType.URL] & {
transformedUnfurl: true;
};
export type Unfurl =
| UnfurlIssueOrPR
| UnfurlURL
| {
type: Exclude<
UnfurlResourceType,
UnfurlResourceType.Issue | UnfurlResourceType.PR
| UnfurlResourceType.Issue
| UnfurlResourceType.PR
| UnfurlResourceType.URL
>;
[x: string]: JSONValue;
};
+21 -15
View File
@@ -193,7 +193,7 @@ export const MentionURL = (props: IssueUrlProps) => {
} = getAttributesFromNode(node);
const url = String(attrs.href);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
const unfurl = unfurls.get(url)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchUnfurl = async () => {
@@ -204,31 +204,37 @@ export const MentionURL = (props: IssueUrlProps) => {
return;
}
// We got a result back from the server, so update the unfurl in the node attributes.
if (unfurlModel) {
onChangeUnfurl(
unfurlModel.data satisfies UnfurlResponse[UnfurlResourceType.URL]
);
} else {
// If we didn't get a result back, we still want to add a basic unfurl
// to avoid refetching again in future. This will just show the URL
// with a generic link icon.
unfurls.add({
id: url,
type: UnfurlResourceType.URL,
fetchedAt: new Date().toISOString(),
data: {
return;
}
const attrs = getAttributesFromNode(node);
// If we have a unfurl attribute, use that.
// Otherwise, set a basic unfurl to avoid refetching again in future.
// This will just show the URL with a generic link icon.
const data = attrs.unfurl
? attrs.unfurl
: {
title: toDisplayUrl(url),
faviconUrl: cdnPath("/images/link.png"),
},
});
}
};
unfurls.add({
id: url,
type: UnfurlResourceType.URL,
fetchedAt: new Date().toISOString(),
data,
});
} finally {
setLoaded(true);
}
};
void fetchUnfurl();
}, [unfurls, attrs.href, isMounted]);
}, [unfurls, url, node, isMounted]);
if (!unfurl) {
return !loaded ? (
@@ -244,7 +250,7 @@ export const MentionURL = (props: IssueUrlProps) => {
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
href={attrs.href as string}
href={url}
target="_blank"
rel="noopener noreferrer nofollow"
>
+27
View File
@@ -1,4 +1,6 @@
import type { Node, Schema } from "prosemirror-model";
import type { EmbedDescriptor } from "../embeds";
import { isList } from "../queries/isList";
export function getMatchingEmbed(
embeds: EmbedDescriptor[],
@@ -13,3 +15,28 @@ export function getMatchingEmbed(
return undefined;
}
export function transformListToEmbeds(listNode: Node, schema: Schema): Node[] {
const nodes: Node[] = [];
listNode.forEach((node) => {
nodes.push(...transformListItemToEmbeds(node, schema));
});
return nodes;
}
function transformListItemToEmbeds(listItemNode: Node, schema: Schema): Node[] {
const nodes: Node[] = [];
listItemNode.forEach((node) => {
if (node.type.name === "paragraph") {
const url = node.textContent;
nodes.push(schema.nodes.embed.create({ href: url }));
} else if (isList(node, schema)) {
nodes.push(...transformListToEmbeds(node, schema));
}
});
return nodes;
}
+60 -18
View File
@@ -1,21 +1,25 @@
import type { Token } from "markdown-it";
import type {
NodeSpec,
NodeType,
Node as ProsemirrorNode,
import {
Fragment,
Slice,
type NodeSpec,
type NodeType,
type Node as ProsemirrorNode,
} from "prosemirror-model";
import type { Command } from "prosemirror-state";
import { NodeSelection } from "prosemirror-state";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import type { Primitive } from "utility-types";
import { sanitizeUrl } from "../../utils/urls";
import EmbedComponent from "../components/Embed";
import defaultEmbeds from "../embeds";
import { getMatchingEmbed } from "../lib/embeds";
import { getMatchingEmbed, transformListToEmbeds } from "../lib/embeds";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import embedsRule from "../rules/embeds";
import type { ComponentProps } from "../types";
import Node from "./Node";
import { isInList } from "../queries/isInList";
import { findParentNodeClosestToPos } from "../queries/findParentNode";
import { isList } from "../queries/isList";
export default class Embed extends Node {
get name() {
@@ -96,10 +100,6 @@ export default class Embed extends Node {
};
}
get rulePlugins() {
return [embedsRule(this.options.embeds)];
}
handleChangeSize =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
({ width, height }: { width: number; height?: number }) => {
@@ -132,13 +132,55 @@ export default class Embed extends Node {
};
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
dispatch?.(
state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView()
);
return true;
};
return {
embed:
(attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
dispatch?.(
state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView()
);
return true;
},
embed_list:
(_attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined || !isInList(state)) {
return false;
}
const resolvedPos = state.tr.doc.resolve(position);
const nodeWithPos = findParentNodeClosestToPos(resolvedPos, (node) =>
isList(node, this.editor.schema)
);
if (!nodeWithPos) {
return false;
}
const listNode = nodeWithPos.node,
from = nodeWithPos.pos,
to = from + listNode.nodeSize;
const nodes = transformListToEmbeds(listNode, this.editor.schema);
const slice = new Slice(Fragment.fromArray(nodes), 0, 0);
const tr = state.tr.deleteRange(from, to);
dispatch?.(
tr
.setSelection(TextSelection.near(tr.doc.resolve(from)))
.replaceSelection(slice)
.scrollIntoView()
);
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
+8 -3
View File
@@ -1374,8 +1374,14 @@
"Sorry, an answer could not be found in the workspace, try widening your search.": "Sorry, an answer could not be found in the workspace, try widening your search.",
"Looking for answers": "Looking for answers",
"Answer to \"{{ query }}\"": "Answer to \"{{ query }}\"",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Whoops, you need to accept the permissions in Figma to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Figma to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"Something went wrong while processing your request. Please try again.": "Something went wrong while processing your request. Please try again.",
"Link your {{appName}} account to Figma to enable previews of design files you have access to, directly within documents.": "Link your {{appName}} account to Figma to enable previews of design files you have access to, directly within documents.",
"Enabled on": "Enabled on",
"Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?": "Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?",
"The Figma integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Figma integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",
"Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.": "Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.",
"Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}",
@@ -1386,8 +1392,7 @@
"Measurement ID": "Measurement ID",
"Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.",
"Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?",
"Something went wrong while processing your request. Please try again.": "Something went wrong while processing your request. Please try again.",
"Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.",
"Enable previews of Linear issues in documents by connecting a Linear workspace to {{appName}}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {{appName}}.",
"Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?": "Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?",
"The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.",
+31 -20
View File
@@ -129,6 +129,7 @@ export enum IntegrationService {
Umami = "umami",
GitHub = "github",
Linear = "linear",
Figma = "figma",
Notion = "notion",
}
@@ -211,28 +212,38 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
? {
externalWorkspace: { id: string; name: string; iconUrl?: string };
}
:
| { url: string }
| {
github?: {
installation: {
id: number;
account: {
id?: number;
name: string;
avatarUrl?: string;
: T extends IntegrationType.LinkedAccount
? {
slack?: { serviceTeamId: string; serviceUserId: string };
figma?: {
account: {
id: string;
name: string;
email: string;
avatarUrl: string;
};
};
}
:
| { url: string }
| {
github?: {
installation: {
id: number;
account: {
id?: number;
name: string;
avatarUrl?: string;
};
};
};
};
diagrams?: {
url: string;
};
}
| { url: string; channel: string; channelId: string }
| { serviceTeamId: string }
| { measurementId: string }
| { slack: { serviceTeamId: string; serviceUserId: string } }
| undefined;
diagrams?: {
url: string;
};
}
| { serviceTeamId: string }
| { measurementId: string }
| undefined;
export enum UserPreference {
/** Whether reopening the app should redirect to the last viewed document. */