Compare commits

...

6 Commits

Author SHA1 Message Date
Tom Moor 16923cbe1b Optional integration type 2023-08-06 10:13:29 -04:00
Tom Moor 7a21d57840 Working connect/disconnect 2023-08-06 10:01:25 -04:00
Tom Moor 578d9fe8d2 wip 2023-08-05 23:43:41 -04:00
Tom Moor 3ba1b55fc9 First pass 2023-08-05 22:39:16 -04:00
Tom Moor 9dcd087c82 stash 2023-08-05 19:41:06 -04:00
Tom Moor a56ab96210 Add refreshToken storage for integrations 2023-08-05 10:58:23 -04:00
22 changed files with 424 additions and 45 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ export default function SearchActions() {
React.useEffect(() => {
if (!searches.isLoaded) {
void searches.fetchPage({});
void searches.fetchPage();
}
}, [searches]);
+9 -1
View File
@@ -16,10 +16,12 @@ type RequestResponse<T> = {
* A hook to make an API request and track its state within a component.
*
* @param requestFn The function to call to make the request, it should return a promise.
* @param makeRequestOnMount Whether to make the request when the component mounts.
* @returns
*/
export default function useRequest<T = unknown>(
requestFn: () => Promise<T>
requestFn: () => Promise<T>,
makeRequestOnMount = false
): RequestResponse<T> {
const isMounted = useIsMounted();
const [data, setData] = React.useState<T>();
@@ -48,5 +50,11 @@ export default function useRequest<T = unknown>(
return undefined;
}, [requestFn, isMounted]);
React.useEffect(() => {
if (makeRequestOnMount) {
void request();
}
}, [request, makeRequestOnMount]);
return { data, loading, error, request };
}
+11 -1
View File
@@ -12,6 +12,7 @@ import {
SettingsIcon,
ExportIcon,
ImportIcon,
GlobeIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -23,6 +24,7 @@ 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 LinkedAccounts from "~/scenes/Settings/LinkedAccounts";
import Members from "~/scenes/Settings/Members";
import Notifications from "~/scenes/Settings/Notifications";
import Preferences from "~/scenes/Settings/Preferences";
@@ -79,6 +81,14 @@ const useSettingsConfig = () => {
group: t("Account"),
icon: EmailIcon,
},
{
name: t("Linked Accounts"),
path: settingsPath("linked-accounts"),
component: LinkedAccounts,
enabled: true,
group: t("Account"),
icon: LinkIcon,
},
{
name: t("API Tokens"),
path: settingsPath("tokens"),
@@ -134,7 +144,7 @@ const useSettingsConfig = () => {
component: Shares,
enabled: true,
group: t("Workspace"),
icon: LinkIcon,
icon: GlobeIcon,
},
{
name: t("Import"),
@@ -18,7 +18,7 @@ function RecentSearches() {
const [isPreloaded] = React.useState(searches.recent.length > 0);
React.useEffect(() => {
void searches.fetchPage({});
void searches.fetchPage();
}, [searches]);
const content = searches.recent.length ? (
+201
View File
@@ -0,0 +1,201 @@
import { partition } from "lodash";
import { observer } from "mobx-react";
import { LinkIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { setCookie } from "tiny-cookie";
import SlackLogo from "~/components/AuthLogo/SlackLogo";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import List from "~/components/List";
import PlaceholderList from "~/components/List/Placeholder";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import DisconnectAccountDialog from "./components/DisconnectAccountDialog";
import Integration from "./components/Integration";
function LinkedAccounts() {
const { t } = useTranslation();
const { integrations, authenticationProviders } = useStores();
const { loading: loadingAuthenticationProviders } = useRequest(
authenticationProviders.fetchPage,
true
);
const { loading: loadingIntegrations } = useRequest(
integrations.fetchPage,
true
);
const accounts = partition(
[
{
isEnabled: authenticationProviders.getByName("google")?.isEnabled,
isActive: authenticationProviders.getByName("google")?.isActive,
Component: GoogleAuthAccount,
},
{
isEnabled: authenticationProviders.getByName("slack")?.isEnabled,
isActive: authenticationProviders.getByName("slack")?.isActive,
Component: SlackAuthAccount,
},
],
"isActive"
);
const appName = env.APP_NAME;
const loading = loadingAuthenticationProviders || loadingIntegrations;
const isEmpty =
authenticationProviders.orderedData.length === 0 &&
integrations.orderedData.length === 0;
const activeIntegrations = accounts[0].filter((account) => account.isEnabled);
const inactiveIntegrations = accounts[1].filter(
(account) => account.isEnabled
);
return (
<Scene title={t("Linked Accounts")} icon={<LinkIcon />}>
<Heading>{t("Linked Accounts")}</Heading>
<Text type="secondary">
<Trans>
Manage the third-party services that are connected to {{ appName }}.
</Trans>
</Text>
{loading && isEmpty ? (
<PlaceholderList count={5} />
) : (
<>
{activeIntegrations.length > 0 && (
<List>
<Heading as="h2">{t("Connected")}</Heading>
{activeIntegrations.map(({ Component }, index) => (
<Component key={index} />
))}
</List>
)}
{inactiveIntegrations.length > 0 && (
<List>
<Heading as="h2">{t("Available")}</Heading>
{inactiveIntegrations.map(({ Component }, index) => (
<Component key={index} />
))}
</List>
)}
</>
)}
</Scene>
);
}
function GoogleAuthAccount() {
const { t } = useTranslation();
const location = useLocation();
const { dialogs, authenticationProviders } = useStores();
const team = useCurrentTeam();
const integration = authenticationProviders.getByName("google");
const isActive = integration?.isActive ?? false;
const connect = () => {
setCookie("postLoginRedirectPath", location.pathname);
window.location.href = "/auth/google";
};
const disconnect = () => {
dialogs.openModal({
title: t("Disconnect account"),
isCentered: true,
content: (
<DisconnectAccountDialog
title="Google"
isAuthentication
onSubmit={() => {
void client.post("/userAuthentications.delete", {
authenticationProviderId: integration?.id,
});
dialogs.closeAllModals();
}}
/>
),
});
};
return (
<Integration
title="Google"
subtitle={
isActive
? "Your Google account is connected and can be used for sign-in"
: "Connect with Google"
}
icon={<GoogleIcon />}
actions={
isActive ? (
<Button neutral onClick={disconnect} disabled={!team.guestSignin}>
{t("Disconnect")}
</Button>
) : (
<Button onClick={connect}>{t("Connect")}</Button>
)
}
/>
);
}
function SlackAuthAccount() {
const { t } = useTranslation();
const { dialogs, authenticationProviders } = useStores();
const team = useCurrentTeam();
const integration = authenticationProviders.getByName("slack");
const isActive = integration?.isActive ?? false;
const disconnect = () => {
dialogs.openModal({
title: t("Disconnect account"),
isCentered: true,
content: (
<DisconnectAccountDialog
title="Slack"
isAuthentication
onSubmit={() => {
void client.post("/userAuthentications.delete", {
authenticationProviderId: integration?.id,
});
dialogs.closeAllModals();
}}
/>
),
});
};
return (
<Integration
title="Slack"
subtitle={
isActive
? "Your Slack account is connected and can be used for sign-in"
: "Connect with Slack"
}
icon={<SlackLogo size={16} fill="currentColor" />}
actions={
isActive ? (
<Button neutral onClick={disconnect} disabled={!team.guestSignin}>
{t("Disconnect")}
</Button>
) : (
<Button as="a" href="/auth/slack">
{t("Connect")}
</Button>
)
}
/>
);
}
export default observer(LinkedAccounts);
+1 -1
View File
@@ -42,7 +42,7 @@ function Security() {
data: providers,
loading,
request,
} = useRequest(() => authenticationProviders.fetchPage({}));
} = useRequest(authenticationProviders.fetchPage);
React.useEffect(() => {
if (!providers && !loading) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import { LinkIcon, WarningIcon } from "outline-icons";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
@@ -67,7 +67,7 @@ function Shares() {
}, [shares.orderedData, shareIds]);
return (
<Scene title={t("Shared Links")} icon={<LinkIcon />}>
<Scene title={t("Shared Links")} icon={<GlobeIcon />}>
<Heading>{t("Shared Links")}</Heading>
{can.manage && !canShareDocuments && (
@@ -0,0 +1,36 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
type Props = React.ComponentProps<typeof ConfirmationDialog> & {
title: string;
isAuthentication?: boolean;
};
function DisconnectAccountDialog({
title,
isAuthentication,
onSubmit,
...props
}: Props) {
const { t } = useTranslation();
return (
<ConfirmationDialog
onSubmit={onSubmit}
submitText={t("Confirm")}
savingText={`${t("Disconnecting")}`}
{...props}
>
<Trans>
Are you sure you want to disconnect your account from {{ title }}?
</Trans>{" "}
{isAuthentication ? (
<Trans>You will no longer be able to sign in with this account.</Trans>
) : (
<Trans>Associated functionality will be disabled.</Trans>
)}
</ConfirmationDialog>
);
}
export default DisconnectAccountDialog;
@@ -0,0 +1,22 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Item from "~/components/List/Item";
const Integration = ({
icon,
...props
}: Omit<React.ComponentProps<typeof Item>, "image"> & {
icon: React.ReactNode;
}) => <Item image={<IconBackground>{icon}</IconBackground>} {...props} />;
const IconBackground = styled(Flex)`
background: ${(props) => props.theme.secondaryBackground};
border-radius: 4px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
`;
export default Integration;
@@ -8,4 +8,12 @@ export default class AuthenticationProvidersStore extends BaseStore<Authenticati
constructor(rootStore: RootStore) {
super(rootStore, AuthenticationProvider);
}
getAllByName(name: string) {
return this.orderedData.filter((provider) => provider.name === name);
}
getByName(name: string) {
return this.orderedData.find((provider) => provider.name === name);
}
}
+1 -1
View File
@@ -220,7 +220,7 @@ export default abstract class BaseStore<T extends BaseModel> {
}
@action
fetchPage = async (params: FetchPageParams | undefined): Promise<T[]> => {
fetchPage = async (params?: FetchPageParams | undefined): Promise<T[]> => {
if (!this.actions.includes(RPCAction.List)) {
throw new Error(`Cannot list ${this.modelName}`);
}
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn("authentications", "refreshToken", {
type: Sequelize.BLOB,
allowNull: true,
});
},
async down (queryInterface) {
await queryInterface.removeColumn("authentications", "refreshToken");
}
};
+1 -1
View File
@@ -65,7 +65,7 @@ class AuthenticationProvider extends Model {
@Column(DataType.UUID)
teamId: string;
@HasMany(() => UserAuthentication, "providerId")
@HasMany(() => UserAuthentication, "authenticationProviderId")
userAuthentications: UserAuthentication[];
// instance methods
@@ -34,6 +34,16 @@ class IntegrationAuthentication extends IdModel {
setEncryptedColumn(this, "token", value);
}
@Column(DataType.BLOB)
@Encrypted
get refreshToken() {
return getEncryptedColumn(this, "refreshToken");
}
set refreshToken(value: string) {
setEncryptedColumn(this, "refreshToken", value);
}
// associations
@BelongsTo(() => User, "userId")
+1 -1
View File
@@ -8,6 +8,6 @@ export default function presentAuthenticationProvider(
name: authenticationProvider.name,
createdAt: authenticationProvider.createdAt,
isEnabled: authenticationProvider.enabled,
isConnected: true,
isConnected: authenticationProvider.userAuthentications.length > 0,
};
}
@@ -2,7 +2,11 @@ import Router from "koa-router";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { AuthenticationProvider, Event } from "@server/models";
import {
AuthenticationProvider,
Event,
UserAuthentication,
} from "@server/models";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import { authorize } from "@server/policies";
import {
@@ -16,7 +20,7 @@ const router = new Router();
router.post(
"authenticationProviders.info",
auth({ admin: true }),
auth(),
validate(T.AuthenticationProvidersInfoSchema),
async (ctx: APIContext<T.AuthenticationProvidersInfoReq>) => {
const { id } = ctx.input.body;
@@ -77,37 +81,44 @@ router.post(
}
);
router.post(
"authenticationProviders.list",
auth({ admin: true }),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
authorize(user, "read", user.team);
router.post("authenticationProviders.list", auth(), async (ctx: APIContext) => {
const { user } = ctx.state.auth;
authorize(user, "read", user.team);
const teamAuthenticationProviders = (await user.team.$get(
"authenticationProviders"
)) as AuthenticationProvider[];
const teamAuthenticationProviders = await AuthenticationProvider.findAll({
where: {
teamId: user.teamId,
},
include: [
{
model: UserAuthentication,
required: false,
where: {
userId: user.id,
},
},
],
});
const data = AuthenticationHelper.providers
.filter((p) => p.id !== "email")
.map((p) => {
const row = teamAuthenticationProviders.find((t) => t.name === p.id);
const data = AuthenticationHelper.providers
.filter((p) => p.id !== "email")
.map((p) => {
const row = teamAuthenticationProviders.find((t) => t.name === p.id);
return {
id: p.id,
name: p.id,
displayName: p.name,
isEnabled: false,
isConnected: false,
...(row ? presentAuthenticationProvider(row) : {}),
};
})
.sort((a) => (a.isEnabled ? -1 : 1));
return {
id: p.id,
name: p.id,
displayName: p.name,
isEnabled: false,
isConnected: false,
...(row ? presentAuthenticationProvider(row) : {}),
};
})
.sort((a) => (a.isEnabled ? -1 : 1));
ctx.body = {
data,
};
}
);
ctx.body = {
data,
};
});
export default router;
+3 -1
View File
@@ -33,6 +33,7 @@ import stars from "./stars";
import subscriptions from "./subscriptions";
import teams from "./teams";
import urls from "./urls";
import userAuthentications from "./userAuthentications";
import users from "./users";
import views from "./views";
@@ -68,7 +69,6 @@ glob
router.use("/", auth.routes());
router.use("/", authenticationProviders.routes());
router.use("/", events.routes());
router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", documents.routes());
@@ -88,6 +88,8 @@ router.use("/", cron.routes());
router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
router.use("/", userAuthentications.routes());
router.use("/", users.routes());
if (env.ENVIRONMENT === "development") {
router.use("/", developer.routes());
+1 -1
View File
@@ -21,7 +21,7 @@ export const IntegrationsListSchema = BaseSchema.extend({
.default("updatedAt"),
/** Integration type */
type: z.nativeEnum(IntegrationType),
type: z.nativeEnum(IntegrationType).optional(),
}),
});
@@ -0,0 +1 @@
export { default } from "./userAuthentications";
@@ -0,0 +1,13 @@
import { z } from "zod";
import BaseSchema from "../BaseSchema";
export const UserAuthenticationsDeleteSchema = BaseSchema.extend({
body: z.object({
/** Associated provider id of user authentication to be deleted */
authenticationProviderId: z.string().uuid(),
}),
});
export type UserAuthenticationsDeleteReq = z.infer<
typeof UserAuthenticationsDeleteSchema
>;
@@ -0,0 +1,35 @@
import Router from "koa-router";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { UserAuthentication } from "@server/models";
import { APIContext } from "@server/types";
import * as T from "./schema";
const router = new Router();
router.post(
"userAuthentications.delete",
auth(),
validate(T.UserAuthenticationsDeleteSchema),
transaction(),
async (ctx: APIContext<T.UserAuthenticationsDeleteReq>) => {
const { authenticationProviderId } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
await UserAuthentication.destroy({
where: {
userId: user.id,
authenticationProviderId,
},
transaction,
});
ctx.body = {
success: true,
};
}
);
export default router;
+11 -3
View File
@@ -333,6 +333,7 @@
"Outdent": "Outdent",
"Could not import file": "Could not import file",
"Account": "Account",
"Linked Accounts": "Linked Accounts",
"API Tokens": "API Tokens",
"Details": "Details",
"Security": "Security",
@@ -708,6 +709,10 @@
"Copied": "Copied",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
"Disconnecting": "Disconnecting",
"Are you sure you want to disconnect your account from {{title}}?": "Are you sure you want to disconnect your account from {{title}}?",
"You will no longer be able to sign in with this account.": "You will no longer be able to sign in with this account.",
"Associated functionality will be disabled.": "Associated functionality will be disabled.",
"Allowed domains": "Allowed domains",
"The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.": "The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.",
"Remove domain": "Remove domain",
@@ -784,6 +789,12 @@
"Import pages from a Confluence instance": "Import pages from a Confluence instance",
"Enterprise": "Enterprise",
"Recent imports": "Recent imports",
"Manage the third-party services that are connected to {{appName}}.": "Manage the third-party services that are connected to {{appName}}.",
"Connected": "Connected",
"Available": "Available",
"Disconnect account": "Disconnect account",
"Disconnect": "Disconnect",
"Connect": "Connect",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Filter": "Filter",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
@@ -833,7 +844,6 @@
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
"Connected": "Connected",
"Disabled": "Disabled",
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
@@ -877,12 +887,10 @@
"document updated": "document updated",
"Posting to the <em>{{ channelName }}</em> channel on": "Posting to the <em>{{ channelName }}</em> channel on",
"These events should be posted to Slack": "These events should be posted to Slack",
"Disconnect": "Disconnect",
"Whoops, you need to accept the permissions in Slack to connect {{appName}} to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your team. Try again?",
"Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?",
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.",
"Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.",
"Connect": "Connect",
"The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"How to use {{ command }}": "How to use {{ command }}",
"To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.",