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:
@@ -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}</>;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
25
app/components/Icons/GoogleIcon.tsx
Normal file
25
app/components/Icons/GoogleIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
115
app/scenes/Settings/GoogleAnalytics.tsx
Normal file
115
app/scenes/Settings/GoogleAnalytics.tsx
Normal 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);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/typings/window.d.ts
vendored
2
app/typings/window.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
11
server/migrations/20230101144349-integration-indexes.js
Normal file
11
server/migrations/20230101144349-integration-indexes.js
Normal 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"]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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!`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 team’s logo on public pages like login and shared documents.": "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.": "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",
|
"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.",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user