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 */
import { escape } from "lodash";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
@@ -17,9 +20,6 @@ const Analytics: React.FC = ({ children }) => {
ga.l = +new Date();
ga("create", env.GOOGLE_ANALYTICS_ID, "auto");
ga("set", {
dimension1: "true",
});
ga("send", "pageview");
const script = document.createElement("script");
script.src = "https://www.google-analytics.com/analytics.js";
@@ -30,9 +30,28 @@ const Analytics: React.FC = ({ children }) => {
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}</>;

View File

@@ -11,7 +11,7 @@ import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsAction";
import useSettingsActions from "~/hooks/useSettingsActions";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types";
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 Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar";
@@ -21,7 +21,7 @@ import Version from "./components/Version";
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const configs = useAuthorizedSettingsConfig();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
BelongsTo,
Column,
} from "sequelize-typescript";
import { IntegrationService } from "@shared/types";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
@@ -17,8 +18,8 @@ import Fix from "./decorators/Fix";
@Table({ tableName: "authentications", modelName: "authentication" })
@Fix
class IntegrationAuthentication extends IdModel {
@Column
service: string;
@Column(DataType.STRING)
service: IntegrationService;
@Column(DataType.ARRAY(DataType.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 { Integration } from "@server/models";
// 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
export default function present(env: Environment): PublicEnv {
export default function present(
env: Environment,
analytics?: Integration<IntegrationType.Analytics> | null
): PublicEnv {
return {
URL: env.URL.replace(/\/$/, ""),
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,
RELEASE:
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 { Op } from "sequelize";
import { IntegrationType } from "@shared/types";
import { IntegrationService, IntegrationType } from "@shared/types";
import env from "@server/env";
import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters";
@@ -36,8 +36,8 @@ export default class SlackProcessor extends BaseProcessor {
const integration = (await Integration.findOne({
where: {
id: event.modelId,
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
},
include: [
{
@@ -98,8 +98,8 @@ export default class SlackProcessor extends BaseProcessor {
where: {
teamId: document.teamId,
collectionId: document.collectionId,
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
events: {
[Op.contains]: [
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 { IntegrationAuthentication, SearchQuery } from "@server/models";
import { buildDocument, buildIntegration } from "@server/test/factories";
@@ -14,7 +15,7 @@ describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: "",

View File

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

View File

@@ -1,5 +1,6 @@
import Router from "koa-router";
import { has } from "lodash";
import { WhereOptions } from "sequelize";
import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { Event } from "@server/models";
@@ -21,17 +22,27 @@ const router = new Router();
router.post("integrations.list", auth(), pagination(), async (ctx) => {
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") {
direction = "DESC";
}
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({
where: {
teamId: user.teamId,
},
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,

View File

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

View File

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

View File

@@ -5,9 +5,12 @@ import Router from "koa-router";
import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n";
import { IntegrationType } from "@shared/types";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
import { Integration } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots";
import apexRedirect from "../middlewares/apexRedirect";
import { renderApp, renderShare } from "./app";
@@ -118,7 +121,21 @@ router.get("/s/:shareId/doc/:documentSlug", renderShare);
router.get("/s/:shareId/*", renderShare);
// 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
// must be provided when serving the application, see:

View File

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

View File

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

View File

@@ -89,7 +89,7 @@ export function assertUrl(
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
): asserts value {
if (typeof value !== "boolean") {
throw ValidationError(
message ?? `${String(value)} is a ${typeof value}, not a boolean!`
);
throw ValidationError(message ?? `${String(value)} is 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 }} 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.",
"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 before": "Insert column before",
"Insert row after": "Insert row after",
@@ -303,6 +289,21 @@
"Current time": "Current time",
"Current date and time": "Current date and time",
"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",
"Path to document": "Path to document",
"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.",
"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.",
"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",
"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",
@@ -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",
"Collection creation": "Collection creation",
"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",
"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.",
"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.",

View File

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