feat: Add GA integration, support for GA4 (#4626)

* GA integration settings

* trackingId -> measurementId
Hook up script

* Public page GA tracking
Correct layout of settings

* Remove multiple codepaths for loading GA measurementID, add missing db index

* Remove unneccessary changes, tsc

* test
This commit is contained in:
Tom Moor
2023-01-01 15:29:08 +00:00
committed by GitHub
parent dc795604a4
commit 8e4270c321
29 changed files with 374 additions and 114 deletions

View File

@@ -1,8 +1,11 @@
/* global ga */ /* global ga */
import { escape } from "lodash";
import * as React from "react"; import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env"; import env from "~/env";
const Analytics: React.FC = ({ children }) => { const Analytics: React.FC = ({ children }) => {
// Google Analytics 3
React.useEffect(() => { React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID) { if (!env.GOOGLE_ANALYTICS_ID) {
return; return;
@@ -17,9 +20,6 @@ const Analytics: React.FC = ({ children }) => {
ga.l = +new Date(); ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto"); ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", {
dimension1: "true",
});
ga("send", "pageview"); ga("send", "pageview");
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js"; script.src = "https://www.google-analytics.com/analytics.js";
@@ -30,9 +30,28 @@ const Analytics: React.FC = ({ children }) => {
ga("send", "event", "pwa", "install"); ga("send", "event", "pwa", "install");
}); });
if (document.body) { document.body?.appendChild(script);
document.body.appendChild(script); }, []);
// Google Analytics 4
React.useEffect(() => {
if (env.analytics.service !== IntegrationService.GoogleAnalytics) {
return;
} }
const measurementId = escape(env.analytics.settings?.measurementId);
window.dataLayer = window.dataLayer || [];
function gtag(...args: any[]) {
window.dataLayer.push(args);
}
gtag("js", new Date());
gtag("config", measurementId);
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
script.async = true;
document.body?.appendChild(script);
}, []); }, []);
return <>{children}</>; return <>{children}</>;

View File

@@ -11,7 +11,7 @@ import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions"; import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root"; import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions"; import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction"; import useSettingsActions from "~/hooks/useSettingsActions";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types"; import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard"; import { metaDisplay } from "~/utils/keyboard";

View File

@@ -0,0 +1,25 @@
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 */
color?: string;
};
export default function GoogleIcon({
size = 24,
color = "currentColor",
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="-8 -8 48 48"
version="1.1"
>
<path d="M32.6162791,13.9090909 L16.8837209,13.9090909 L16.8837209,20.4772727 L25.9395349,20.4772727 C25.0953488,24.65 21.5651163,27.0454545 16.8837209,27.0454545 C11.3581395,27.0454545 6.90697674,22.5636364 6.90697674,17 C6.90697674,11.4363636 11.3581395,6.95454545 16.8837209,6.95454545 C19.2627907,6.95454545 21.4116279,7.80454545 23.1,9.19545455 L28.0116279,4.25 C25.0186047,1.62272727 21.1813953,0 16.8837209,0 C7.52093023,0 0,7.57272727 0,17 C0,26.4272727 7.52093023,34 16.8837209,34 C25.3255814,34 33,27.8181818 33,17 C33,15.9954545 32.8465116,14.9136364 32.6162791,13.9090909 Z" />
</svg>
);
}

View File

