diff --git a/app/components/Analytics.tsx b/app/components/Analytics.tsx index 48853ebd9..bbd7f6f52 100644 --- a/app/components/Analytics.tsx +++ b/app/components/Analytics.tsx @@ -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}>; diff --git a/app/components/CommandBar.tsx b/app/components/CommandBar.tsx index d00a4d92d..4aae23add 100644 --- a/app/components/CommandBar.tsx +++ b/app/components/CommandBar.tsx @@ -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"; diff --git a/app/components/Icons/GoogleIcon.tsx b/app/components/Icons/GoogleIcon.tsx new file mode 100644 index 000000000..66a5309d5 --- /dev/null +++ b/app/components/Icons/GoogleIcon.tsx @@ -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 ( + + + + ); +} diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index fd9e63bec..8bd030c23 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -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(() => { diff --git a/app/hooks/useSettingsAction.tsx b/app/hooks/useSettingsActions.tsx similarity index 88% rename from app/hooks/useSettingsAction.tsx rename to app/hooks/useSettingsActions.tsx index 28e64b54f..dda958c17 100644 --- a/app/hooks/useSettingsAction.tsx +++ b/app/hooks/useSettingsActions.tsx @@ -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; diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useSettingsConfig.ts similarity index 92% rename from app/hooks/useAuthorizedSettingsConfig.ts rename to app/hooks/useSettingsConfig.ts index 022f22e7c..6b99b03cd 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -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; diff --git a/app/models/Integration.ts b/app/models/Integration.ts index f62e1ff85..5799ea78b 100644 --- a/app/models/Integration.ts +++ b/app/models/Integration.ts @@ -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 extends BaseModel { id: string; - type: string; + type: IntegrationType; - service: string; + service: IntegrationService; collectionId: string; diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 50e16f07e..af81f1b4c 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -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 ( diff --git a/app/scenes/Settings/GoogleAnalytics.tsx b/app/scenes/Settings/GoogleAnalytics.tsx new file mode 100644 index 000000000..9a4cb55d1 --- /dev/null +++ b/app/scenes/Settings/GoogleAnalytics.tsx @@ -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 | undefined; + + const { + register, + reset, + handleSubmit: formHandleSubmit, + formState, + } = useForm({ + 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 ( + } + > + {t("Google Analytics")} + + + + Add a Google Analytics 4 measurement ID to send document views and + analytics from the workspace to your own Google Analytics account. + + + + + + + + + {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} + + + + ); +} + +export default observer(GoogleAnalytics); diff --git a/app/scenes/Settings/SelfHosted.tsx b/app/scenes/Settings/SelfHosted.tsx index 5c3eb31e5..af1b8cc08 100644 --- a/app/scenes/Settings/SelfHosted.tsx +++ b/app/scenes/Settings/SelfHosted.tsx @@ -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 | 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() { > {t("Self Hosted")} - - - Add your self-hosted draw.io installation url here to enable automatic - embedding of diagrams within documents. - - - - - - {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} - - - - + + + + + + + {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} + + ); } diff --git a/app/stores/IntegrationsStore.ts b/app/stores/IntegrationsStore.ts index e9f0888b7..ceb36795f 100644 --- a/app/stores/IntegrationsStore.ts +++ b/app/stores/IntegrationsStore.ts @@ -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 { @computed get slackIntegrations(): Integration[] { return filter(this.orderedData, { - service: "slack", + service: IntegrationService.Slack, }); } } diff --git a/app/typings/window.d.ts b/app/typings/window.d.ts index 8442161ca..e8ae1b036 100644 --- a/app/typings/window.d.ts +++ b/app/typings/window.d.ts @@ -1,5 +1,7 @@ declare global { interface Window { + dataLayer: any[]; + DesktopBridge: { /** * The name of the platform running on. diff --git a/server/env.ts b/server/env.ts index ca17f5d2d..f195bf870 100644 --- a/server/env.ts +++ b/server/env.ts @@ -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() diff --git a/server/migrations/20230101144349-integration-indexes.js b/server/migrations/20230101144349-integration-indexes.js new file mode 100644 index 000000000..9ac7c0c58 --- /dev/null +++ b/server/migrations/20230101144349-integration-indexes.js @@ -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"]); + } +}; diff --git a/server/models/Integration.ts b/server/models/Integration.ts index 7ea0b1551..1d188e9a9 100644 --- a/server/models/Integration.ts +++ b/server/models/Integration.ts @@ -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 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; diff --git a/server/models/IntegrationAuthentication.ts b/server/models/IntegrationAuthentication.ts index 18aee1ea2..207258aff 100644 --- a/server/models/IntegrationAuthentication.ts +++ b/server/models/IntegrationAuthentication.ts @@ -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[]; diff --git a/server/presenters/env.ts b/server/presenters/env.ts index e50d4a909..c31c5c781 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -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 | 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, + }, }; } diff --git a/server/queues/processors/SlackProcessor.ts b/server/queues/processors/SlackProcessor.ts index 4b0f0f61f..1561d05dc 100644 --- a/server/queues/processors/SlackProcessor.ts +++ b/server/queues/processors/SlackProcessor.ts @@ -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, diff --git a/server/routes/api/hooks.test.ts b/server/routes/api/hooks.test.ts index ab8632fe4..50b788905 100644 --- a/server/routes/api/hooks.test.ts +++ b/server/routes/api/hooks.test.ts @@ -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: "", diff --git a/server/routes/api/hooks.ts b/server/routes/api/hooks.ts index 25be2f0e9..d74900c77 100644 --- a/server/routes/api/hooks.ts +++ b/server/routes/api/hooks.ts @@ -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, }, }); diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations.ts index a3392b48d..bfa521abd 100644 --- a/server/routes/api/integrations.ts +++ b/server/routes/api/integrations.ts @@ -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 = { + 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, diff --git a/server/routes/app.ts b/server/routes/app.ts index 146e68a99..d42870045 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -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 => { 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, diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index b28c1227d..1b57ce800 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -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, diff --git a/server/routes/index.ts b/server/routes/index.ts index 1d15df107..4514cc8cf 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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: diff --git a/server/services/web.ts b/server/services/web.ts index 7333bae8a..af89f585e 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -23,6 +23,7 @@ const scriptSrc = [ "'unsafe-inline'", "'unsafe-eval'", "gist.github.com", + "www.googletagmanager.com", "cdn.zapier.com", ]; diff --git a/server/test/factories.ts b/server/test/factories.ts index 4776b4913..8f9061a82 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -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 = {}) { 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", diff --git a/server/validation.ts b/server/validation.ts index 70acd1b21..c1995448f 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -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`); } } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 578cb8871..db97ec019 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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 team’s logo on public pages like login and shared documents.": "Show your team’s 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 security settings.": "You can globally enable and disable public document sharing in the security settings.", "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.", diff --git a/shared/types.ts b/shared/types.ts index 43b04e8ca..e7b286df2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -52,6 +52,10 @@ export type PublicEnv = { DEFAULT_LANGUAGE: string; GOOGLE_ANALYTICS_ID: string | undefined; RELEASE: string | undefined; + analytics: { + service?: IntegrationService; + settings?: IntegrationSettings; + }; }; 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 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 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. */
- - - {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} - -