diff --git a/app/components/Analytics.tsx b/app/components/Analytics.tsx index 4d6f56cb6..59c75b0a1 100644 --- a/app/components/Analytics.tsx +++ b/app/components/Analytics.tsx @@ -2,13 +2,14 @@ /* global ga */ import escape from "lodash/escape"; import * as React from "react"; -import { IntegrationService } from "@shared/types"; +import { IntegrationService, PublicEnv } from "@shared/types"; import env from "~/env"; type Props = { children?: React.ReactNode; }; +// TODO: Refactor this component to allow injection from plugins const Analytics: React.FC = ({ children }: Props) => { // Google Analytics 3 React.useEffect(() => { @@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => { React.useEffect(() => { const measurementIds = []; - if (env.analytics.service === IntegrationService.GoogleAnalytics) { - measurementIds.push(escape(env.analytics.settings?.measurementId)); - } if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) { measurementIds.push(env.GOOGLE_ANALYTICS_ID); } + + (env.analytics as PublicEnv["analytics"]).forEach((integration) => { + if (integration.service === IntegrationService.GoogleAnalytics) { + measurementIds.push(escape(integration.settings?.measurementId)); + } + }); + if (measurementIds.length === 0) { return; } @@ -75,6 +80,32 @@ const Analytics: React.FC = ({ children }: Props) => { document.getElementsByTagName("head")[0]?.appendChild(script); }, []); + // Matomo + React.useEffect(() => { + (env.analytics as PublicEnv["analytics"]).forEach((integration) => { + if (integration.service !== IntegrationService.Matomo) { + return; + } + + // @ts-expect-error - Matomo global variable + const _paq = (window._paq = window._paq || []); + _paq.push(["trackPageView"]); + _paq.push(["enableLinkTracking"]); + (function () { + const u = integration.settings?.instanceUrl; + _paq.push(["setTrackerUrl", u + "matomo.php"]); + _paq.push(["setSiteId", integration.settings?.measurementId]); + const d = document, + g = d.createElement("script"), + s = d.getElementsByTagName("script")[0]; + g.type = "text/javascript"; + g.async = true; + g.src = u + "matomo.js"; + s.parentNode?.insertBefore(g, s); + })(); + }); + }, []); + return <>{children}; }; diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 482a100f1..c97dfdfc9 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -1,3 +1,4 @@ +import sortBy from "lodash/sortBy"; import { EmailIcon, ProfileIcon, @@ -18,7 +19,6 @@ import { import React, { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; -import GoogleIcon from "~/components/Icons/GoogleIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon"; import PluginLoader from "~/utils/PluginLoader"; import isCloudHosted from "~/utils/isCloudHosted"; @@ -32,7 +32,6 @@ const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys")); const Details = lazy(() => import("~/scenes/Settings/Details")); const Export = lazy(() => import("~/scenes/Settings/Export")); const Features = lazy(() => import("~/scenes/Settings/Features")); -const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics")); const Groups = lazy(() => import("~/scenes/Settings/Groups")); const Import = lazy(() => import("~/scenes/Settings/Import")); const Members = lazy(() => import("~/scenes/Settings/Members")); @@ -177,14 +176,6 @@ const useSettingsConfig = () => { group: t("Integrations"), icon: BuildingBlocksIcon, }, - { - name: t("Google Analytics"), - path: integrationSettingsPath("google-analytics"), - component: GoogleAnalytics, - enabled: can.update, - group: t("Integrations"), - icon: GoogleIcon, - }, { name: "Zapier", path: integrationSettingsPath("zapier"), @@ -196,30 +187,37 @@ const useSettingsConfig = () => { ]; // Plugins - Object.values(PluginLoader.plugins).map((plugin) => { - const hasSettings = !!plugin.settings; - const enabledInDeployment = - !plugin.config?.deployments || - plugin.config.deployments.length === 0 || - (plugin.config.deployments.includes("cloud") && isCloudHosted) || - (plugin.config.deployments.includes("enterprise") && !isCloudHosted); + const insertIndex = items.findIndex((i) => i.group === t("Integrations")); + items.splice( + insertIndex, + 0, + ...(sortBy( + Object.values(PluginLoader.plugins), + (plugin) => plugin.config?.priority ?? 0 + ).map((plugin) => { + const hasSettings = !!plugin.settings; + const enabledInDeployment = + !plugin.config?.deployments || + plugin.config.deployments.length === 0 || + (plugin.config.deployments.includes("community") && !isCloudHosted) || + (plugin.config.deployments.includes("cloud") && isCloudHosted) || + (plugin.config.deployments.includes("enterprise") && !isCloudHosted); - const item = { - name: t(plugin.config.name), - path: integrationSettingsPath(plugin.id), - // TODO: Remove hardcoding of plugin id here - group: plugin.id === "collections" ? t("Workspace") : t("Integrations"), - component: plugin.settings, - enabled: - enabledInDeployment && - hasSettings && - (plugin.config.roles?.includes(user.role) || can.update), - icon: plugin.icon, - } as ConfigItem; - - const insertIndex = items.findIndex((i) => i.group === t("Integrations")); - items.splice(insertIndex, 0, item); - }); + return { + name: t(plugin.config.name), + path: integrationSettingsPath(plugin.id), + // TODO: Remove hardcoding of plugin id here + group: + plugin.id === "collections" ? t("Workspace") : t("Integrations"), + component: plugin.settings, + enabled: + enabledInDeployment && + hasSettings && + (plugin.config.roles?.includes(user.role) || can.update), + icon: plugin.icon, + }; + }) as ConfigItem[]) + ); return items; }, [t, can.createApiKey, can.update, can.createImport, can.createExport]); diff --git a/app/utils/PluginLoader.ts b/app/utils/PluginLoader.ts index 2f74bcfee..6593a4079 100644 --- a/app/utils/PluginLoader.ts +++ b/app/utils/PluginLoader.ts @@ -8,6 +8,7 @@ interface Plugin { description: string; roles?: UserRole[]; deployments?: string[]; + priority?: number; }; settings: React.FC; icon: React.FC<{ size?: number; fill?: string }>; diff --git a/plugins/google/plugin.json b/plugins/google/plugin.json index 30b732cd2..44d727a66 100644 --- a/plugins/google/plugin.json +++ b/plugins/google/plugin.json @@ -1,6 +1,6 @@ { "id": "google", "name": "Google", - "priority": 10, + "priority": 0, "description": "Adds a Google authentication provider." } diff --git a/plugins/googleanalytics/client/Icon.tsx b/plugins/googleanalytics/client/Icon.tsx new file mode 100644 index 000000000..a8f7cc3ac --- /dev/null +++ b/plugins/googleanalytics/client/Icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +type Props = { + /** The size of the icon, 24px is default to match standard icons */ + size?: number; + /** The color of the icon, defaults to the current text color */ + fill?: string; + className?: string; +}; + +function GoogleLogo({ size = 24, fill = "currentColor", className }: Props) { + return ( + + + + ); +} + +export default GoogleLogo; diff --git a/app/scenes/Settings/GoogleAnalytics.tsx b/plugins/googleanalytics/client/Settings.tsx similarity index 97% rename from app/scenes/Settings/GoogleAnalytics.tsx rename to plugins/googleanalytics/client/Settings.tsx index ef40bb084..632384c4d 100644 --- a/app/scenes/Settings/GoogleAnalytics.tsx +++ b/plugins/googleanalytics/client/Settings.tsx @@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; import { IntegrationType, IntegrationService } from "@shared/types"; import Integration from "~/models/Integration"; +import SettingRow from "~/scenes/Settings/components/SettingRow"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import GoogleIcon from "~/components/Icons/GoogleIcon"; @@ -13,7 +14,6 @@ import Input from "~/components/Input"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; -import SettingRow from "./components/SettingRow"; type FormData = { measurementId: string; diff --git a/plugins/googleanalytics/plugin.json b/plugins/googleanalytics/plugin.json new file mode 100644 index 000000000..9548ca4f3 --- /dev/null +++ b/plugins/googleanalytics/plugin.json @@ -0,0 +1,6 @@ +{ + "id": "googleanalytics", + "name": "Google Analytics", + "priority": 30, + "description": "Adds support for reporting analytics to a Google." +} diff --git a/plugins/matomo/client/Icon.tsx b/plugins/matomo/client/Icon.tsx new file mode 100644 index 000000000..f83b56d7f --- /dev/null +++ b/plugins/matomo/client/Icon.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +type Props = { + /** The size of the icon, 24px is default to match standard icons */ + size?: number; + /** The color of the icon, defaults to the current text color */ + fill?: string; +}; + +export default function Icon({ size = 24, fill = "currentColor" }: Props) { + return ( + + + + ); +} diff --git a/plugins/matomo/client/Settings.tsx b/plugins/matomo/client/Settings.tsx new file mode 100644 index 000000000..7ad7565d1 --- /dev/null +++ b/plugins/matomo/client/Settings.tsx @@ -0,0 +1,128 @@ +import find from "lodash/find"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation, Trans } from "react-i18next"; +import { toast } from "sonner"; +import { IntegrationType, IntegrationService } from "@shared/types"; +import Integration from "~/models/Integration"; +import SettingRow from "~/scenes/Settings/components/SettingRow"; +import Button from "~/components/Button"; +import Heading from "~/components/Heading"; +import Input from "~/components/Input"; +import Scene from "~/components/Scene"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; +import Icon from "./Icon"; + +type FormData = { + instanceUrl: string; + measurementId: string; +}; + +function Matomo() { + const { integrations } = useStores(); + const { t } = useTranslation(); + + const integration = find(integrations.orderedData, { + type: IntegrationType.Analytics, + service: IntegrationService.Matomo, + }) as Integration | undefined; + + const { + register, + reset, + handleSubmit: formHandleSubmit, + formState, + } = useForm({ + mode: "all", + defaultValues: { + instanceUrl: integration?.settings.instanceUrl, + measurementId: integration?.settings.measurementId, + }, + }); + + React.useEffect(() => { + void integrations.fetchPage({ + type: IntegrationType.Analytics, + }); + }, [integrations]); + + React.useEffect(() => { + reset({ + measurementId: integration?.settings.measurementId, + instanceUrl: integration?.settings.instanceUrl, + }); + }, [integration, reset]); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + if (data.instanceUrl && data.measurementId) { + await integrations.save({ + id: integration?.id, + type: IntegrationType.Analytics, + service: IntegrationService.Matomo, + settings: { + measurementId: data.measurementId, + // Ensure the URL ends with a trailing slash + instanceUrl: data.instanceUrl.replace(/\/?$/, "/"), + } as Integration["settings"], + }); + } else { + await integration?.delete(); + } + + toast.success(t("Settings saved")); + } catch (err) { + toast.error(err.message); + } + }, + [integrations, integration, t] + ); + + return ( + }> + Matomo + + + + Configure a Matomo installation to send views and analytics from the + workspace to your own Matomo instance. + + +
+ + + + + + + + +
+
+ ); +} + +export default observer(Matomo); diff --git a/plugins/matomo/plugin.json b/plugins/matomo/plugin.json new file mode 100644 index 000000000..62bfa0a66 --- /dev/null +++ b/plugins/matomo/plugin.json @@ -0,0 +1,8 @@ +{ + "id": "matomo", + "name": "Matomo", + "roles": ["admin"], + "priority": 40, + "description": "Adds support for reporting analytics to a Matomo server.", + "deployments": ["community", "enterprise"] +} diff --git a/plugins/oidc/plugin.json b/plugins/oidc/plugin.json index b693a1d6e..00d3a251d 100644 --- a/plugins/oidc/plugin.json +++ b/plugins/oidc/plugin.json @@ -1,6 +1,6 @@ { "id": "oidc", "name": "OIDC", - "priority": 30, + "priority": 10, "description": "Adds an OpenID compatible authentication provider." } diff --git a/plugins/slack/plugin.json b/plugins/slack/plugin.json index 6a34e53e3..5c79e09e6 100644 --- a/plugins/slack/plugin.json +++ b/plugins/slack/plugin.json @@ -1,7 +1,7 @@ { "id": "slack", "name": "Slack", - "priority": 40, + "priority": 20, "roles": ["admin", "member"], "description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling." } diff --git a/plugins/webhooks/plugin.json b/plugins/webhooks/plugin.json index ef333c4e1..d7de0aa96 100644 --- a/plugins/webhooks/plugin.json +++ b/plugins/webhooks/plugin.json @@ -1,6 +1,6 @@ { "id": "webhooks", "name": "Webhooks", - "priority": 200, + "priority": 0, "description": "Adds HTTP webhooks for various events." } diff --git a/server/presenters/env.ts b/server/presenters/env.ts index e49d2b398..fdbab8640 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -7,16 +7,16 @@ import { Integration } from "@server/models"; export default function present( env: Environment, options: { - analytics?: Integration | null; + analytics?: Integration[]; rootShareId?: string | null; } = {} ): PublicEnv { return { ROOT_SHARE_ID: options.rootShareId || undefined, - analytics: { - service: options.analytics?.service, - settings: options.analytics?.settings, - }, + analytics: (options.analytics ?? []).map((integration) => ({ + service: integration?.service, + settings: integration?.settings, + })), ...env.public, }; } diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts index cbc3f1e1d..c1fcb17f6 100644 --- a/server/routes/api/integrations/schema.ts +++ b/server/routes/api/integrations/schema.ts @@ -51,7 +51,12 @@ export const IntegrationsCreateSchema = BaseSchema.extend({ channelId: z.string(), }) ) - .or(z.object({ measurementId: z.string() })) + .or( + z.object({ + measurementId: z.string(), + instanceUrl: z.string().url().optional(), + }) + ) .or(z.object({ serviceTeamId: z.string() })) .optional(), }), @@ -74,7 +79,12 @@ export const IntegrationsUpdateSchema = BaseSchema.extend({ channelId: z.string(), }) ) - .or(z.object({ measurementId: z.string() })) + .or( + z.object({ + measurementId: z.string(), + instanceUrl: z.string().url().optional(), + }) + ) .or(z.object({ serviceTeamId: z.string() })) .optional(), diff --git a/server/routes/app.ts b/server/routes/app.ts index 540c56441..703ed7668 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -52,7 +52,7 @@ export const renderApp = async ( canonical?: string; shortcutIcon?: string; rootShareId?: string; - analytics?: Integration | null; + analytics?: Integration[]; } = {} ) => { const { @@ -66,6 +66,19 @@ export const renderApp = async ( return next(); } + if (!env.isCloudHosted) { + options.analytics?.forEach((integration) => { + if (integration.settings?.instanceUrl) { + const parsed = new URL(integration.settings?.instanceUrl); + const csp = ctx.response.get("Content-Security-Policy"); + ctx.set( + "Content-Security-Policy", + csp.replace("script-src", `script-src ${parsed.hostname}`) + ); + } + }); + } + const { shareId } = ctx.params; const page = await readIndexFile(); const environment = ` @@ -112,7 +125,8 @@ 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, team, analytics; + let share, document, team; + let analytics: Integration[] = []; try { team = await getTeamFromContext(ctx); @@ -131,7 +145,7 @@ export const renderShare = async (ctx: Context, next: Next) => { } document = result.document; - analytics = await Integration.findOne({ + analytics = await Integration.findAll({ where: { teamId: document.teamId, type: IntegrationType.Analytics, diff --git a/server/routes/index.ts b/server/routes/index.ts index 0a3d8f865..77b3d8cde 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -147,13 +147,13 @@ router.get("*", shareDomains(), async (ctx, next) => { } const analytics = team - ? await Integration.findOne({ + ? await Integration.findAll({ where: { teamId: team.id, type: IntegrationType.Analytics, }, }) - : undefined; + : []; return renderApp(ctx, next, { analytics, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index e02beeadd..17784705f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -443,7 +443,6 @@ "Import": "Import", "Self Hosted": "Self Hosted", "Integrations": "Integrations", - "Google Analytics": "Google Analytics", "Choose a template": "Choose a template", "Revoke token": "Revoke token", "Revoke": "Revoke", @@ -842,9 +841,6 @@ "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.": "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.", "Commenting": "Commenting", "When enabled team members can add comments to documents.": "When enabled team members can add comments to 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.", "No groups have been created yet": "No groups have been created yet", @@ -969,6 +965,15 @@ "Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}", "Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?", "The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.", + "Google Analytics": "Google Analytics", + "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.", + "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.", + "Instance URL": "Instance URL", + "The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/", + "Site ID": "Site ID", + "An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.", "Add to Slack": "Add to Slack", "document published": "document published", "document updated": "document updated", diff --git a/shared/types.ts b/shared/types.ts index 6349f4df3..e97cb8a79 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -52,9 +52,9 @@ export enum MentionType { export type PublicEnv = { ROOT_SHARE_ID?: string; analytics: { - service?: IntegrationService; - settings?: IntegrationSettings; - }; + service: IntegrationService; + settings: IntegrationSettings; + }[]; }; export enum AttachmentPreset { @@ -82,6 +82,7 @@ export enum IntegrationService { Grist = "grist", Slack = "slack", GoogleAnalytics = "google-analytics", + Matomo = "matomo", GitHub = "github", } @@ -90,12 +91,14 @@ export type UserCreatableIntegrationService = Extract< | IntegrationService.Diagrams | IntegrationService.Grist | IntegrationService.GoogleAnalytics + | IntegrationService.Matomo >; export const UserCreatableIntegrationService = { Diagrams: IntegrationService.Diagrams, Grist: IntegrationService.Grist, GoogleAnalytics: IntegrationService.GoogleAnalytics, + Matomo: IntegrationService.Matomo, } as const; export enum CollectionPermission { @@ -121,7 +124,7 @@ export type IntegrationSettings = T extends IntegrationType.Embed }; } : T extends IntegrationType.Analytics - ? { measurementId: string } + ? { measurementId: string; instanceUrl?: string } : T extends IntegrationType.Post ? { url: string; channel: string; channelId: string } : T extends IntegrationType.Command