From 1a893b0e459414630415003ee7b9d1cf9cc5ae6d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 14 Mar 2026 23:02:20 -0400 Subject: [PATCH] Group sync framework (#11684) Adds group sync from external authentication providers, allowing team group memberships to be automatically managed based on provider data on sign-in in the future. --- .yarnrc.yml | 3 + app/editor/extensions/Keys.ts | 1 - app/hooks/useGroupMenuActions.tsx | 13 +- app/models/AuthenticationProvider.ts | 9 + app/models/Group.ts | 27 ++ app/scenes/Settings/Authentication.tsx | 275 +++++++++++++++--- app/scenes/Settings/GroupMembers.tsx | 81 +++++- app/scenes/Settings/Groups.tsx | 84 ++++-- .../Settings/components/GroupDialogs.tsx | 22 +- .../Settings/components/GroupMembersTable.tsx | 1 + .../components/GroupPermissionFilter.tsx | 47 +++ .../Settings/components/GroupSourceFilter.tsx | 52 ++++ .../Settings/components/GroupsTable.tsx | 43 ++- app/scenes/Shared/index.tsx | 4 +- package.json | 2 +- plugins/google/server/auth/google.ts | 33 ++- plugins/google/server/index.ts | 12 +- plugins/oidc/server/auth/oidcRouter.ts | 4 +- plugins/oidc/server/index.ts | 14 +- server/commands/accountProvisioner.ts | 44 +++ server/commands/groupsSyncer.test.ts | 190 ++++++++++++ server/commands/groupsSyncer.ts | 162 +++++++++++ server/commands/revisionCreator.ts | 2 +- server/env.ts | 6 +- ...dd-settings-to-authentication-providers.js | 15 + .../20260222000001-create-external-groups.js | 79 +++++ server/models/AuthenticationProvider.ts | 17 ++ server/models/ExternalGroup.ts | 72 +++++ server/models/Group.ts | 4 + server/models/index.ts | 2 + server/policies/externalGroup.ts | 6 + server/policies/group.ts | 3 +- server/policies/index.ts | 1 + server/presenters/authenticationProvider.ts | 7 + server/presenters/externalGroup.ts | 22 ++ server/presenters/group.ts | 12 + server/presenters/index.ts | 2 + .../authenticationProviders.ts | 30 +- .../api/authenticationProviders/schema.ts | 15 +- server/routes/api/groups/groups.test.ts | 45 ++- server/routes/api/groups/groups.ts | 167 ++++++++++- server/routes/api/groups/schema.ts | 23 +- server/utils/GroupSyncProvider.ts | 32 ++ server/utils/PluginManager.ts | 16 + server/utils/passport.ts | 11 +- shared/editor/extensions/Mermaid.ts | 5 +- shared/editor/lib/Extension.ts | 1 - shared/editor/lib/ExtensionManager.ts | 6 +- shared/i18n/locales/en_US/translation.json | 22 +- shared/types.ts | 16 + yarn.lock | 10 +- 51 files changed, 1614 insertions(+), 158 deletions(-) create mode 100644 app/scenes/Settings/components/GroupPermissionFilter.tsx create mode 100644 app/scenes/Settings/components/GroupSourceFilter.tsx create mode 100644 server/commands/groupsSyncer.test.ts create mode 100644 server/commands/groupsSyncer.ts create mode 100644 server/migrations/20260222000000-add-settings-to-authentication-providers.js create mode 100644 server/migrations/20260222000001-create-external-groups.js create mode 100644 server/models/ExternalGroup.ts create mode 100644 server/policies/externalGroup.ts create mode 100644 server/presenters/externalGroup.ts create mode 100644 server/utils/GroupSyncProvider.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index 08e6e8fe31..ded0d60241 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,6 @@ nodeLinker: node-modules npmMinimalAgeGate: 86400 + +npmPreapprovedPackages: + - outline-icons diff --git a/app/editor/extensions/Keys.ts b/app/editor/extensions/Keys.ts index 68c9e16566..a0b94bfaea 100644 --- a/app/editor/extensions/Keys.ts +++ b/app/editor/extensions/Keys.ts @@ -15,7 +15,6 @@ export default class Keys extends Extension { return "keys"; } - keys(): Record { const onCancel = () => { if (this.editor.props.onCancel) { diff --git a/app/hooks/useGroupMenuActions.tsx b/app/hooks/useGroupMenuActions.tsx index bce54157f3..19a602735c 100644 --- a/app/hooks/useGroupMenuActions.tsx +++ b/app/hooks/useGroupMenuActions.tsx @@ -88,7 +88,7 @@ export function useGroupMenuActions( name: t("Members"), icon: , section: GroupSection, - visible: !!(targetGroup && can.read), + visible: can.read, perform: navigateToMembers, }), ActionSeparator, @@ -97,14 +97,14 @@ export function useGroupMenuActions( name: `${t("Edit")}…`, icon: , section: GroupSection, - visible: !!(targetGroup && can.update), + visible: can.update, perform: openEditDialog, }), createAction({ name: `${t("Delete")}…`, icon: , section: GroupSection, - visible: !!(targetGroup && can.delete), + visible: can.delete, dangerous: true, perform: openDeleteDialog, }), @@ -116,6 +116,13 @@ export function useGroupMenuActions( disabled: true, url: "", }), + createExternalLinkAction({ + name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`, + section: GroupSection, + visible: !!targetGroup.externalGroup?.externalId, + disabled: true, + url: "", + }), ], [ t, diff --git a/app/models/AuthenticationProvider.ts b/app/models/AuthenticationProvider.ts index f1480304b8..cdc68ec6c5 100644 --- a/app/models/AuthenticationProvider.ts +++ b/app/models/AuthenticationProvider.ts @@ -1,4 +1,5 @@ import { computed, observable } from "mobx"; +import type { AuthenticationProviderSettings } from "@shared/types"; import Model from "./base/Model"; import Field from "./decorators/Field"; import { AfterDelete } from "./decorators/Lifecycle"; @@ -13,6 +14,10 @@ class AuthenticationProvider extends Model { providerId: string; + groupSyncSupported: boolean; + + groupSyncUsesClaim: boolean; + @observable isConnected: boolean; @@ -20,6 +25,10 @@ class AuthenticationProvider extends Model { @observable isEnabled: boolean; + @Field + @observable + settings: AuthenticationProviderSettings | undefined; + @computed get isActive() { return this.isEnabled && this.isConnected; diff --git a/app/models/Group.ts b/app/models/Group.ts index 4c5a8429cd..4573b95a0e 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -5,6 +5,22 @@ import Field from "./decorators/Field"; import { GroupPermission } from "@shared/types"; import type { Searchable } from "./interfaces/Searchable"; +/** + * Information about a group that is managed by an external provider. + */ +interface ExternalGroupInfo { + /** The unique identifier of the external group record in Outline. */ + id: string; + /** The unique identifier of the group in the external provider. */ + externalId: string; + /** The name of the external provider (e.g. google, slack, azure). */ + provider: string; + /** The display name of the group in the external provider. */ + displayName: string; + /** The date and time the group was last synced from the external provider. */ + lastSyncedAt: string | null; +} + class Group extends Model implements Searchable { static modelName = "Group"; @@ -26,6 +42,17 @@ class Group extends Model implements Searchable { @observable disableMentions: boolean; + @observable + externalGroup: ExternalGroupInfo | undefined; + + /** + * Whether this group's membership is managed by an external authentication provider. + */ + @computed + get isExternallyManaged(): boolean { + return !!this.externalGroup; + } + /** * Returns the users that are members of this group. */ diff --git a/app/scenes/Settings/Authentication.tsx b/app/scenes/Settings/Authentication.tsx index 5fc2b606bd..08c6c52c1f 100644 --- a/app/scenes/Settings/Authentication.tsx +++ b/app/scenes/Settings/Authentication.tsx @@ -6,6 +6,8 @@ import { toast } from "sonner"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; +import Input from "~/components/Input"; +import { InputSelect } from "~/components/InputSelect"; import type AuthenticationProvider from "~/models/AuthenticationProvider"; import PluginIcon from "~/components/PluginIcon"; import Scene from "~/components/Scene"; @@ -21,6 +23,7 @@ import { settingsPath } from "~/utils/routeHelpers"; import DomainManagement from "./components/DomainManagement"; import Button from "~/components/Button"; import { ConnectedIcon } from "~/components/Icons/ConnectedIcon"; +import { client } from "~/utils/ApiClient"; import { useTheme } from "styled-components"; import { VStack } from "~/components/primitives/VStack"; @@ -97,6 +100,54 @@ function Authentication() { window.location.href = `/auth/${name}?host=${window.location.host}`; }, []); + const handleToggleGroupSync = React.useCallback( + (provider: AuthenticationProvider, checked: boolean) => { + if (checked) { + void (async () => { + try { + await provider.save({ + settings: { + ...provider.settings, + groupSyncEnabled: true, + }, + }); + toast.success(t("Settings saved")); + } catch (err) { + toast.error(err.message); + } + })(); + } else { + dialogs.openModal({ + title: t("Disable group sync"), + content: ( + + ), + }); + } + }, + [t, dialogs] + ); + + const handleGroupClaimChange = React.useCallback( + async (provider: AuthenticationProvider, groupClaim: string) => { + try { + await provider.save({ + settings: { + ...provider.settings, + groupClaim, + }, + }); + toast.success(t("Settings saved")); + } catch (err) { + toast.error(err.message); + } + }, + [t] + ); + const showSuccessMessage = React.useMemo( () => () => toast.success(t("Settings saved")), [t] @@ -115,58 +166,107 @@ function Authentication() { {t("Sign In")} {authenticationProviders.orderedData.map((provider) => ( - - {provider.displayName} - - } - name={provider.name} - description={ - provider.isConnected - ? t("Allow members to sign-in with {{ authProvider }}", { - authProvider: provider.displayName, - }) - : t("Connect {{ authProvider }} to allow members to sign-in", { - authProvider: provider.displayName, - }) - } - > - - {provider.isConnected ? ( - + + + {provider.displayName} + + } + name={provider.name} + description={ + provider.isConnected + ? t("Allow members to sign-in with {{ authProvider }}", { + authProvider: provider.displayName, + }) + : t("Connect {{ authProvider }} to allow members to sign-in", { + authProvider: provider.displayName, + }) + } + border={!(provider.isActive && provider.groupSyncSupported)} + > + + {provider.isConnected ? ( + + + + {provider.providerId} + + + ) : ( - - {provider.providerId} - - - ) : ( - + ) => { + const value = ev.target.value.trim(); + if (value !== (provider.settings?.groupClaim ?? "")) { + void handleGroupClaimChange(provider, value); + } + }} + /> + )} - - + ))} void; +}) { + const { t } = useTranslation(); + const [action, setAction] = React.useState("keep"); + const [isSaving, setIsSaving] = React.useState(false); + + const options = React.useMemo( + () => [ + { + type: "item" as const, + label: t("Keep synced groups"), + description: t("Groups will remain but no longer update"), + value: "keep", + }, + { + type: "item" as const, + label: t("Delete synced groups"), + description: t("Remove all groups created by sync"), + value: "delete", + }, + ], + [t] + ); + + const handleSubmit = React.useCallback( + async (ev: React.SyntheticEvent) => { + ev.preventDefault(); + setIsSaving(true); + try { + await provider.save({ + settings: { + ...provider.settings, + groupSyncEnabled: false, + }, + }); + + if (action === "delete") { + await client.post("/groups.deleteAll", { + authenticationProviderId: provider.id, + }); + } + + toast.success(t("Settings saved")); + onSubmit(); + } catch (err) { + toast.error(err.message); + } finally { + setIsSaving(false); + } + }, + [provider, action, onSubmit, t] + ); + + return ( +
+ + + {t( + "Group memberships will no longer be synced from {{ authProvider }} when members sign in.", + { authProvider: provider.displayName } + )} + + + + + + +
+ ); +}); + export default observer(Authentication); diff --git a/app/scenes/Settings/GroupMembers.tsx b/app/scenes/Settings/GroupMembers.tsx index 3309377fba..1c6546368e 100644 --- a/app/scenes/Settings/GroupMembers.tsx +++ b/app/scenes/Settings/GroupMembers.tsx @@ -1,10 +1,11 @@ import type { ColumnSort } from "@tanstack/react-table"; import { observer } from "mobx-react"; -import { GroupIcon, PlusIcon } from "outline-icons"; +import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom"; +import styled, { useTheme } from "styled-components"; import { toast } from "sonner"; import type User from "~/models/User"; import { Action } from "~/components/Actions"; @@ -16,6 +17,7 @@ import InputSearch from "~/components/InputSearch"; import LoadingIndicator from "~/components/LoadingIndicator"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; +import Tooltip from "~/components/Tooltip"; import Error404 from "~/scenes/Errors/Error404"; import { createInternalLinkAction } from "~/actions"; import { NavigationSection } from "~/actions/sections"; @@ -28,6 +30,7 @@ import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store"; import { PAGINATION_SYMBOL } from "~/stores/base/Store"; import GroupMenu from "~/menus/GroupMenu"; import { AddPeopleToGroupDialog } from "./components/GroupDialogs"; +import GroupPermissionFilter from "./components/GroupPermissionFilter"; import { GroupMembersTable } from "./components/GroupMembersTable"; import { StickyFilters } from "./components/StickyFilters"; import { settingsPath } from "~/utils/routeHelpers"; @@ -64,6 +67,7 @@ const GroupMembersPage = observer(function GroupMembersPage({ groupId: string; }) { const { t } = useTranslation(); + const theme = useTheme(); const { dialogs, groups, users, groupUsers } = useStores(); const group = groups.get(groupId)!; const can = usePolicy(group); @@ -76,6 +80,7 @@ const GroupMembersPage = observer(function GroupMembersPage({ () => ({ id: group.id, query: params.get("query") || undefined, + permission: params.get("permission") || undefined, sort: params.get("sort") || "name", direction: (params.get("direction") || "asc").toUpperCase() as | "ASC" @@ -102,19 +107,41 @@ const GroupMembersPage = observer(function GroupMembersPage({ [groupUsers] ); + const filteredUsers = useMemo(() => { + let result = users.inGroup(group.id, reqParams.query); + if (reqParams.permission) { + const memberIds = new Set( + groupUsers.orderedData + .filter( + (gu) => + gu.groupId === group.id && gu.permission === reqParams.permission + ) + .map((gu) => gu.userId) + ); + result = result.filter((user) => memberIds.has(user.id)); + } + return result; + }, [ + users, + groupUsers.orderedData, + group.id, + reqParams.query, + reqParams.permission, + ]); + const { data, error, loading, next } = useTableRequest({ - data: users.inGroup(group.id, reqParams.query), + data: filteredUsers, sort, reqFn: fetchMembers, reqParams, }); - const updateQuery = useCallback( - (value: string) => { + const updateParams = useCallback( + (name: string, value: string) => { if (value) { - params.set("query", value); + params.set(name, value); } else { - params.delete("query"); + params.delete(name); } history.replace({ @@ -125,6 +152,17 @@ const GroupMembersPage = observer(function GroupMembersPage({ [params, history, location.pathname] ); + const updateQuery = useCallback( + (value: string) => updateParams("query", value), + [updateParams] + ); + + const handlePermissionFilter = useCallback( + (permission: string | null | undefined) => + updateParams("permission", permission ?? ""), + [updateParams] + ); + const handleSearch = useCallback( (event: React.ChangeEvent) => { const { value } = event.target; @@ -176,6 +214,7 @@ const GroupMembersPage = observer(function GroupMembersPage({