@@ -7,7 +7,7 @@ import { useHistory } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable"; import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig"; import useSettingsConfig from "~/hooks/useSettingsConfig";
import Desktop from "~/utils/Desktop"; import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
@@ -21,7 +21,7 @@ import Version from "./components/Version";
function SettingsSidebar() { function SettingsSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const configs = useAuthorizedSettingsConfig(); const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group"); const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => { const returnToApp = React.useCallback(() => {

View File

@@ -3,10 +3,10 @@ import * as React from "react";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections"; import { NavigationSection } from "~/actions/sections";
import history from "~/utils/history"; import history from "~/utils/history";
import useAuthorizedSettingsConfig from "./useAuthorizedSettingsConfig"; import useSettingsConfig from "./useSettingsConfig";
const useSettingsActions = () => { const useSettingsActions = () => {
const config = useAuthorizedSettingsConfig(); const config = useSettingsConfig();
const actions = React.useMemo(() => { const actions = React.useMemo(() => {
return config.map((item) => { return config.map((item) => {
const Icon = item.icon; const Icon = item.icon;

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details"; import Details from "~/scenes/Settings/Details";
import Export from "~/scenes/Settings/Export"; import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features"; import Features from "~/scenes/Settings/Features";
import GoogleAnalytics from "~/scenes/Settings/GoogleAnalytics";
import Groups from "~/scenes/Settings/Groups"; import Groups from "~/scenes/Settings/Groups";
import Import from "~/scenes/Settings/Import"; import Import from "~/scenes/Settings/Import";
import Members from "~/scenes/Settings/Members"; import Members from "~/scenes/Settings/Members";
@@ -32,6 +33,7 @@ import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens"; import Tokens from "~/scenes/Settings/Tokens";
import Webhooks from "~/scenes/Settings/Webhooks"; import Webhooks from "~/scenes/Settings/Webhooks";
import Zapier from "~/scenes/Settings/Zapier"; import Zapier from "~/scenes/Settings/Zapier";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import SlackIcon from "~/components/Icons/SlackIcon"; import SlackIcon from "~/components/Icons/SlackIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon";
import env from "~/env"; import env from "~/env";
@@ -55,7 +57,8 @@ type SettingsPage =
| "Export" | "Export"
| "Webhooks" | "Webhooks"
| "Slack" | "Slack"
| "Zapier"; | "Zapier"
| "GoogleAnalytics";
export type ConfigItem = { export type ConfigItem = {
name: string; name: string;
@@ -70,7 +73,7 @@ type ConfigType = {
[key in SettingsPage]: ConfigItem; [key in SettingsPage]: ConfigItem;
}; };
const useAuthorizedSettingsConfig = () => { const useSettingsConfig = () => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const can = usePolicy(team); const can = usePolicy(team);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -199,6 +202,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"), group: t("Integrations"),
icon: SlackIcon, icon: SlackIcon,
}, },
GoogleAnalytics: {
name: t("Google Analytics"),
path: "/settings/integrations/google-analytics",
component: GoogleAnalytics,
enabled: can.update,
group: t("Integrations"),
icon: GoogleIcon,
},
Zapier: { Zapier: {
name: "Zapier", name: "Zapier",
path: "/settings/integrations/zapier", path: "/settings/integrations/zapier",
@@ -215,7 +226,6 @@ const useAuthorizedSettingsConfig = () => {
can.createImport, can.createImport,
can.createExport, can.createExport,
can.createWebhookSubscription, can.createWebhookSubscription,
team.collaborativeEditing,
] ]
); );
@@ -232,4 +242,4 @@ const useAuthorizedSettingsConfig = () => {
return enabledConfigs; return enabledConfigs;
}; };
export default useAuthorizedSettingsConfig; export default useSettingsConfig;

View File

@@ -1,14 +1,18 @@
import { observable } from "mobx"; import { observable } from "mobx";
import type { IntegrationSettings } from "@shared/types"; import type {
IntegrationService,
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import Field from "./decorators/Field"; import Field from "./decorators/Field";
class Integration<T = unknown> extends BaseModel { class Integration<T = unknown> extends BaseModel {
id: string; id: string;
type: string; type: IntegrationType;
service: string; service: IntegrationService;
collectionId: string; collectionId: string;

View File

@@ -2,10 +2,10 @@ import * as React from "react";
import { Switch, Redirect } from "react-router-dom"; import { Switch, Redirect } from "react-router-dom";
import Error404 from "~/scenes/Error404"; import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute"; import Route from "~/components/ProfiledRoute";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig"; import useSettingsConfig from "~/hooks/useSettingsConfig";
export default function SettingsRoutes() { export default function SettingsRoutes() {
const configs = useAuthorizedSettingsConfig(); const configs = useSettingsConfig();
return ( return (
<Switch> <Switch>

View File

@@ -0,0 +1,115 @@
import { find } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import SettingRow from "./components/SettingRow";
type FormData = {
measurementId: string;
};
function GoogleAnalytics() {
const { integrations } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
service: IntegrationService.GoogleAnalytics,
}) as Integration<IntegrationType.Analytics> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
measurementId: integration?.settings.measurementId,
},
});
React.useEffect(() => {
integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.measurementId) {
await integrations.save({
id: integration?.id,
type: IntegrationType.Analytics,
service: IntegrationService.GoogleAnalytics,
settings: {
measurementId: data.measurementId,
},
});
} else {
await integration?.delete();
}
showToast(t("Settings saved"), {
type: "success",
});
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[integrations, integration, t, showToast]
);
return (
<Scene
title={t("Google Analytics")}
icon={<GoogleIcon color="currentColor" />}
>
<Heading>{t("Google Analytics")}</Heading>
<Text type="secondary">
<Trans>
Add a Google Analytics 4 measurement ID to send document views and
analytics from the workspace to your own Google Analytics account.
</Trans>
</Text>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Measurement ID")}
name="measurementId"
description={t(
'Create a "Web" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.'
)}
border={false}
>
<Input placeholder="G-XXXXXXXXX1" {...register("measurementId")} />
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(GoogleAnalytics);

View File

@@ -3,16 +3,16 @@ import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons"; import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IntegrationType } from "@shared/types"; import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration"; import Integration from "~/models/Integration";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Heading from "~/components/Heading"; import Heading from "~/components/Heading";
import { ReactHookWrappedInput as Input } from "~/components/Input"; import { ReactHookWrappedInput as Input } from "~/components/Input";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import SettingRow from "./components/SettingRow";
type FormData = { type FormData = {
drawIoUrl: string; drawIoUrl: string;
@@ -25,7 +25,7 @@ function SelfHosted() {
const integration = find(integrations.orderedData, { const integration = find(integrations.orderedData, {
type: IntegrationType.Embed, type: IntegrationType.Embed,
service: "diagrams", service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined; }) as Integration<IntegrationType.Embed> | undefined;
const { const {
@@ -53,14 +53,18 @@ function SelfHosted() {
const handleSubmit = React.useCallback( const handleSubmit = React.useCallback(
async (data: FormData) => { async (data: FormData) => {
try { try {
await integrations.save({ if (data.drawIoUrl) {
id: integration?.id, await integrations.save({
type: IntegrationType.Embed, id: integration?.id,
service: "diagrams", type: IntegrationType.Embed,
settings: { service: IntegrationService.Diagrams,
url: data.drawIoUrl, settings: {
}, url: data.drawIoUrl,
}); },
});
} else {
await integration?.delete();
}
showToast(t("Settings saved"), { showToast(t("Settings saved"), {
type: "success", type: "success",
@@ -81,27 +85,26 @@ function SelfHosted() {
> >
<Heading>{t("Self Hosted")}</Heading> <Heading>{t("Self Hosted")}</Heading>
<Text type="secondary"> <form onSubmit={formHandleSubmit(handleSubmit)}>
<Trans> <SettingRow
Add your self-hosted draw.io installation url here to enable automatic label={t("Draw.io deployment")}
embedding of diagrams within documents. name="drawIoUrl"
</Trans> description={t(
<form onSubmit={formHandleSubmit(handleSubmit)}> "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
<p> )}
<Input border={false}
label={t("Draw.io deployment")} >
placeholder="https://app.diagrams.net/" <Input
pattern="https?://.*" placeholder="https://app.diagrams.net/"
{...register("drawIoUrl", { pattern="https?://.*"
required: true, {...register("drawIoUrl")}
})} />
/> </SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")} <Button type="submit" disabled={formState.isSubmitting}>
</Button> {formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</p> </Button>
</form> </form>
</Text>
</Scene> </Scene>
); );
} }

View File

@@ -1,5 +1,6 @@
import { filter } from "lodash"; import { filter } from "lodash";
import { computed } from "mobx"; import { computed } from "mobx";
import { IntegrationService } from "@shared/types";
import naturalSort from "@shared/utils/naturalSort"; import naturalSort from "@shared/utils/naturalSort";
import BaseStore from "~/stores/BaseStore"; import BaseStore from "~/stores/BaseStore";
import RootStore from "~/stores/RootStore"; import RootStore from "~/stores/RootStore";
@@ -18,7 +19,7 @@ class IntegrationsStore extends BaseStore<Integration> {
@computed @computed
get slackIntegrations(): Integration[] { get slackIntegrations(): Integration[] {
return filter(this.orderedData, { return filter(this.orderedData, {
service: "slack", service: IntegrationService.Slack,
}); });
} }
} }

View File

@@ -1,5 +1,7 @@
declare global { declare global {
interface Window { interface Window {
dataLayer: any[];
DesktopBridge: { DesktopBridge: {
/** /**
* The name of the platform running on. * The name of the platform running on.

View File

@@ -330,7 +330,7 @@ export class Environment {
public RELEASE = this.toOptionalString(process.env.RELEASE); public RELEASE = this.toOptionalString(process.env.RELEASE);
/** /**
* A Google Analytics tracking ID, only v3 supported at this time. * A Google Analytics tracking ID, supports only v3 properties.
*/ */
@Contains("UA-") @Contains("UA-")
@IsOptional() @IsOptional()

View File

@@ -0,0 +1,11 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addIndex("integrations", ["teamId", "type", "service"]);
},
async down (queryInterface, Sequelize) {
await queryInterface.removeIndex("integrations", ["teamId", "type", "service"]);
}
};

View File

@@ -7,7 +7,7 @@ import {
Scopes, Scopes,
IsIn, IsIn,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { IntegrationType } from "@shared/types"; import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types"; import type { IntegrationSettings } from "@shared/types";
import Collection from "./Collection"; import Collection from "./Collection";
import IntegrationAuthentication from "./IntegrationAuthentication"; import IntegrationAuthentication from "./IntegrationAuthentication";
@@ -16,13 +16,9 @@ import User from "./User";
import IdModel from "./base/IdModel"; import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
export enum IntegrationService {
Diagrams = "diagrams",
Slack = "slack",
}
export enum UserCreatableIntegrationService { export enum UserCreatableIntegrationService {
Diagrams = "diagrams", Diagrams = "diagrams",
GoogleAnalytics = "google-analytics",
} }
@Scopes(() => ({ @Scopes(() => ({
@@ -40,12 +36,12 @@ export enum UserCreatableIntegrationService {
@Fix @Fix
class Integration<T = unknown> extends IdModel { class Integration<T = unknown> extends IdModel {
@IsIn([Object.values(IntegrationType)]) @IsIn([Object.values(IntegrationType)])
@Column @Column(DataType.STRING)
type: string; type: IntegrationType;
@IsIn([Object.values(IntegrationService)]) @IsIn([Object.values(IntegrationService)])
@Column @Column(DataType.STRING)
service: string; service: IntegrationService;
@Column(DataType.JSONB) @Column(DataType.JSONB)
settings: IntegrationSettings<T>; settings: IntegrationSettings<T>;

View File

@@ -5,6 +5,7 @@ import {
BelongsTo, BelongsTo,
Column, Column,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { IntegrationService } from "@shared/types";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
import IdModel from "./base/IdModel"; import IdModel from "./base/IdModel";
@@ -17,8 +18,8 @@ import Fix from "./decorators/Fix";
@Table({ tableName: "authentications", modelName: "authentication" }) @Table({ tableName: "authentications", modelName: "authentication" })
@Fix @Fix
class IntegrationAuthentication extends IdModel { class IntegrationAuthentication extends IdModel {
@Column @Column(DataType.STRING)
service: string; service: IntegrationService;
@Column(DataType.ARRAY(DataType.STRING)) @Column(DataType.ARRAY(DataType.STRING))
scopes: string[]; scopes: string[];

View File

@@ -1,9 +1,13 @@
import { PublicEnv } from "@shared/types"; import { IntegrationType, PublicEnv } from "@shared/types";
import { Environment } from "@server/env"; import { Environment } from "@server/env";
import { Integration } from "@server/models";
// Note: This entire object is stringified in the HTML exposed to the client // Note: This entire object is stringified in the HTML exposed to the client
// do not add anything here that should be a secret or password // do not add anything here that should be a secret or password
export default function present(env: Environment): PublicEnv { export default function present(
env: Environment,
analytics?: Integration<IntegrationType.Analytics> | null
): PublicEnv {
return { return {
URL: env.URL.replace(/\/$/, ""), URL: env.URL.replace(/\/$/, ""),
AWS_S3_UPLOAD_BUCKET_URL: process.env.AWS_S3_UPLOAD_BUCKET_URL || "", AWS_S3_UPLOAD_BUCKET_URL: process.env.AWS_S3_UPLOAD_BUCKET_URL || "",
@@ -26,5 +30,9 @@ export default function present(env: Environment): PublicEnv {
GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID, GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID,
RELEASE: RELEASE:
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined, process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
analytics: {
service: analytics?.service,
settings: analytics?.settings,
},
}; };
} }

View File

@@ -1,6 +1,6 @@
import fetch from "fetch-with-proxy"; import fetch from "fetch-with-proxy";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { IntegrationType } from "@shared/types"; import { IntegrationService, IntegrationType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { Document, Integration, Collection, Team } from "@server/models"; import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters"; import { presentSlackAttachment } from "@server/presenters";
@@ -36,8 +36,8 @@ export default class SlackProcessor extends BaseProcessor {
const integration = (await Integration.findOne({ const integration = (await Integration.findOne({
where: { where: {
id: event.modelId, id: event.modelId,
service: "slack", service: IntegrationService.Slack,
type: "post", type: IntegrationType.Post,
}, },
include: [ include: [
{ {
@@ -98,8 +98,8 @@ export default class SlackProcessor extends BaseProcessor {
where: { where: {
teamId: document.teamId, teamId: document.teamId,
collectionId: document.collectionId, collectionId: document.collectionId,
service: "slack", service: IntegrationService.Slack,
type: "post", type: IntegrationType.Post,
events: { events: {
[Op.contains]: [ [Op.contains]: [
event.name === "revisions.create" ? "documents.update" : event.name, event.name === "revisions.create" ? "documents.update" : event.name,

View File

@@ -1,3 +1,4 @@
import { IntegrationService } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { IntegrationAuthentication, SearchQuery } from "@server/models"; import { IntegrationAuthentication, SearchQuery } from "@server/models";
import { buildDocument, buildIntegration } from "@server/test/factories"; import { buildDocument, buildIntegration } from "@server/test/factories";
@@ -14,7 +15,7 @@ describe("#hooks.unfurl", () => {
it("should return documents", async () => { it("should return documents", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
await IntegrationAuthentication.create({ await IntegrationAuthentication.create({
service: "slack", service: IntegrationService.Slack,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
token: "", token: "",

View File

@@ -1,6 +1,7 @@
import crypto from "crypto"; import crypto from "crypto";
import Router from "koa-router"; import Router from "koa-router";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
import { IntegrationService } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { AuthenticationError, InvalidRequestError } from "@server/errors"; import { AuthenticationError, InvalidRequestError } from "@server/errors";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
@@ -67,7 +68,7 @@ router.post("hooks.unfurl", async (ctx) => {
} }
const auth = await IntegrationAuthentication.findOne({ const auth = await IntegrationAuthentication.findOne({
where: { where: {
service: "slack", service: IntegrationService.Slack,
teamId: user.teamId, teamId: user.teamId,
}, },
}); });
@@ -240,7 +241,7 @@ router.post("hooks.slack", async (ctx) => {
if (!user) { if (!user) {
const auth = await IntegrationAuthentication.findOne({ const auth = await IntegrationAuthentication.findOne({
where: { where: {
service: "slack", service: IntegrationService.Slack,
teamId: team.id, teamId: team.id,
}, },
}); });

View File

@@ -1,5 +1,6 @@
import Router from "koa-router"; import Router from "koa-router";
import { has } from "lodash"; import { has } from "lodash";
import { WhereOptions } from "sequelize";
import { IntegrationType } from "@shared/types"; import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { Event } from "@server/models"; import { Event } from "@server/models";
@@ -21,17 +22,27 @@ const router = new Router();
router.post("integrations.list", auth(), pagination(), async (ctx) => { router.post("integrations.list", auth(), pagination(), async (ctx) => {
let { direction } = ctx.request.body; let { direction } = ctx.request.body;
const { sort = "updatedAt" } = ctx.request.body; const { user } = ctx.state;
const { type, sort = "updatedAt" } = ctx.request.body;
if (direction !== "ASC") { if (direction !== "ASC") {
direction = "DESC"; direction = "DESC";
} }
assertSort(sort, Integration); assertSort(sort, Integration);
const { user } = ctx.state; let where: WhereOptions<Integration> = {
teamId: user.teamId,
};
if (type) {
assertIn(type, Object.values(IntegrationType));
where = {
...where,
type,
};
}
const integrations = await Integration.findAll({ const integrations = await Integration.findAll({
where: { where,
teamId: user.teamId,
},
order: [[sort, direction]], order: [[sort, direction]],
offset: ctx.state.pagination.offset, offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,

View File

@@ -5,8 +5,10 @@ import { Context, Next } from "koa";
import { escape } from "lodash"; import { escape } from "lodash";
import { Sequelize } from "sequelize"; import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import { IntegrationType } from "@shared/types";
import documentLoader from "@server/commands/documentLoader"; import documentLoader from "@server/commands/documentLoader";
import env from "@server/env"; import env from "@server/env";
import { Integration } from "@server/models";
import presentEnv from "@server/presenters/env"; import presentEnv from "@server/presenters/env";
import { getTeamFromContext } from "@server/utils/passport"; import { getTeamFromContext } from "@server/utils/passport";
import prefetchTags from "@server/utils/prefetchTags"; import prefetchTags from "@server/utils/prefetchTags";
@@ -54,7 +56,12 @@ const readIndexFile = async (ctx: Context): Promise<Buffer> => {
export const renderApp = async ( export const renderApp = async (
ctx: Context, ctx: Context,
next: Next, next: Next,
options: { title?: string; description?: string; canonical?: string } = {} options: {
title?: string;
description?: string;
canonical?: string;
analytics?: Integration | null;
} = {}
) => { ) => {
const { const {
title = "Outline", title = "Outline",
@@ -69,7 +76,7 @@ export const renderApp = async (
const { shareId } = ctx.params; const { shareId } = ctx.params;
const page = await readIndexFile(ctx); const page = await readIndexFile(ctx);
const environment = ` const environment = `
window.env = ${JSON.stringify(presentEnv(env))}; window.env = ${JSON.stringify(presentEnv(env, options.analytics))};
`; `;
ctx.body = page ctx.body = page
.toString() .toString()
@@ -86,7 +93,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
// Find the share record if publicly published so that the document title // Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in // can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity // unfurls with more reliablity
let share, document; let share, document, analytics;
try { try {
const team = await getTeamFromContext(ctx); const team = await getTeamFromContext(ctx);
@@ -105,6 +112,13 @@ export const renderShare = async (ctx: Context, next: Next) => {
} }
document = result.document; document = result.document;
analytics = await Integration.findOne({
where: {
teamId: document.teamId,
type: IntegrationType.Analytics,
},
});
if (share && !ctx.userAgent.isBot) { if (share && !ctx.userAgent.isBot) {
await share.update({ await share.update({
lastAccessedAt: new Date(), lastAccessedAt: new Date(),
@@ -123,6 +137,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
return renderApp(ctx, next, { return renderApp(ctx, next, {
title: document?.title, title: document?.title,
description: document?.getSummary(), description: document?.getSummary(),
analytics,
canonical: share canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}` ? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined, : undefined,

View File

@@ -3,6 +3,7 @@ import type { Context } from "koa";
import Router from "koa-router"; import Router from "koa-router";
import { Profile } from "passport"; import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2"; import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import accountProvisioner from "@server/commands/accountProvisioner"; import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env"; import env from "@server/env";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
@@ -173,15 +174,15 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.commands`; const endpoint = `${env.URL}/auth/slack.commands`;
const data = await Slack.oauthAccess(String(code), endpoint); const data = await Slack.oauthAccess(String(code), endpoint);
const authentication = await IntegrationAuthentication.create({ const authentication = await IntegrationAuthentication.create({
service: "slack", service: IntegrationService.Slack,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
token: data.access_token, token: data.access_token,
scopes: data.scope.split(","), scopes: data.scope.split(","),
}); });
await Integration.create({ await Integration.create({
service: "slack", service: IntegrationService.Slack,
type: "command", type: IntegrationType.Command,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
authenticationId: authentication.id, authenticationId: authentication.id,
@@ -239,7 +240,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.post`; const endpoint = `${env.URL}/auth/slack.post`;
const data = await Slack.oauthAccess(code as string, endpoint); const data = await Slack.oauthAccess(code as string, endpoint);
const authentication = await IntegrationAuthentication.create({ const authentication = await IntegrationAuthentication.create({
service: "slack", service: IntegrationService.Slack,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
token: data.access_token, token: data.access_token,
@@ -247,8 +248,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
}); });
await Integration.create({ await Integration.create({
service: "slack", service: IntegrationService.Slack,
type: "post", type: IntegrationType.Post,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
authenticationId: authentication.id, authenticationId: authentication.id,

View File

@@ -5,9 +5,12 @@ import Router from "koa-router";
import send from "koa-send"; import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent"; import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n"; import { languages } from "@shared/i18n";
import { IntegrationType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { NotFoundError } from "@server/errors"; import { NotFoundError } from "@server/errors";
import { Integration } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch"; import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots"; import { robotsResponse } from "@server/utils/robots";
import apexRedirect from "../middlewares/apexRedirect"; import apexRedirect from "../middlewares/apexRedirect";
import { renderApp, renderShare } from "./app"; import { renderApp, renderShare } from "./app";
@@ -118,7 +121,21 @@ router.get("/s/:shareId/doc/:documentSlug", renderShare);
router.get("/s/:shareId/*", renderShare); router.get("/s/:shareId/*", renderShare);
// catch all for application // catch all for application
router.get("*", renderApp); router.get("*", async (ctx, next) => {
const team = await getTeamFromContext(ctx);
const analytics = team
? await Integration.findOne({
where: {
teamId: team.id,
type: IntegrationType.Analytics,
},
})
: undefined;
return renderApp(ctx, next, {
analytics,
});
});
// In order to report all possible performance metrics to Sentry this header // In order to report all possible performance metrics to Sentry this header
// must be provided when serving the application, see: // must be provided when serving the application, see:

View File

@@ -23,6 +23,7 @@ const scriptSrc = [
"'unsafe-inline'", "'unsafe-inline'",
"'unsafe-eval'", "'unsafe-eval'",
"gist.github.com", "gist.github.com",
"www.googletagmanager.com",
"cdn.zapier.com", "cdn.zapier.com",
]; ];

View File

@@ -4,6 +4,8 @@ import {
CollectionPermission, CollectionPermission,
FileOperationState, FileOperationState,
FileOperationType, FileOperationType,
IntegrationService,
IntegrationType,
} from "@shared/types"; } from "@shared/types";
import { import {
Share, Share,
@@ -241,15 +243,15 @@ export async function buildIntegration(overrides: Partial<Integration> = {}) {
teamId: overrides.teamId, teamId: overrides.teamId,
}); });
const authentication = await IntegrationAuthentication.create({ const authentication = await IntegrationAuthentication.create({
service: "slack", service: IntegrationService.Slack,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
token: "fake-access-token", token: "fake-access-token",
scopes: ["example", "scopes", "here"], scopes: ["example", "scopes", "here"],
}); });
return Integration.create({ return Integration.create({
type: "post", service: IntegrationService.Slack,
service: "slack", type: IntegrationType.Post,
events: ["documents.update", "documents.publish"], events: ["documents.update", "documents.publish"],
settings: { settings: {
serviceTeamId: "slack_team_id", serviceTeamId: "slack_team_id",

View File

@@ -89,7 +89,7 @@ export function assertUrl(
require_valid_protocol: true, require_valid_protocol: true,
}) })
) { ) {
throw ValidationError(message ?? `${String(value)} is an invalid url!`); throw ValidationError(message ?? `${String(value)} is an invalid url`);
} }
} }
@@ -105,9 +105,7 @@ export function assertBoolean(
message?: string message?: string
): asserts value { ): asserts value {
if (typeof value !== "boolean") { if (typeof value !== "boolean") {
throw ValidationError( throw ValidationError(message ?? `${String(value)} is not a boolean`);
message ?? `${String(value)} is a ${typeof value}, not a boolean!`
);
} }
} }

View File

@@ -220,20 +220,6 @@
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?", "Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.", "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
"Account": "Account",
"Notifications": "Notifications",
"API Tokens": "API Tokens",
"Details": "Details",
"Team": "Team",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"Groups": "Groups",
"Shared Links": "Shared Links",
"Import": "Import",
"Webhooks": "Webhooks",
"Integrations": "Integrations",
"Self Hosted": "Self Hosted",
"Insert column after": "Insert column after", "Insert column after": "Insert column after",
"Insert column before": "Insert column before", "Insert column before": "Insert column before",
"Insert row after": "Insert row after", "Insert row after": "Insert row after",
@@ -303,6 +289,21 @@
"Current time": "Current time", "Current time": "Current time",
"Current date and time": "Current date and time", "Current date and time": "Current date and time",
"Could not import file": "Could not import file", "Could not import file": "Could not import file",
"Account": "Account",
"Notifications": "Notifications",
"API Tokens": "API Tokens",
"Details": "Details",
"Team": "Team",
"Security": "Security",
"Features": "Features",
"Members": "Members",
"Groups": "Groups",
"Shared Links": "Shared Links",
"Import": "Import",
"Webhooks": "Webhooks",
"Integrations": "Integrations",
"Self Hosted": "Self Hosted",
"Google Analytics": "Google Analytics",
"Show path to document": "Show path to document", "Show path to document": "Show path to document",
"Path to document": "Path to document", "Path to document": "Path to document",
"Group member options": "Group member options", "Group member options": "Group member options",
@@ -702,6 +703,9 @@
"When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.", "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.",
"Public branding": "Public branding", "Public branding": "Public branding",
"Show your teams logo on public pages like login and shared documents.": "Show your teams logo on public pages like login and shared documents.", "Show your teams logo on public pages like login and shared documents.": "Show your teams logo on public pages like login and shared documents.",
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.",
"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.",
"New group": "New group", "New group": "New group",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.", "Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"All groups": "All groups", "All groups": "All groups",
@@ -774,8 +778,8 @@
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents", "Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
"Collection creation": "Collection creation", "Collection creation": "Collection creation",
"Allow members to create new collections within the knowledge base": "Allow members to create new collections within the knowledge base", "Allow members to create new collections within the knowledge base": "Allow members to create new collections within the knowledge base",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Draw.io deployment": "Draw.io deployment", "Draw.io deployment": "Draw.io deployment",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Sharing is currently disabled.": "Sharing is currently disabled.", "Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.", "You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.", "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",

View File

@@ -52,6 +52,10 @@ export type PublicEnv = {
DEFAULT_LANGUAGE: string; DEFAULT_LANGUAGE: string;
GOOGLE_ANALYTICS_ID: string | undefined; GOOGLE_ANALYTICS_ID: string | undefined;
RELEASE: string | undefined; RELEASE: string | undefined;
analytics: {
service?: IntegrationService;
settings?: IntegrationSettings<IntegrationType.Analytics>;
};
}; };
export enum AttachmentPreset { export enum AttachmentPreset {
@@ -64,6 +68,13 @@ export enum IntegrationType {
Post = "post", Post = "post",
Command = "command", Command = "command",
Embed = "embed", Embed = "embed",
Analytics = "analytics",
}
export enum IntegrationService {
Diagrams = "diagrams",
Slack = "slack",
GoogleAnalytics = "google-analytics",
} }
export enum CollectionPermission { export enum CollectionPermission {
@@ -73,6 +84,8 @@ export enum CollectionPermission {
export type IntegrationSettings<T> = T extends IntegrationType.Embed export type IntegrationSettings<T> = T extends IntegrationType.Embed
? { url: string } ? { url: string }
: T extends IntegrationType.Analytics
? { measurementId: string }
: T extends IntegrationType.Post : T extends IntegrationType.Post
? { url: string; channel: string; channelId: string } ? { url: string; channel: string; channelId: string }
: T extends IntegrationType.Post : T extends IntegrationType.Post
@@ -80,7 +93,8 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
: :
| { url: string } | { url: string }
| { url: string; channel: string; channelId: string } | { url: string; channel: string; channelId: string }
| { serviceTeamId: string }; | { serviceTeamId: string }
| { measurementId: string };
export enum UserPreference { export enum UserPreference {
/** Whether reopening the app should redirect to the last viewed document. */ /** Whether reopening the app should redirect to the last viewed document. */