mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
@@ -15,7 +15,6 @@ export default class Keys extends Extension {
|
||||
return "keys";
|
||||
}
|
||||
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
const onCancel = () => {
|
||||
if (this.editor.props.onCancel) {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function useGroupMenuActions(
|
||||
name: t("Members"),
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
visible: can.read,
|
||||
perform: navigateToMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
@@ -97,14 +97,14 @@ export function useGroupMenuActions(
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
visible: can.update,
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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: (
|
||||
<DisableGroupSyncDialog
|
||||
provider={provider}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[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() {
|
||||
<Heading as="h2">{t("Sign In")}</Heading>
|
||||
|
||||
{authenticationProviders.orderedData.map((provider) => (
|
||||
<SettingRow
|
||||
key={provider.name}
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<React.Fragment key={provider.name}>
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
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)}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<VStack align="start">
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">
|
||||
{provider.providerId}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
{provider.isActive && provider.groupSyncSupported && (
|
||||
<SettingRow
|
||||
label={t("Group sync")}
|
||||
name={`groupSync-${provider.name}`}
|
||||
description={t(
|
||||
"Sync group memberships from {{ authProvider }} on each sign-in",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
border={
|
||||
!(
|
||||
provider.settings?.groupSyncEnabled &&
|
||||
provider.groupSyncUsesClaim
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
id={`groupSync-${provider.name}`}
|
||||
checked={provider.settings?.groupSyncEnabled ?? false}
|
||||
onChange={(checked) => handleToggleGroupSync(provider, checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{provider.isActive &&
|
||||
provider.groupSyncSupported &&
|
||||
provider.groupSyncUsesClaim &&
|
||||
provider.settings?.groupSyncEnabled && (
|
||||
<SettingRow
|
||||
label={t("Group claim")}
|
||||
name={`groupClaim-${provider.name}`}
|
||||
description={t(
|
||||
"The claim in the provider response that contains group names (e.g. groups, roles)"
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
<Input
|
||||
id={`groupClaim-${provider.name}`}
|
||||
defaultValue={provider.settings?.groupClaim ?? "groups"}
|
||||
placeholder="groups"
|
||||
onBlur={(ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
const value = ev.target.value.trim();
|
||||
if (value !== (provider.settings?.groupClaim ?? "")) {
|
||||
void handleGroupClaimChange(provider, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<SettingRow
|
||||
label={
|
||||
@@ -219,4 +319,87 @@ function Authentication() {
|
||||
);
|
||||
}
|
||||
|
||||
const DisableGroupSyncDialog = observer(function DisableGroupSyncDialog({
|
||||
provider,
|
||||
onSubmit,
|
||||
}: {
|
||||
provider: AuthenticationProvider;
|
||||
onSubmit: () => 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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Flex gap={12} column>
|
||||
<Text type="secondary">
|
||||
{t(
|
||||
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
|
||||
{ authProvider: provider.displayName }
|
||||
)}
|
||||
</Text>
|
||||
<InputSelect
|
||||
label={t("Existing groups")}
|
||||
options={options}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<Flex justify="flex-end">
|
||||
<Button type="submit" disabled={isSaving} danger>
|
||||
{isSaving ? `${t("Disabling")}…` : t("Disable")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(Authentication);
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
@@ -176,6 +214,7 @@ const GroupMembersPage = observer(function GroupMembersPage({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
disabled={group.isExternallyManaged}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("Add people")}…`}
|
||||
@@ -189,9 +228,27 @@ const GroupMembersPage = observer(function GroupMembersPage({
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{group.name}</Heading>
|
||||
<Heading>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={32} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
{group.description || t("No description")}
|
||||
{group.externalGroup && (
|
||||
<>
|
||||
{t("Synced to {{ provider }}", {
|
||||
provider: group.externalGroup.displayName,
|
||||
})}
|
||||
{group.description && <> · </>}
|
||||
</>
|
||||
)}
|
||||
{group.description || (!group.externalGroup && t("No description"))}
|
||||
</Text>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
@@ -199,6 +256,10 @@ const GroupMembersPage = observer(function GroupMembersPage({
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupPermissionFilter
|
||||
activeKey={reqParams.permission ?? ""}
|
||||
onSelect={handlePermissionFilter}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupMembersTable
|
||||
@@ -216,4 +277,8 @@ const GroupMembersPage = observer(function GroupMembersPage({
|
||||
);
|
||||
});
|
||||
|
||||
const LargeGroupPermissionFilter = styled(GroupPermissionFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export const GroupMembersScene = observer(GroupMembers);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
@@ -12,6 +13,7 @@ import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import styled from "styled-components";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
@@ -21,18 +23,30 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { CreateGroupDialog } from "./components/GroupDialogs";
|
||||
import GroupSourceFilter from "./components/GroupSourceFilter";
|
||||
import { GroupsTable } from "./components/GroupsTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
function getFilteredGroups(groups: Group[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return groups;
|
||||
function getFilteredGroups(groups: Group[], query?: string, source?: string) {
|
||||
let filtered = groups;
|
||||
|
||||
if (query?.length) {
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
filtered = filtered.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return groups.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
if (source === "manual") {
|
||||
filtered = filtered.filter((group) => !group.externalGroup);
|
||||
} else if (source) {
|
||||
filtered = filtered.filter(
|
||||
(group) => group.externalGroup?.provider === source
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
@@ -48,6 +62,7 @@ function Groups() {
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
source: params.get("source") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
@@ -65,7 +80,11 @@ function Groups() {
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredGroups(groups.orderedData, reqParams.query),
|
||||
data: getFilteredGroups(
|
||||
groups.orderedData,
|
||||
reqParams.query,
|
||||
reqParams.source
|
||||
),
|
||||
sort,
|
||||
reqFn: groups.fetchPage,
|
||||
reqParams,
|
||||
@@ -73,12 +92,12 @@ function Groups() {
|
||||
|
||||
const isEmpty = !loading && !groups.orderedData.length;
|
||||
|
||||
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({
|
||||
@@ -89,10 +108,18 @@ function Groups() {
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
const handleSourceFilter = useCallback(
|
||||
(source: string | null | undefined) => updateParams("source", source ?? ""),
|
||||
[updateParams]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNewGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
@@ -108,9 +135,9 @@ function Groups() {
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -144,11 +171,18 @@ function Groups() {
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<HStack>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<LargeGroupSourceFilter
|
||||
activeKey={reqParams.source ?? ""}
|
||||
onSelect={handleSourceFilter}
|
||||
/>
|
||||
</HStack>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<GroupsTable
|
||||
@@ -167,4 +201,8 @@ function Groups() {
|
||||
);
|
||||
}
|
||||
|
||||
const LargeGroupSourceFilter = styled(GroupSourceFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export default observer(Groups);
|
||||
|
||||
@@ -151,10 +151,17 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
{group.isExternallyManaged ? (
|
||||
<Trans>
|
||||
This group is managed by an external authentication provider. The
|
||||
name is synced automatically and cannot be changed.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so
|
||||
too often might confuse your team mates.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
@@ -162,6 +169,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
disabled={group.isExternallyManaged}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
@@ -177,7 +185,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
/>
|
||||
<Switch
|
||||
id="mentions"
|
||||
label={t("Disable mentions")}
|
||||
label={t("Hidden")}
|
||||
note={t(
|
||||
"Prevent this group from being mentionable in documents or comments"
|
||||
)}
|
||||
@@ -389,7 +397,7 @@ const GroupMemberListItem = observer(function ({
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
)}{" "}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
@@ -427,7 +435,7 @@ const GroupMemberListItem = observer(function ({
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
disabled={!can.update || group.isExternallyManaged}
|
||||
value={groupUser?.permission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,7 @@ export const GroupMembersTable = observer(function GroupMembersTable({
|
||||
component: (user: User) => (
|
||||
<InputMemberPermissionSelect
|
||||
permissions={permissions}
|
||||
disabled={group.isExternallyManaged}
|
||||
onChange={(permission) =>
|
||||
handlePermissionChange(
|
||||
user,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter component for group member permissions.
|
||||
*/
|
||||
const GroupPermissionFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("All permissions"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Admin,
|
||||
label: t("Group admin"),
|
||||
},
|
||||
{
|
||||
key: GroupPermission.Member,
|
||||
label: t("Member"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All permissions")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupPermissionFilter);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
const GroupSourceFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { authenticationProviders } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void authenticationProviders.fetchPage({});
|
||||
}, [authenticationProviders]);
|
||||
|
||||
const syncProviders = useMemo(
|
||||
() =>
|
||||
authenticationProviders.orderedData.filter(
|
||||
(p) => p.settings?.groupSyncEnabled
|
||||
),
|
||||
[authenticationProviders.orderedData]
|
||||
);
|
||||
|
||||
if (!syncProviders.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ key: "", label: t("All sources") },
|
||||
{ key: "manual", label: t("Manual") },
|
||||
...syncProviders.map((p) => ({
|
||||
key: p.name,
|
||||
label: p.displayName,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("All sources")}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupSourceFilter);
|
||||
@@ -1,11 +1,11 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import { GroupIcon, HiddenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type Group from "~/models/Group";
|
||||
@@ -26,6 +26,7 @@ import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
@@ -52,6 +53,7 @@ const GroupRowContextMenu = observer(function GroupRowContextMenu({
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const history = useHistory();
|
||||
|
||||
const handleViewMembers = useCallback(
|
||||
@@ -86,6 +88,14 @@ export function GroupsTable(props: Props) {
|
||||
<Flex column>
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
{group.disableMentions && (
|
||||
<>
|
||||
{" "}
|
||||
<Tooltip content={t("This group is hidden")}>
|
||||
<HiddenIcon size={16} color={theme.textSecondary} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small" weight="normal">
|
||||
<Trans
|
||||
@@ -137,6 +147,33 @@ export function GroupsTable(props: Props) {
|
||||
width: "1.5fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "source",
|
||||
header: t("Source"),
|
||||
accessor: (group) => group.externalGroup?.displayName ?? "manual",
|
||||
component: (group) =>
|
||||
group.externalGroup ? (
|
||||
<Flex column>
|
||||
<Text type="secondary" size="small" weight="normal">
|
||||
{group.externalGroup.displayName}
|
||||
</Text>
|
||||
{group.externalGroup.lastSyncedAt && (
|
||||
<Text type="tertiary" size="xsmall" weight="normal">
|
||||
<Trans>
|
||||
Synced{" "}
|
||||
<Time
|
||||
dateTime={group.externalGroup.lastSyncedAt}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
@@ -155,7 +192,7 @@ export function GroupsTable(props: Props) {
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, handleViewMembers]
|
||||
[t, handleViewMembers, theme.textSecondary]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -161,9 +161,7 @@ function SharedScene() {
|
||||
),
|
||||
useCallback(() => {
|
||||
if (!ui.themeOverride) {
|
||||
ui.setTheme(
|
||||
ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
|
||||
);
|
||||
ui.setTheme(ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light);
|
||||
}
|
||||
}, [ui])
|
||||
);
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^4.2.0",
|
||||
"outline-icons": "^4.3.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TeamDomainRequiredError,
|
||||
} from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { User } from "@server/models";
|
||||
import { AuthenticationProvider, User } from "@server/models";
|
||||
import type { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
@@ -56,7 +56,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
params: { expires_in: number; scope?: string },
|
||||
profile: GoogleProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
@@ -139,7 +139,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,13 +151,30 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
config.id,
|
||||
passport.authenticate(config.id, {
|
||||
router.get(config.id, async (ctx, next) => {
|
||||
const team = await getTeamFromContext(ctx, {
|
||||
includeHostQueryParam: true,
|
||||
});
|
||||
let extraScopes: string[] = [];
|
||||
|
||||
if (team) {
|
||||
const authProvider = await AuthenticationProvider.findOne({
|
||||
where: { name: config.id, teamId: team.id },
|
||||
});
|
||||
|
||||
if (authProvider?.settings?.groupSyncEnabled) {
|
||||
extraScopes = authProvider.settings.groupSyncScopes ?? [
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return passport.authenticate(config.id, {
|
||||
accessType: "offline",
|
||||
prompt: "select_account consent",
|
||||
})
|
||||
);
|
||||
scope: [...scopes, ...extraScopes],
|
||||
})(ctx, next);
|
||||
});
|
||||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import env from "./env";
|
||||
const enabled = !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add({
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
});
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createOIDCRouter(
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string },
|
||||
params: { expires_in: number; id_token: string; scope?: string },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
@@ -216,7 +216,7 @@ export function createOIDCRouter(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
|
||||
@@ -23,11 +23,13 @@ const enabled = hasManualConfig || hasIssuerConfig;
|
||||
|
||||
if (enabled) {
|
||||
// Register plugin with the router (which handles both manual and discovery config)
|
||||
PluginManager.add({
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
name: env.OIDC_DISPLAY_NAME || config.name,
|
||||
});
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
name: env.OIDC_DISPLAY_NAME || config.name,
|
||||
},
|
||||
]);
|
||||
Logger.info("plugins", "OIDC plugin registered");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InvalidAuthenticationError,
|
||||
AuthenticationProviderDisabledError,
|
||||
} from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import type { User } from "@server/models";
|
||||
import {
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { PluginManager } from "@server/utils/PluginManager";
|
||||
import groupsSyncer from "./groupsSyncer";
|
||||
import teamProvisioner from "./teamProvisioner";
|
||||
import userProvisioner from "./userProvisioner";
|
||||
import type { APIContext } from "@server/types";
|
||||
@@ -220,6 +223,47 @@ async function accountProvisioner(
|
||||
}
|
||||
}
|
||||
|
||||
// Sync group memberships from the authentication provider if enabled
|
||||
if (authenticationParams.accessToken) {
|
||||
const settings = authenticationProvider.settings;
|
||||
|
||||
if (settings?.groupSyncEnabled) {
|
||||
const syncProvider = PluginManager.getGroupSyncProvider(
|
||||
authenticationProviderParams.name
|
||||
);
|
||||
|
||||
if (syncProvider) {
|
||||
try {
|
||||
const externalGroups = await syncProvider.fetchUserGroups(
|
||||
authenticationParams.accessToken,
|
||||
settings
|
||||
);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const groupSyncCtx = createContext({
|
||||
user,
|
||||
ip: ctx.context?.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await groupsSyncer(groupSyncCtx, {
|
||||
user,
|
||||
team,
|
||||
authenticationProvider,
|
||||
externalGroups,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Group sync failure should never block login
|
||||
Logger.error("Group sync failed during login", err, {
|
||||
userId: user.id,
|
||||
provider: authenticationProviderParams.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { createContext } from "@server/context";
|
||||
import {
|
||||
AuthenticationProvider,
|
||||
ExternalGroup,
|
||||
Group,
|
||||
GroupUser,
|
||||
} from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import groupsSyncer from "./groupsSyncer";
|
||||
|
||||
describe("groupsSyncer", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create groups and memberships for new external groups", async () => {
|
||||
const user = await buildUser();
|
||||
const team = await user.$get("team")!;
|
||||
const authenticationProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
const result = await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [
|
||||
{ id: "ext-1", name: "Engineering" },
|
||||
{ id: "ext-2", name: "Design" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.groupsCreated).toEqual(2);
|
||||
expect(result.membershipsAdded).toEqual(2);
|
||||
expect(result.membershipsRemoved).toEqual(0);
|
||||
|
||||
const groups = await Group.findAll({ where: { teamId: user.teamId } });
|
||||
expect(groups.map((g) => g.name).sort()).toEqual(["Design", "Engineering"]);
|
||||
});
|
||||
|
||||
it("should update internal group name when external name changes", async () => {
|
||||
const user = await buildUser();
|
||||
const team = await user.$get("team")!;
|
||||
const authenticationProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
// Initial sync creates the group
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [{ id: "ext-1", name: "Engineering" }],
|
||||
})
|
||||
);
|
||||
|
||||
const groupBefore = await Group.findOne({
|
||||
where: { teamId: user.teamId, name: "Engineering" },
|
||||
});
|
||||
expect(groupBefore).not.toBeNull();
|
||||
|
||||
// Second sync with updated name
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [{ id: "ext-1", name: "Platform Engineering" }],
|
||||
})
|
||||
);
|
||||
|
||||
const groupAfter = await Group.findByPk(groupBefore!.id);
|
||||
expect(groupAfter!.name).toEqual("Platform Engineering");
|
||||
|
||||
const externalGroup = await ExternalGroup.findOne({
|
||||
where: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
externalId: "ext-1",
|
||||
},
|
||||
});
|
||||
expect(externalGroup!.name).toEqual("Platform Engineering");
|
||||
});
|
||||
|
||||
it("should remove memberships when user is no longer in external group", async () => {
|
||||
const user = await buildUser();
|
||||
const team = await user.$get("team")!;
|
||||
const authenticationProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
// Initial sync with two groups
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [
|
||||
{ id: "ext-1", name: "Engineering" },
|
||||
{ id: "ext-2", name: "Design" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Second sync with only one group
|
||||
const result = await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [{ id: "ext-1", name: "Engineering" }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.membershipsRemoved).toEqual(1);
|
||||
|
||||
const designGroup = await Group.findOne({
|
||||
where: { teamId: user.teamId, name: "Design" },
|
||||
});
|
||||
const membership = await GroupUser.findOne({
|
||||
where: { groupId: designGroup!.id, userId: user.id },
|
||||
});
|
||||
expect(membership).toBeNull();
|
||||
});
|
||||
|
||||
it("should not create duplicate memberships on re-sync", async () => {
|
||||
const user = await buildUser();
|
||||
const team = await user.$get("team")!;
|
||||
const authenticationProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
const groups = [{ id: "ext-1", name: "Engineering" }];
|
||||
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: groups,
|
||||
})
|
||||
);
|
||||
|
||||
const result = await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: groups,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.groupsCreated).toEqual(0);
|
||||
expect(result.membershipsAdded).toEqual(0);
|
||||
|
||||
const memberships = await GroupUser.findAll({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(memberships).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should remove all memberships when user has no external groups", async () => {
|
||||
const user = await buildUser();
|
||||
const team = await user.$get("team")!;
|
||||
const authenticationProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [{ id: "ext-1", name: "Engineering" }],
|
||||
})
|
||||
);
|
||||
|
||||
const result = await sequelize.transaction(async (transaction) =>
|
||||
groupsSyncer(createContext({ user, transaction, ip }), {
|
||||
user,
|
||||
team: team!,
|
||||
authenticationProvider,
|
||||
externalGroups: [],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.membershipsRemoved).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Op } from "sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type { AuthenticationProvider } from "@server/models";
|
||||
import { ExternalGroup, Group, GroupUser } from "@server/models";
|
||||
import type { User, Team } from "@server/models";
|
||||
import type { APIContext } from "@server/types";
|
||||
import type { ExternalGroupData } from "@server/utils/GroupSyncProvider";
|
||||
|
||||
interface Props {
|
||||
/** The user whose group memberships are being synced. */
|
||||
user: User;
|
||||
/** The team the user belongs to. */
|
||||
team: Team;
|
||||
/** The authentication provider that reported these groups. */
|
||||
authenticationProvider: AuthenticationProvider;
|
||||
/** The groups reported by the external provider for this user. */
|
||||
externalGroups: ExternalGroupData[];
|
||||
}
|
||||
|
||||
interface GroupSyncResult {
|
||||
/** Number of new internal groups created. */
|
||||
groupsCreated: number;
|
||||
/** Number of group memberships added. */
|
||||
membershipsAdded: number;
|
||||
/** Number of group memberships removed. */
|
||||
membershipsRemoved: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes a user's external group memberships with internal Outline
|
||||
* groups. Upserts ExternalGroup records, auto-creates Group records when
|
||||
* needed, and manages GroupUser memberships.
|
||||
*
|
||||
* @param ctx - API context with transaction.
|
||||
* @param props - sync parameters.
|
||||
* @returns result summary.
|
||||
*/
|
||||
async function groupsSyncer(
|
||||
ctx: APIContext,
|
||||
{ user, team, authenticationProvider, externalGroups }: Props
|
||||
): Promise<GroupSyncResult> {
|
||||
const { transaction } = ctx.state;
|
||||
const result: GroupSyncResult = {
|
||||
groupsCreated: 0,
|
||||
membershipsAdded: 0,
|
||||
membershipsRemoved: 0,
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const externalGroupIds = new Set<string>();
|
||||
|
||||
for (const eg of externalGroups) {
|
||||
externalGroupIds.add(eg.id);
|
||||
|
||||
// Upsert ExternalGroup record
|
||||
const [externalGroup, created] = await ExternalGroup.findOrCreate({
|
||||
where: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
externalId: eg.id,
|
||||
},
|
||||
defaults: {
|
||||
name: eg.name,
|
||||
teamId: team.id,
|
||||
lastSyncedAt: now,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
// Update name if changed, and always update lastSyncedAt
|
||||
if (!created) {
|
||||
const updates: Partial<{ name: string; lastSyncedAt: Date }> = {
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
if (externalGroup.name !== eg.name) {
|
||||
updates.name = eg.name;
|
||||
|
||||
// Also update the linked internal Group name
|
||||
if (externalGroup.groupId) {
|
||||
const group = await Group.findByPk(externalGroup.groupId, {
|
||||
transaction,
|
||||
});
|
||||
if (group) {
|
||||
await group.update({ name: eg.name }, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
await externalGroup.update(updates, { transaction });
|
||||
}
|
||||
|
||||
// Auto-create internal Group if one doesn't exist yet
|
||||
if (!externalGroup.groupId) {
|
||||
const group = await Group.createWithCtx(ctx, {
|
||||
name: eg.name,
|
||||
teamId: team.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
await externalGroup.update({ groupId: group.id }, { transaction });
|
||||
externalGroup.groupId = group.id;
|
||||
result.groupsCreated++;
|
||||
}
|
||||
|
||||
// Add user to group if not already a member
|
||||
const [, membershipCreated] = await GroupUser.findOrCreateWithCtx(ctx, {
|
||||
where: { groupId: externalGroup.groupId!, userId: user.id },
|
||||
defaults: { createdById: user.id },
|
||||
});
|
||||
|
||||
if (membershipCreated) {
|
||||
result.membershipsAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from synced groups they are no longer a member of.
|
||||
// Scope query to groups the user is actually a member of to avoid
|
||||
// touching unrelated external group records.
|
||||
const staleWhere: Record<string, unknown> = {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
teamId: team.id,
|
||||
groupId: { [Op.ne]: null },
|
||||
};
|
||||
|
||||
if (externalGroupIds.size > 0) {
|
||||
staleWhere.externalId = { [Op.notIn]: [...externalGroupIds] };
|
||||
}
|
||||
|
||||
const staleExternalGroups = await ExternalGroup.findAll({
|
||||
where: staleWhere,
|
||||
include: [
|
||||
{
|
||||
model: Group,
|
||||
as: "group",
|
||||
required: true,
|
||||
include: [
|
||||
{
|
||||
model: GroupUser,
|
||||
as: "groupUsers",
|
||||
required: true,
|
||||
where: { userId: user.id },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const stale of staleExternalGroups) {
|
||||
const membership = stale.group?.groupUsers?.[0];
|
||||
if (membership) {
|
||||
await membership.destroyWithCtx(ctx);
|
||||
result.membershipsRemoved++;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
"commands",
|
||||
`Group sync completed for user ${user.id}: ${result.groupsCreated} groups created, ${result.membershipsAdded} added, ${result.membershipsRemoved} removed`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default groupsSyncer;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext } from "@server/context";
|
||||
import type { User , Document} from "@server/models";
|
||||
import type { User, Document } from "@server/models";
|
||||
import { Revision } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import type { DocumentEvent, RevisionEvent } from "@server/types";
|
||||
|
||||
+2
-4
@@ -342,14 +342,12 @@ export class Environment {
|
||||
public FORCE_HTTPS = this.toBoolean(environment.FORCE_HTTPS ?? "true");
|
||||
|
||||
/**
|
||||
* When the app is behind a proxy, sets the HTTP header used for the client IP.
|
||||
* When the app is behind a proxy, sets the HTTP header used for the client IP.
|
||||
* The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
* and "X-Client-IP".
|
||||
*/
|
||||
@IsOptional()
|
||||
public PROXY_IP_HEADER = this.toOptionalString(
|
||||
environment.PROXY_IP_HEADER
|
||||
);
|
||||
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
|
||||
|
||||
/**
|
||||
* Should the installation send anonymized statistics to the maintainers.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("authentication_providers", "settings", {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("authentication_providers", "settings");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.createTable(
|
||||
"external_groups",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
externalId: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
groupId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "groups",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "SET NULL",
|
||||
},
|
||||
authenticationProviderId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "authentication_providers",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
teamId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "teams",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
lastSyncedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
"external_groups",
|
||||
["authenticationProviderId", "externalId"],
|
||||
{ unique: true, transaction }
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.dropTable("external_groups", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
PrimaryKey,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import type { AuthenticationProviderSettings } from "@shared/types";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import Model from "@server/models/base/Model";
|
||||
import { ValidationError } from "../errors";
|
||||
import Team from "./Team";
|
||||
@@ -80,6 +82,10 @@ class AuthenticationProvider extends Model<
|
||||
@Column
|
||||
providerId: string;
|
||||
|
||||
/** Provider-specific settings such as group sync configuration. */
|
||||
@Column(DataType.JSONB)
|
||||
settings: AuthenticationProviderSettings | null;
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@@ -97,6 +103,17 @@ class AuthenticationProvider extends Model<
|
||||
|
||||
// instance methods
|
||||
|
||||
/**
|
||||
* The human-readable display name for this provider, resolved from the
|
||||
* plugin registry. Falls back to the raw provider name.
|
||||
*/
|
||||
get displayName(): string {
|
||||
return (
|
||||
AuthenticationHelper.providers.find((p) => p.value.id === this.name)
|
||||
?.name ?? this.name
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OAuthClient for this provider, if possible.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Group from "./Group";
|
||||
import Team from "./Team";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@Table({
|
||||
tableName: "external_groups",
|
||||
modelName: "external_group",
|
||||
})
|
||||
@Fix
|
||||
class ExternalGroup extends IdModel<
|
||||
InferAttributes<ExternalGroup>,
|
||||
Partial<InferCreationAttributes<ExternalGroup>>
|
||||
> {
|
||||
/** The external identifier from the provider (e.g. OIDC group id or name). */
|
||||
@Length({
|
||||
max: 255,
|
||||
msg: "externalId must be 255 characters or less",
|
||||
})
|
||||
@Column
|
||||
externalId: string;
|
||||
|
||||
/** The group name as reported by the external provider. */
|
||||
@Length({
|
||||
max: 255,
|
||||
msg: "name must be 255 characters or less",
|
||||
})
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
/** When this record was last synced from the provider. */
|
||||
@Column(DataType.DATE)
|
||||
lastSyncedAt: Date | null;
|
||||
|
||||
// associations
|
||||
|
||||
/** The linked internal Outline Group, if one has been created. */
|
||||
@BelongsTo(() => Group, "groupId")
|
||||
group: Group | null;
|
||||
|
||||
@ForeignKey(() => Group)
|
||||
@Column(DataType.UUID)
|
||||
groupId: string | null;
|
||||
|
||||
/** The authentication provider this external group came from. */
|
||||
@BelongsTo(() => AuthenticationProvider, "authenticationProviderId")
|
||||
authenticationProvider: AuthenticationProvider;
|
||||
|
||||
@ForeignKey(() => AuthenticationProvider)
|
||||
@Column(DataType.UUID)
|
||||
authenticationProviderId: string;
|
||||
|
||||
/** The team this external group belongs to. */
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export default ExternalGroup;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { GroupValidation } from "@shared/validations";
|
||||
import ExternalGroup from "./ExternalGroup";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Team from "./Team";
|
||||
@@ -92,6 +93,9 @@ class Group extends ParanoidModel<
|
||||
@HasMany(() => GroupUser, "groupId")
|
||||
groupUsers: GroupUser[];
|
||||
|
||||
@HasMany(() => ExternalGroup, "groupId")
|
||||
externalGroups: ExternalGroup[];
|
||||
|
||||
@HasMany(() => GroupMembership, "groupId")
|
||||
groupMemberships: GroupMembership[];
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export { default as Document } from "./Document";
|
||||
|
||||
export { default as Event } from "./Event";
|
||||
|
||||
export { default as ExternalGroup } from "./ExternalGroup";
|
||||
|
||||
export { default as FileOperation } from "./FileOperation";
|
||||
|
||||
export { default as Group } from "./Group";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ExternalGroup } from "@server/models";
|
||||
import User from "@server/models/User";
|
||||
import { allow } from "./cancan";
|
||||
import { isTeamAdmin } from "./utils";
|
||||
|
||||
allow(User, "read", ExternalGroup, isTeamAdmin);
|
||||
@@ -44,6 +44,7 @@ allow(User, "delete", Group, (actor, group) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, group),
|
||||
isTeamMutable(actor)
|
||||
isTeamMutable(actor),
|
||||
!Array.isArray(group?.externalGroups) || group.externalGroups.length === 0
|
||||
)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import "./authenticationProvider";
|
||||
import "./collection";
|
||||
import "./comment";
|
||||
import "./document";
|
||||
import "./externalGroup";
|
||||
import "./fileOperation";
|
||||
import "./import";
|
||||
import "./integration";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { AuthenticationProvider } from "@server/models";
|
||||
|
||||
/**
|
||||
* Presents an AuthenticationProvider model for API responses.
|
||||
*
|
||||
* @param authenticationProvider - the authentication provider to present.
|
||||
* @returns a plain object for serialization.
|
||||
*/
|
||||
export default function presentAuthenticationProvider(
|
||||
authenticationProvider: AuthenticationProvider
|
||||
) {
|
||||
@@ -10,5 +16,6 @@ export default function presentAuthenticationProvider(
|
||||
createdAt: authenticationProvider.createdAt,
|
||||
isEnabled: authenticationProvider.enabled,
|
||||
isConnected: true,
|
||||
settings: authenticationProvider.settings ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type ExternalGroup from "@server/models/ExternalGroup";
|
||||
|
||||
/**
|
||||
* Presents an ExternalGroup model for API responses.
|
||||
*
|
||||
* @param externalGroup - the external group to present.
|
||||
* @returns a plain object for serialization.
|
||||
*/
|
||||
export default function presentExternalGroup(externalGroup: ExternalGroup) {
|
||||
return {
|
||||
id: externalGroup.id,
|
||||
externalId: externalGroup.externalId,
|
||||
name: externalGroup.name,
|
||||
groupId: externalGroup.groupId,
|
||||
provider: externalGroup.authenticationProvider?.name,
|
||||
displayName: externalGroup.authenticationProvider?.displayName,
|
||||
authenticationProviderId: externalGroup.authenticationProviderId,
|
||||
lastSyncedAt: externalGroup.lastSyncedAt,
|
||||
createdAt: externalGroup.createdAt,
|
||||
updatedAt: externalGroup.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import type Group from "@server/models/Group";
|
||||
import presentExternalGroup from "./externalGroup";
|
||||
|
||||
/**
|
||||
* Presents a group for the API response.
|
||||
*
|
||||
* @param group - the group to present.
|
||||
* @returns the presented group object.
|
||||
*/
|
||||
export default async function presentGroup(group: Group) {
|
||||
const externalGroup = group.externalGroups?.[0];
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
@@ -8,6 +17,9 @@ export default async function presentGroup(group: Group) {
|
||||
externalId: group.externalId,
|
||||
memberCount: await group.memberCount,
|
||||
disableMentions: group.disableMentions,
|
||||
externalGroup: externalGroup
|
||||
? presentExternalGroup(externalGroup)
|
||||
: undefined,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import presentCollection from "./collection";
|
||||
import presentComment from "./comment";
|
||||
import presentDocument, { presentDocuments } from "./document";
|
||||
import presentEvent from "./event";
|
||||
import presentExternalGroup from "./externalGroup";
|
||||
import presentFileOperation from "./fileOperation";
|
||||
import presentGroup from "./group";
|
||||
import presentGroupMembership from "./groupMembership";
|
||||
@@ -41,6 +42,7 @@ export {
|
||||
presentDocument,
|
||||
presentDocuments,
|
||||
presentEvent,
|
||||
presentExternalGroup,
|
||||
presentFileOperation,
|
||||
presentGroup,
|
||||
presentGroupUser,
|
||||
|
||||
@@ -6,6 +6,7 @@ import validate from "@server/middlewares/validate";
|
||||
import { AuthenticationProvider } from "@server/models";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { PluginManager } from "@server/utils/PluginManager";
|
||||
import {
|
||||
presentAuthenticationProvider,
|
||||
presentPolicies,
|
||||
@@ -40,7 +41,7 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.AuthenticationProvidersUpdateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id, isEnabled } = ctx.input.body;
|
||||
const { id, isEnabled, settings } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id, {
|
||||
@@ -49,12 +50,24 @@ router.post(
|
||||
});
|
||||
|
||||
authorize(user, "update", authenticationProvider);
|
||||
const enabled = !!isEnabled;
|
||||
|
||||
if (enabled) {
|
||||
await authenticationProvider.enable(ctx);
|
||||
} else {
|
||||
await authenticationProvider.disable(ctx);
|
||||
if (isEnabled !== undefined) {
|
||||
const enabled = !!isEnabled;
|
||||
|
||||
if (enabled) {
|
||||
await authenticationProvider.enable(ctx);
|
||||
} else {
|
||||
await authenticationProvider.disable(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings !== undefined) {
|
||||
await authenticationProvider.updateWithCtx(ctx, {
|
||||
settings: {
|
||||
...(authenticationProvider.settings ?? {}),
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -110,6 +123,9 @@ router.post(
|
||||
const row = teamAuthenticationProviders.find(
|
||||
(t) => t.name === p.value.id
|
||||
);
|
||||
const groupSyncProvider = PluginManager.getGroupSyncProvider(
|
||||
p.value.id
|
||||
);
|
||||
|
||||
return {
|
||||
id: p.value.id,
|
||||
@@ -117,6 +133,8 @@ router.post(
|
||||
displayName: p.name,
|
||||
isEnabled: false,
|
||||
isConnected: false,
|
||||
groupSyncSupported: !!groupSyncProvider,
|
||||
groupSyncUsesClaim: groupSyncProvider?.useGroupClaim ?? false,
|
||||
...(row ? presentAuthenticationProvider(row) : {}),
|
||||
};
|
||||
})
|
||||
|
||||
@@ -12,13 +12,26 @@ export type AuthenticationProvidersInfoReq = z.infer<
|
||||
typeof AuthenticationProvidersInfoSchema
|
||||
>;
|
||||
|
||||
/** Schema for group sync settings on an authentication provider. */
|
||||
const AuthenticationProviderSettingsSchema = z.object({
|
||||
/** Whether group sync from this provider is enabled. */
|
||||
groupSyncEnabled: z.boolean().optional(),
|
||||
/** The claim path in the provider response that contains group data. */
|
||||
groupClaim: z.string().max(255).optional(),
|
||||
/** Additional scopes to request when group sync is enabled. */
|
||||
groupSyncScopes: z.array(z.string().max(255)).max(20).optional(),
|
||||
});
|
||||
|
||||
export const AuthenticationProvidersUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Authentication Provider Id */
|
||||
id: z.uuid(),
|
||||
|
||||
/** Whether the Authentication Provider is enabled or not */
|
||||
isEnabled: z.boolean(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
|
||||
/** Provider-specific settings such as group sync configuration */
|
||||
settings: AuthenticationProviderSettingsSchema.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Group, User } from "@server/models";
|
||||
import { Event } from "@server/models";
|
||||
import { AuthenticationProvider, Event, ExternalGroup } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
buildAdmin,
|
||||
@@ -378,6 +378,49 @@ describe("#groups.list", () => {
|
||||
expect(body.pagination.total).toEqual(2);
|
||||
expect(body.data.groups.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not return groups from other teams when filtering by source", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
const authProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: user.teamId },
|
||||
}))!;
|
||||
|
||||
await ExternalGroup.create({
|
||||
externalId: "ext-1",
|
||||
name: "Synced Group",
|
||||
groupId: group.id,
|
||||
authenticationProviderId: authProvider.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
// Create a group on a different team with an external group mapping
|
||||
const otherUser = await buildUser();
|
||||
const otherGroup = await buildGroup({ teamId: otherUser.teamId });
|
||||
const otherAuthProvider = (await AuthenticationProvider.findOne({
|
||||
where: { teamId: otherUser.teamId },
|
||||
}))!;
|
||||
|
||||
await ExternalGroup.create({
|
||||
externalId: "ext-2",
|
||||
name: "Other Team Group",
|
||||
groupId: otherGroup.id,
|
||||
authenticationProviderId: otherAuthProvider.id,
|
||||
teamId: otherUser.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: {
|
||||
source: authProvider.name,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.groups.length).toEqual(1);
|
||||
expect(body.data.groups[0].id).toEqual(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.info", () => {
|
||||
|
||||
@@ -2,13 +2,20 @@ import Router from "koa-router";
|
||||
import type { WhereOptions } from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { GroupPermission, UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { User, Group, GroupUser } from "@server/models";
|
||||
import {
|
||||
User,
|
||||
Group,
|
||||
GroupUser,
|
||||
ExternalGroup,
|
||||
AuthenticationProvider,
|
||||
} from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import {
|
||||
presentGroup,
|
||||
presentGroupUser,
|
||||
@@ -22,13 +29,28 @@ import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
/** Standard include for loading ExternalGroup with its AuthenticationProvider. */
|
||||
const externalGroupInclude = {
|
||||
model: ExternalGroup,
|
||||
as: "externalGroups",
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: AuthenticationProvider,
|
||||
as: "authenticationProvider",
|
||||
attributes: ["id", "name", "providerId"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
router.post(
|
||||
"groups.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.GroupsListSchema),
|
||||
async (ctx: APIContext<T.GroupsListReq>) => {
|
||||
const { sort, direction, query, userId, externalId, name } = ctx.input.body;
|
||||
const { sort, direction, query, userId, externalId, name, source } =
|
||||
ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "listGroups", user.team);
|
||||
|
||||
@@ -74,6 +96,38 @@ router.post(
|
||||
};
|
||||
}
|
||||
|
||||
if (source) {
|
||||
const externalGroupWhere: WhereOptions<ExternalGroup> = {
|
||||
teamId: user.teamId,
|
||||
groupId: { [Op.ne]: null },
|
||||
};
|
||||
|
||||
const sourceGroupIds = await ExternalGroup.findAll({
|
||||
attributes: ["groupId"],
|
||||
where: externalGroupWhere,
|
||||
...(source !== "manual" && {
|
||||
include: [
|
||||
{
|
||||
model: AuthenticationProvider,
|
||||
as: "authenticationProvider",
|
||||
attributes: [],
|
||||
where: { name: source },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).then((egs) =>
|
||||
egs.map((eg) => eg.groupId).filter((id): id is string => id !== null)
|
||||
);
|
||||
|
||||
where = {
|
||||
...where,
|
||||
id: {
|
||||
...((where.id as object) ?? {}),
|
||||
[source === "manual" ? Op.notIn : Op.in]: sourceGroupIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [groups, total] = await Promise.all([
|
||||
Group.findAll({
|
||||
where,
|
||||
@@ -86,8 +140,18 @@ router.post(
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
],
|
||||
order: [
|
||||
sort === "source"
|
||||
? [
|
||||
{ model: ExternalGroup, as: "externalGroups" },
|
||||
{ model: AuthenticationProvider, as: "authenticationProvider" },
|
||||
"name",
|
||||
direction,
|
||||
]
|
||||
: [sort, direction],
|
||||
],
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
@@ -142,6 +206,7 @@ router.post(
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
];
|
||||
|
||||
const group = id
|
||||
@@ -182,6 +247,7 @@ router.post(
|
||||
});
|
||||
|
||||
group.groupUsers = [];
|
||||
group.externalGroups = [];
|
||||
|
||||
ctx.body = {
|
||||
data: await presentGroup(group),
|
||||
@@ -211,6 +277,7 @@ router.post(
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
],
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
@@ -219,6 +286,16 @@ router.post(
|
||||
});
|
||||
authorize(user, "update", group);
|
||||
|
||||
if (
|
||||
group.externalGroups?.length &&
|
||||
ctx.input.body.name !== undefined &&
|
||||
ctx.input.body.name !== group.name
|
||||
) {
|
||||
throw ValidationError(
|
||||
"The name of a group synced from an external provider cannot be changed"
|
||||
);
|
||||
}
|
||||
|
||||
await group.updateWithCtx(ctx, ctx.input.body);
|
||||
|
||||
ctx.body = {
|
||||
@@ -240,7 +317,11 @@ router.post(
|
||||
|
||||
const group = await Group.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
include: [externalGroupInclude],
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: Group,
|
||||
},
|
||||
});
|
||||
authorize(user, "delete", group);
|
||||
|
||||
@@ -252,13 +333,54 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"groups.deleteAll",
|
||||
auth({ role: UserRole.Admin }),
|
||||
validate(T.GroupsDeleteAllSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GroupsDeleteAllReq>) => {
|
||||
const { authenticationProviderId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(
|
||||
authenticationProviderId,
|
||||
{ transaction }
|
||||
);
|
||||
authorize(user, "update", authenticationProvider);
|
||||
|
||||
const groupIds = await ExternalGroup.findAll({
|
||||
attributes: ["groupId"],
|
||||
where: {
|
||||
authenticationProviderId,
|
||||
teamId: user.teamId,
|
||||
groupId: { [Op.ne]: null },
|
||||
},
|
||||
transaction,
|
||||
}).then((egs) =>
|
||||
egs.map((eg) => eg.groupId).filter((id): id is string => id !== null)
|
||||
);
|
||||
|
||||
if (groupIds.length) {
|
||||
await Group.destroy({
|
||||
where: { id: groupIds },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"groups.memberships",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.GroupsMembershipsSchema),
|
||||
async (ctx: APIContext<T.GroupsMembershipsReq>) => {
|
||||
const { id, query } = ctx.input.body;
|
||||
const { id, query, permission } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const group = await Group.findByPk(id);
|
||||
@@ -273,10 +395,16 @@ router.post(
|
||||
};
|
||||
}
|
||||
|
||||
const groupUserWhere: Record<string, unknown> = {
|
||||
groupId: id,
|
||||
};
|
||||
|
||||
if (permission) {
|
||||
groupUserWhere.permission = permission;
|
||||
}
|
||||
|
||||
const options = {
|
||||
where: {
|
||||
groupId: id,
|
||||
},
|
||||
where: groupUserWhere,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
@@ -334,10 +462,17 @@ router.post(
|
||||
userId: actor.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
],
|
||||
});
|
||||
authorize(actor, "update", group);
|
||||
|
||||
if (group.externalGroups?.length) {
|
||||
throw ValidationError(
|
||||
"This group is managed by an external provider and its membership cannot be modified"
|
||||
);
|
||||
}
|
||||
|
||||
const userPermission = permission;
|
||||
|
||||
const [groupUser] = await GroupUser.findOrCreateWithCtx(
|
||||
@@ -396,10 +531,17 @@ router.post(
|
||||
userId: actor.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
],
|
||||
});
|
||||
authorize(actor, "update", group);
|
||||
|
||||
if (group.externalGroups?.length) {
|
||||
throw ValidationError(
|
||||
"This group is managed by an external provider and its membership cannot be modified"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, { transaction });
|
||||
authorize(actor, "read", user);
|
||||
|
||||
@@ -444,10 +586,17 @@ router.post(
|
||||
userId: actor.id,
|
||||
},
|
||||
},
|
||||
externalGroupInclude,
|
||||
],
|
||||
});
|
||||
authorize(actor, "update", group);
|
||||
|
||||
if (group.externalGroups?.length) {
|
||||
throw ValidationError(
|
||||
"This group is managed by an external provider and its membership cannot be modified"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, { transaction });
|
||||
authorize(actor, "read", user);
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ export const GroupsListSchema = z.object({
|
||||
/** Groups sorting column */
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) => Object.keys(Group.getAttributes()).includes(val), {
|
||||
error: "Invalid sort parameter",
|
||||
})
|
||||
.refine(
|
||||
(val) =>
|
||||
Object.keys(Group.getAttributes()).includes(val) || val === "source",
|
||||
{
|
||||
error: "Invalid sort parameter",
|
||||
}
|
||||
)
|
||||
.prefault("updatedAt"),
|
||||
/** Only list groups where this user is a member */
|
||||
userId: z.uuid().optional(),
|
||||
@@ -28,6 +32,8 @@ export const GroupsListSchema = z.object({
|
||||
externalId: z.string().optional(),
|
||||
/** @deprecated Find group with matching name */
|
||||
name: z.string().optional(),
|
||||
/** Filter groups by source: "manual" for non-synced, or a provider name */
|
||||
source: z.string().optional(),
|
||||
/** Find group matching query */
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
@@ -88,10 +94,21 @@ export const GroupsDeleteSchema = z.object({
|
||||
|
||||
export type GroupsDeleteReq = z.infer<typeof GroupsDeleteSchema>;
|
||||
|
||||
export const GroupsDeleteAllSchema = z.object({
|
||||
body: z.object({
|
||||
/** The authentication provider whose synced groups should be deleted. */
|
||||
authenticationProviderId: z.uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type GroupsDeleteAllReq = z.infer<typeof GroupsDeleteAllSchema>;
|
||||
|
||||
export const GroupsMembershipsSchema = z.object({
|
||||
body: BaseIdSchema.extend({
|
||||
/** Group name search query */
|
||||
query: z.string().optional(),
|
||||
/** Filter by group permission */
|
||||
permission: z.nativeEnum(GroupPermission).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AuthenticationProviderSettings } from "@shared/types";
|
||||
|
||||
/**
|
||||
* Represents a group reported by an external authentication provider.
|
||||
*/
|
||||
export interface ExternalGroupData {
|
||||
/** Unique identifier for the group in the provider's system. */
|
||||
id: string;
|
||||
/** Display name of the group. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface that authentication provider plugins implement to support
|
||||
* group synchronization.
|
||||
*/
|
||||
export interface GroupSyncProvider {
|
||||
/** Whether this provider requires a configurable group claim path. */
|
||||
useGroupClaim: boolean;
|
||||
|
||||
/**
|
||||
* Fetch the groups that a user belongs to from the external provider.
|
||||
*
|
||||
* @param accessToken - the user's OAuth access token.
|
||||
* @param settings - provider-specific settings from AuthenticationProvider.settings.
|
||||
* @returns array of external groups the user is a member of.
|
||||
*/
|
||||
fetchUserGroups(
|
||||
accessToken: string,
|
||||
settings: AuthenticationProviderSettings
|
||||
): Promise<ExternalGroupData[]>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type { BaseTask } from "@server/queues/tasks/base/BaseTask";
|
||||
import type { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
import type { BaseIssueProvider } from "./BaseIssueProvider";
|
||||
import type { GroupSyncProvider } from "./GroupSyncProvider";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
@@ -31,6 +32,7 @@ export enum Hook {
|
||||
Task = "task",
|
||||
UnfurlProvider = "unfurl",
|
||||
Uninstall = "uninstall",
|
||||
GroupSyncProvider = "groupSyncProvider",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,7 @@ type PluginValueMap = {
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
[Hook.Uninstall]: UninstallSignature;
|
||||
[Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number };
|
||||
[Hook.GroupSyncProvider]: { id: string; provider: GroupSyncProvider };
|
||||
};
|
||||
|
||||
export type Plugin<T extends Hook> = {
|
||||
@@ -112,6 +115,19 @@ export class PluginManager {
|
||||
return sortBy(this.plugins.get(type) || [], "priority") as Plugin<T>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the GroupSyncProvider for the given authentication provider name.
|
||||
*
|
||||
* @param name - the authentication provider name (e.g. "oidc", "google").
|
||||
* @returns the GroupSyncProvider if one is registered, undefined otherwise.
|
||||
*/
|
||||
public static getGroupSyncProvider(
|
||||
name: string
|
||||
): GroupSyncProvider | undefined {
|
||||
const hooks = this.getHooks(Hook.GroupSyncProvider);
|
||||
return hooks.find((h) => h.value.id === name)?.value.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin server components (anything in the `/server/` directory of a plugin will be loaded)
|
||||
*/
|
||||
|
||||
@@ -207,6 +207,11 @@ type TeamFromContextOptions = {
|
||||
* this should only be used in the authentication process.
|
||||
*/
|
||||
includeStateCookie?: boolean;
|
||||
/**
|
||||
* Whether to consider the host query parameter in the context when determining the team.
|
||||
* If true, the host query parameter will be used to determine the host and infer the team
|
||||
*/
|
||||
includeHostQueryParam?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -225,7 +230,11 @@ export async function getTeamFromContext(
|
||||
const state = options.includeStateCookie
|
||||
? ctx.cookies.get("state")
|
||||
: undefined;
|
||||
const host = state ? parseState(state).host : ctx.hostname;
|
||||
const queryHost =
|
||||
options.includeHostQueryParam && typeof ctx.query.host === "string"
|
||||
? ctx.query.host
|
||||
: undefined;
|
||||
const host = (state ? parseState(state).host : queryHost) || ctx.hostname;
|
||||
const domain = parseDomain(host);
|
||||
|
||||
let team;
|
||||
|
||||
@@ -146,9 +146,8 @@ class MermaidRenderer {
|
||||
{
|
||||
name: "fa-brands",
|
||||
loader: async () => {
|
||||
const { fab } = await import(
|
||||
"@fortawesome/free-brands-svg-icons"
|
||||
);
|
||||
const { fab } =
|
||||
await import("@fortawesome/free-brands-svg-icons");
|
||||
return fontAwesomeToIconify(fab, "fa-brands");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -56,7 +56,6 @@ export default class Extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
get focusAfterExecution(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,7 @@ export default class ExtensionManager {
|
||||
extension = new ext(editor?.props);
|
||||
} else {
|
||||
// For already-instantiated extensions, check the instance.
|
||||
if (
|
||||
this.readOnly &&
|
||||
ext.type === "extension" &&
|
||||
!ext.allowInReadOnly
|
||||
) {
|
||||
if (this.readOnly && ext.type === "extension" && !ext.allowInReadOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1080,17 +1080,30 @@
|
||||
"Are you sure?": "Are you sure?",
|
||||
"Removing": "Removing",
|
||||
"Removing this authentication provider will prevent members from signing in with {{ authProvider }}.": "Removing this authentication provider will prevent members from signing in with {{ authProvider }}.",
|
||||
"Disable group sync": "Disable group sync",
|
||||
"Manage how members sign-in to your workspace and which authentication providers are enabled.": "Manage how members sign-in to your workspace and which authentication providers are enabled.",
|
||||
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
|
||||
"Connect {{ authProvider }} to allow members to sign-in": "Connect {{ authProvider }} to allow members to sign-in",
|
||||
"Connected": "Connected",
|
||||
"Disabled": "Disabled",
|
||||
"Connect": "Connect",
|
||||
"Group sync": "Group sync",
|
||||
"Sync group memberships from {{ authProvider }} on each sign-in": "Sync group memberships from {{ authProvider }} on each sign-in",
|
||||
"Group claim": "Group claim",
|
||||
"The claim in the provider response that contains group names (e.g. groups, roles)": "The claim in the provider response that contains group names (e.g. groups, roles)",
|
||||
"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",
|
||||
"Passkeys": "Passkeys",
|
||||
"Allow members to sign-in with a WebAuthn passkey": "Allow members to sign-in with a WebAuthn passkey",
|
||||
"Restrictions": "Restrictions",
|
||||
"Keep synced groups": "Keep synced groups",
|
||||
"Groups will remain but no longer update": "Groups will remain but no longer update",
|
||||
"Delete synced groups": "Delete synced groups",
|
||||
"Remove all groups created by sync": "Remove all groups created by sync",
|
||||
"Group memberships will no longer be synced from {{ authProvider }} when members sign in.": "Group memberships will no longer be synced from {{ authProvider }} when members sign in.",
|
||||
"Existing groups": "Existing groups",
|
||||
"Disabling": "Disabling",
|
||||
"Disable": "Disable",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Expired": "Expired",
|
||||
"Last used": "Last used",
|
||||
@@ -1131,9 +1144,10 @@
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"Optional": "Optional",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"This group is managed by an external authentication provider. The name is synced automatically and cannot be changed.": "This group is managed by an external authentication provider. The name is synced automatically and cannot be changed.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"Description": "Description",
|
||||
"Disable mentions": "Disable mentions",
|
||||
"Hidden": "Hidden",
|
||||
"Prevent this group from being mentionable in documents or comments": "Prevent this group from being mentionable in documents or comments",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||
@@ -1149,6 +1163,11 @@
|
||||
"Member": "Member",
|
||||
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
||||
"Last active": "Last active",
|
||||
"All permissions": "All permissions",
|
||||
"All sources": "All sources",
|
||||
"Manual": "Manual",
|
||||
"This group is hidden": "This group is hidden",
|
||||
"Synced <2></2>": "Synced <2></2>",
|
||||
"Date created": "Date created",
|
||||
"Crop Image": "Crop Image",
|
||||
"Crop image": "Crop image",
|
||||
@@ -1231,6 +1250,7 @@
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Could not load group members": "Could not load group members",
|
||||
"Add people": "Add people",
|
||||
"Synced to {{ provider }}": "Synced to {{ provider }}",
|
||||
"No description": "No description",
|
||||
"Create a group": "Create a group",
|
||||
"Could not load groups": "Could not load groups",
|
||||
|
||||
@@ -204,6 +204,22 @@ export enum GroupPermission {
|
||||
Admin = "admin",
|
||||
}
|
||||
|
||||
/** Settings stored on an AuthenticationProvider for group synchronization. */
|
||||
export interface AuthenticationProviderSettings {
|
||||
/** Whether group sync from this provider is enabled. */
|
||||
groupSyncEnabled?: boolean;
|
||||
/**
|
||||
* The claim path in the OIDC userinfo/id_token response that contains
|
||||
* group data (e.g. "groups", "roles", "custom.groups").
|
||||
*/
|
||||
groupClaim?: string;
|
||||
/**
|
||||
* Additional scopes to request when group sync is enabled
|
||||
* (e.g. "groups" for OIDC).
|
||||
*/
|
||||
groupSyncScopes?: string[];
|
||||
}
|
||||
|
||||
export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
? {
|
||||
url?: string;
|
||||
|
||||
@@ -17580,12 +17580,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"outline-icons@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "outline-icons@npm:4.2.0"
|
||||
"outline-icons@npm:^4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "outline-icons@npm:4.3.0"
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/e607c1542e99aa675c1268763e6a442fc74f2aefacb81aa6a3a318fa853c76c062e288a9e03d963a1c2b90c2e06a40028b4c6c1ae319d0d1fe93cf8d7feb25a1
|
||||
checksum: 10c0/e36330852526ac15973480aa4ce4bf82ee21bf54dbeb6b6a9b02bc5e03701a19e3f796fb38a077ad7cef7e3383ff020d4c6246013b656be80c8c97243f522fbb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17811,7 +17811,7 @@ __metadata:
|
||||
nodemailer: "npm:^7.0.11"
|
||||
nodemon: "npm:^3.1.11"
|
||||
octokit: "npm:^3.2.2"
|
||||
outline-icons: "npm:^4.2.0"
|
||||
outline-icons: "npm:^4.3.0"
|
||||
oxlint: "npm:1.11.2"
|
||||
oxlint-tsgolint: "npm:^0.1.6"
|
||||
oy-vey: "npm:^0.12.1"
|
||||
|
||||
Reference in New Issue
Block a user