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:
Tom Moor
2026-03-14 23:02:20 -04:00
committed by GitHub
parent 255efe9844
commit 1a893b0e45
51 changed files with 1614 additions and 158 deletions
+3
View File
@@ -1,3 +1,6 @@
nodeLinker: node-modules
npmMinimalAgeGate: 86400
npmPreapprovedPackages:
- outline-icons
-1
View File
@@ -15,7 +15,6 @@ export default class Keys extends Extension {
return "keys";
}
keys(): Record<string, Command> {
const onCancel = () => {
if (this.editor.props.onCancel) {
+10 -3
View File
@@ -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,
+9
View File
@@ -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;
+27
View File
@@ -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.
*/
+229 -46
View File
@@ -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);
+73 -8
View File
@@ -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 && (
<>
&nbsp;
<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 && <> &middot; </>}
</>
)}
{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);
+61 -23
View File
@@ -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);
+40 -3
View File
@@ -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 (
+1 -3
View File
@@ -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
View File
@@ -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",
+25 -8
View File
@@ -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));
}
+7 -5
View File
@@ -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 },
},
]);
}
+2 -2
View File
@@ -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 });
+8 -6
View File
@@ -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");
}
+44
View File
@@ -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,
+190
View File
@@ -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);
});
});
+162
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 });
});
},
};
+17
View File
@@ -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.
*
+72
View File
@@ -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;
+4
View File
@@ -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[];
+2
View File
@@ -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";
+6
View File
@@ -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);
+2 -1
View File
@@ -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
)
);
+1
View File
@@ -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,
};
}
+22
View File
@@ -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,
};
}
+12
View File
@@ -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,
};
+2
View File
@@ -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(),
}),
});
+44 -1
View File
@@ -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", () => {
+158 -9
View File
@@ -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);
+20 -3
View File
@@ -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(),
}),
});
+32
View File
@@ -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[]>;
}
+16
View File
@@ -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)
*/
+10 -1
View File
@@ -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;
+2 -3
View File
@@ -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");
},
},
-1
View File
@@ -56,7 +56,6 @@ export default class Extension {
return false;
}
get focusAfterExecution(): boolean {
return true;
}
+1 -5
View File
@@ -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;
}
+21 -1
View File
@@ -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",
"Youll be able to add people to the group next.": "Youll 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",
+16
View File
@@ -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;
+5 -5
View File
@@ -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"