From 641ce142dfe229865b1efa130116e851c38c1eb6 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 1 May 2026 14:53:30 +0100 Subject: [PATCH] feat(ee): SCIM (#1347) * SCIM - init (EE) * accept db transaction * sync * Content parser support for scim+json * patch scimmy * sync * return early if userIds is empty * sync * SCIM db table * fixes * scim tokens * backfill * feat(audit): add scim token events * rename scim migration * fix * fix translation * cleanup --- .../public/locales/en-US/translation.json | 34 ++++- .../layouts/global/global-app-shell.tsx | 4 +- .../components/settings/settings-queries.tsx | 8 + .../components/settings/settings-sidebar.tsx | 6 +- .../components/api-key-created-modal.tsx | 7 +- .../components/create-api-key-modal.tsx | 2 +- .../components/revoke-api-key-modal.tsx | 6 +- .../components/update-api-key-modal.tsx | 2 +- .../src/ee/api-key/queries/api-key-query.ts | 6 +- .../src/ee/audit/lib/audit-event-labels.ts | 12 ++ .../components/create-scim-token-modal.tsx | 78 ++++++++++ .../src/ee/scim/components/enable-scim.tsx | 55 +++++++ .../components/revoke-scim-token-modal.tsx | 61 ++++++++ .../components/scim-token-created-modal.tsx | 69 +++++++++ .../ee/scim/components/scim-token-table.tsx | 130 ++++++++++++++++ .../src/ee/scim/components/scim-url-panel.tsx | 30 ++++ .../components/update-scim-token-modal.tsx | 77 ++++++++++ apps/client/src/ee/scim/index.ts | 2 + .../src/ee/scim/queries/scim-token-query.ts | 96 ++++++++++++ .../ee/scim/services/scim-token-service.ts | 34 +++++ .../src/ee/scim/types/scim-token.types.ts | 27 ++++ .../security/components/sso-provider-list.tsx | 4 +- .../client/src/ee/security/pages/security.tsx | 143 +++++++++++++++++- .../workspace/types/workspace.types.ts | 1 + apps/server/package.json | 1 + apps/server/src/common/events/audit-events.ts | 6 + apps/server/src/common/helpers/utils.ts | 2 +- .../core/group/services/group-user.service.ts | 15 +- .../src/core/group/services/group.service.ts | 5 +- .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 12 +- .../migrations/20260501T092214-scim.ts | 110 ++++++++++++++ .../src/database/repos/group/group.repo.ts | 44 ++++-- .../src/database/repos/user/user.repo.ts | 4 + .../repos/workspace/workspace.repo.ts | 1 + apps/server/src/database/types/db.d.ts | 19 +++ .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- apps/server/src/main.ts | 16 ++ package.json | 3 +- patches/@tiptap__core.patch | 105 ------------- patches/scimmy@1.3.5.patch | 23 +++ pnpm-lock.yaml | 12 ++ 43 files changed, 1136 insertions(+), 148 deletions(-) create mode 100644 apps/client/src/ee/scim/components/create-scim-token-modal.tsx create mode 100644 apps/client/src/ee/scim/components/enable-scim.tsx create mode 100644 apps/client/src/ee/scim/components/revoke-scim-token-modal.tsx create mode 100644 apps/client/src/ee/scim/components/scim-token-created-modal.tsx create mode 100644 apps/client/src/ee/scim/components/scim-token-table.tsx create mode 100644 apps/client/src/ee/scim/components/scim-url-panel.tsx create mode 100644 apps/client/src/ee/scim/components/update-scim-token-modal.tsx create mode 100644 apps/client/src/ee/scim/index.ts create mode 100644 apps/client/src/ee/scim/queries/scim-token-query.ts create mode 100644 apps/client/src/ee/scim/services/scim-token-service.ts create mode 100644 apps/client/src/ee/scim/types/scim-token.types.ts create mode 100644 apps/server/src/database/migrations/20260501T092214-scim.ts delete mode 100644 patches/@tiptap__core.patch create mode 100644 patches/scimmy@1.3.5.patch diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3bfe7c9f..56709bbe 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -608,25 +608,21 @@ "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image removed successfully": "Image removed successfully", "API key": "API key", - "API key created successfully": "API key created successfully", "API keys": "API keys", "API management": "API management", - "Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key", - "Create API Key": "Create API Key", "Custom expiration date": "Custom expiration date", "Enter a descriptive token name": "Enter a descriptive token name", "Expiration": "Expiration", "Expired": "Expired", "Expires": "Expires", - "I've saved my API key": "I've saved my API key", "Last use": "Last Used", "No API keys found": "No API keys found", "No expiration": "No expiration", - "Revoke API key": "Revoke API key", "Revoked successfully": "Revoked successfully", "Select expiration date": "Select expiration date", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", - "Update API key": "Update API key", + "Update": "Update", + "Update {{credential}}": "Update {{credential}}", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Restrict API key creation to admins": "Restrict API key creation to admins", "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.", @@ -880,5 +876,29 @@ "Try a different search term.": "Try a different search term.", "Try again": "Try again", "Untitled chat": "Untitled chat", - "What can I help you with?": "What can I help you with?" + "What can I help you with?": "What can I help you with?", + "Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}", + "Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.", + "Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.", + "Create {{credential}}": "Create {{credential}}", + "{{credential}} created": "{{credential}} created", + "{{credential}} created successfully": "{{credential}} created successfully", + "Created by": "Created by", + "Custom": "Custom", + "Enable SCIM": "Enable SCIM", + "Enter a descriptive name": "Enter a descriptive name", + "I've saved my {{credential}}": "I've saved my {{credential}}", + "Important": "Important", + "Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!", + "Never": "Never", + "Revoke {{credential}}": "Revoke {{credential}}", + "SCIM endpoint URL": "SCIM endpoint URL", + "SCIM provisioning": "SCIM provisioning", + "SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.", + "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.", + "SCIM token": "SCIM token", + "SCIM tokens": "SCIM tokens", + "This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.", + "Toggle SCIM provisioning": "Toggle SCIM provisioning", + "Token": "Token" } diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index 64bd3dde..6e842a05 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -116,7 +116,9 @@ export default function GlobalAppShell({ {isSettingsRoute ? ( - {children} + + {children} + ) : ( children )} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index e9562a85..892857f5 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts"; import { getApiKeys } from "@/ee/api-key"; import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; +import { getScimTokens } from "@/ee/scim/services/scim-token-service"; export const prefetchWorkspaceMembers = () => { const params: QueryParams = { limit: 100, query: "" }; @@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => { queryFn: () => getVerificationList(params), }); }; + +export const prefetchScimTokens = () => { + queryClient.prefetchQuery({ + queryKey: ["scim-token-list", { cursor: undefined }], + queryFn: () => getScimTokens({}), + }); +}; diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 90d89d13..8ea1f59f 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -31,6 +31,7 @@ import { prefetchBilling, prefetchGroups, prefetchLicense, + prefetchScimTokens, prefetchShares, prefetchSpaces, prefetchSsoProviders, @@ -204,7 +205,10 @@ export default function SettingsSidebar() { } break; case "Security & SSO": - prefetchHandler = prefetchSsoProviders; + prefetchHandler = () => { + prefetchSsoProviders(); + prefetchScimTokens(); + }; break; case "Public sharing": prefetchHandler = prefetchShares; diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx index 6a01ee3c..ed44811a 100644 --- a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx +++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx @@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({ @@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({ color="red" > {t( - "Make sure to copy your API key now. You won't be able to see it again!", + "Make sure to copy your {{credential}} now. You won't be able to see it again!", + { credential: t("API key") }, )} @@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({ diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx index ab19552c..0b93c080 100644 --- a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -105,7 +105,7 @@ export function CreateApiKeyModal({
handleSubmit(values))}> diff --git a/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx index 3092ead4..95651a4e 100644 --- a/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx @@ -30,12 +30,14 @@ export function RevokeApiKeyModal({ - {t("Are you sure you want to revoke this API key")}{" "} + {t("Are you sure you want to revoke this {{credential}}", { + credential: t("API key"), + })}{" "} {apiKey?.name}? diff --git a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx index e4370eac..0e66d680 100644 --- a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx @@ -53,7 +53,7 @@ export function UpdateApiKeyModal({ handleSubmit(values))}> diff --git a/apps/client/src/ee/api-key/queries/api-key-query.ts b/apps/client/src/ee/api-key/queries/api-key-query.ts index 75f2d351..f27492da 100644 --- a/apps/client/src/ee/api-key/queries/api-key-query.ts +++ b/apps/client/src/ee/api-key/queries/api-key-query.ts @@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() { return useMutation({ mutationFn: (data) => createApiKey(data), onSuccess: () => { - notifications.show({ message: t("API key created successfully") }); + notifications.show({ + message: t("{{credential}} created successfully", { + credential: t("API key"), + }), + }); queryClient.invalidateQueries({ predicate: (item) => ["api-key-list"].includes(item.queryKey[0] as string), diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index 76d08295..7fc55b3d 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -33,6 +33,10 @@ export const auditEventLabels: Record = { "api_key.updated": "Updated API key", "api_key.deleted": "Deleted API key", + "scim_token.created": "Created SCIM token", + "scim_token.updated": "Updated SCIM token", + "scim_token.deleted": "Deleted SCIM token", + "space.created": "Created space", "space.updated": "Updated space", "space.deleted": "Deleted space", @@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [ { value: "api_key.deleted", label: "Deleted API key" }, ], }, + { + group: "SCIM token", + items: [ + { value: "scim_token.created", label: "Created SCIM token" }, + { value: "scim_token.updated", label: "Updated SCIM token" }, + { value: "scim_token.deleted", label: "Deleted SCIM token" }, + ], + }, { group: "License", items: [ diff --git a/apps/client/src/ee/scim/components/create-scim-token-modal.tsx b/apps/client/src/ee/scim/components/create-scim-token-modal.tsx new file mode 100644 index 00000000..c0dfa35c --- /dev/null +++ b/apps/client/src/ee/scim/components/create-scim-token-modal.tsx @@ -0,0 +1,78 @@ +import { Modal, TextInput, Button, Group, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; +import { useTranslation } from "react-i18next"; +import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +interface CreateScimTokenModalProps { + opened: boolean; + onClose: () => void; + onSuccess: (response: IScimToken) => void; +} + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), +}); +type FormValues = z.infer; + +export function CreateScimTokenModal({ + opened, + onClose, + onSuccess, +}: CreateScimTokenModalProps) { + const { t } = useTranslation(); + const createMutation = useCreateScimTokenMutation(); + + const form = useForm({ + validate: zod4Resolver(formSchema), + initialValues: { name: "" }, + }); + + const handleSubmit = async (data: FormValues) => { + try { + const created = await createMutation.mutateAsync({ name: data.name }); + onSuccess(created); + form.reset(); + onClose(); + } catch (err) { + // + } + }; + + const handleClose = () => { + form.reset(); + onClose(); + }; + + return ( + + handleSubmit(values))}> + + + + + + + + + + + ); +} diff --git a/apps/client/src/ee/scim/components/enable-scim.tsx b/apps/client/src/ee/scim/components/enable-scim.tsx new file mode 100644 index 00000000..0f8204eb --- /dev/null +++ b/apps/client/src/ee/scim/components/enable-scim.tsx @@ -0,0 +1,55 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature.ts"; +import { Feature } from "@/ee/features.ts"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts"; + +export default function EnableScim() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false); + const hasAccess = useHasFeature(Feature.SCIM); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ isScimEnabled: value }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + +
+ {t("Enable SCIM")} + + {t( + "Automatically provision users and groups from your identity provider via SCIM.", + )} + +
+ + + + +
+ ); +} diff --git a/apps/client/src/ee/scim/components/revoke-scim-token-modal.tsx b/apps/client/src/ee/scim/components/revoke-scim-token-modal.tsx new file mode 100644 index 00000000..c6987845 --- /dev/null +++ b/apps/client/src/ee/scim/components/revoke-scim-token-modal.tsx @@ -0,0 +1,61 @@ +import { Modal, Text, Button, Group, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +interface RevokeScimTokenModalProps { + opened: boolean; + onClose: () => void; + scimToken: IScimToken | null; +} + +export function RevokeScimTokenModal({ + opened, + onClose, + scimToken, +}: RevokeScimTokenModalProps) { + const { t } = useTranslation(); + const revokeMutation = useRevokeScimTokenMutation(); + + const handleRevoke = async () => { + if (!scimToken) return; + await revokeMutation.mutateAsync({ tokenId: scimToken.id }); + onClose(); + }; + + return ( + + + + {t("Are you sure you want to revoke this {{credential}}", { + credential: t("SCIM token"), + })}{" "} + {scimToken?.name}? + + + {t( + "This action cannot be undone. Your identity provider will stop syncing immediately.", + )} + + + + + + + + + ); +} diff --git a/apps/client/src/ee/scim/components/scim-token-created-modal.tsx b/apps/client/src/ee/scim/components/scim-token-created-modal.tsx new file mode 100644 index 00000000..591a30e3 --- /dev/null +++ b/apps/client/src/ee/scim/components/scim-token-created-modal.tsx @@ -0,0 +1,69 @@ +import { + Modal, + Text, + Stack, + Alert, + Group, + Button, + TextInput, +} from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import CopyTextButton from "@/components/common/copy.tsx"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +interface ScimTokenCreatedModalProps { + opened: boolean; + onClose: () => void; + scimToken: IScimToken | null; +} + +export function ScimTokenCreatedModal({ + opened, + onClose, + scimToken, +}: ScimTokenCreatedModalProps) { + const { t } = useTranslation(); + if (!scimToken) return null; + + return ( + + + } + title={t("Important")} + color="red" + > + {t( + "Make sure to copy your {{credential}} now. You won't be able to see it again!", + { credential: t("SCIM token") }, + )} + + +
+ + {t("SCIM token")} + + + + + +
+ + +
+
+ ); +} diff --git a/apps/client/src/ee/scim/components/scim-token-table.tsx b/apps/client/src/ee/scim/components/scim-token-table.tsx new file mode 100644 index 00000000..81b596ee --- /dev/null +++ b/apps/client/src/ee/scim/components/scim-token-table.tsx @@ -0,0 +1,130 @@ +import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; +import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import React from "react"; +import NoTableResults from "@/components/common/no-table-results"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +interface ScimTokenTableProps { + tokens: IScimToken[]; + isLoading?: boolean; + onUpdate?: (token: IScimToken) => void; + onRevoke?: (token: IScimToken) => void; +} + +export function ScimTokenTable({ + tokens, + isLoading, + onUpdate, + onRevoke, +}: ScimTokenTableProps) { + const { t } = useTranslation(); + + const formatDate = (date: Date | string | null) => { + if (!date) return t("Never"); + return format(new Date(date), "MMM dd, yyyy"); + }; + + return ( + + + + + {t("Name")} + {t("Token")} + {t("Created by")} + {t("Last used")} + {t("Created")} + + + + + + {tokens && tokens.length > 0 ? ( + tokens.map((token) => ( + + + + {token.name} + + + + + + ••••{token.tokenLastFour} + + + + {token.creator ? ( + + + + + {token.creator.name} + + + + ) : ( + + + — + + + )} + + + + {formatDate(token.lastUsedAt)} + + + + + + {formatDate(token.createdAt)} + + + + + + + + + + + + {onUpdate && ( + } + onClick={() => onUpdate(token)} + > + {t("Rename")} + + )} + {onRevoke && ( + } + color="red" + onClick={() => onRevoke(token)} + > + {t("Revoke")} + + )} + + + + + )) + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/scim/components/scim-url-panel.tsx b/apps/client/src/ee/scim/components/scim-url-panel.tsx new file mode 100644 index 00000000..6aa78820 --- /dev/null +++ b/apps/client/src/ee/scim/components/scim-url-panel.tsx @@ -0,0 +1,30 @@ +import { Group, Stack, Text, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CopyTextButton from "@/components/common/copy.tsx"; + +export function ScimUrlPanel() { + const { t } = useTranslation(); + const scimUrl = `${window.location.origin}/api/scim/v2`; + + return ( + + + {t("SCIM endpoint URL")} + + + {t( + "Configure your identity provider with this URL to provision users and groups.", + )} + + + + + + + ); +} diff --git a/apps/client/src/ee/scim/components/update-scim-token-modal.tsx b/apps/client/src/ee/scim/components/update-scim-token-modal.tsx new file mode 100644 index 00000000..104e215f --- /dev/null +++ b/apps/client/src/ee/scim/components/update-scim-token-modal.tsx @@ -0,0 +1,77 @@ +import { Modal, TextInput, Button, Group, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; +import { useTranslation } from "react-i18next"; +import { useEffect } from "react"; +import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), +}); +type FormValues = z.infer; + +interface UpdateScimTokenModalProps { + opened: boolean; + onClose: () => void; + scimToken: IScimToken | null; +} + +export function UpdateScimTokenModal({ + opened, + onClose, + scimToken, +}: UpdateScimTokenModalProps) { + const { t } = useTranslation(); + const updateMutation = useUpdateScimTokenMutation(); + + const form = useForm({ + validate: zod4Resolver(formSchema), + initialValues: { name: "" }, + }); + + useEffect(() => { + if (opened && scimToken) { + form.setValues({ name: scimToken.name }); + } + }, [opened, scimToken]); + + const handleSubmit = async (data: FormValues) => { + if (!scimToken) return; + await updateMutation.mutateAsync({ + tokenId: scimToken.id, + name: data.name, + }); + onClose(); + }; + + return ( + +
handleSubmit(values))}> + + + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/scim/index.ts b/apps/client/src/ee/scim/index.ts new file mode 100644 index 00000000..e246200d --- /dev/null +++ b/apps/client/src/ee/scim/index.ts @@ -0,0 +1,2 @@ +export * from "./types/scim-token.types"; +export * from "./services/scim-token-service"; diff --git a/apps/client/src/ee/scim/queries/scim-token-query.ts b/apps/client/src/ee/scim/queries/scim-token-query.ts new file mode 100644 index 00000000..999f4d20 --- /dev/null +++ b/apps/client/src/ee/scim/queries/scim-token-query.ts @@ -0,0 +1,96 @@ +import { IPagination, QueryParams } from "@/lib/types.ts"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { + createScimToken, + getScimTokens, + revokeScimToken, + updateScimToken, +} from "@/ee/scim/services/scim-token-service"; +import { + IScimToken, + ICreateScimTokenRequest, + IRevokeScimTokenRequest, + IUpdateScimTokenRequest, +} from "@/ee/scim/types/scim-token.types"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; + +export function useGetScimTokensQuery( + params?: QueryParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["scim-token-list", params], + queryFn: () => getScimTokens(params), + placeholderData: keepPreviousData, + }); +} + +export function useCreateScimTokenMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data) => createScimToken(data), + onSuccess: () => { + notifications.show({ + message: t("{{credential}} created successfully", { + credential: t("SCIM token"), + }), + }); + queryClient.invalidateQueries({ + predicate: (item) => + ["scim-token-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} + +export function useUpdateScimTokenMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data) => updateScimToken(data), + onSuccess: () => { + notifications.show({ message: t("Updated successfully") }); + queryClient.invalidateQueries({ + predicate: (item) => + ["scim-token-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} + +export function useRevokeScimTokenMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (data) => revokeScimToken(data), + onSuccess: () => { + notifications.show({ message: t("Revoked successfully") }); + queryClient.invalidateQueries({ + predicate: (item) => + ["scim-token-list"].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + const errorMessage = error["response"]?.data?.message; + notifications.show({ message: errorMessage, color: "red" }); + }, + }); +} diff --git a/apps/client/src/ee/scim/services/scim-token-service.ts b/apps/client/src/ee/scim/services/scim-token-service.ts new file mode 100644 index 00000000..27e73035 --- /dev/null +++ b/apps/client/src/ee/scim/services/scim-token-service.ts @@ -0,0 +1,34 @@ +import api from "@/lib/api-client"; +import { + IScimToken, + ICreateScimTokenRequest, + IRevokeScimTokenRequest, + IUpdateScimTokenRequest, +} from "@/ee/scim/types/scim-token.types"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export async function getScimTokens( + params?: QueryParams, +): Promise> { + const req = await api.post("/scim-tokens", { ...params }); + return req.data; +} + +export async function createScimToken( + data: ICreateScimTokenRequest, +): Promise { + const req = await api.post("/scim-tokens/create", data); + return req.data; +} + +export async function updateScimToken( + data: IUpdateScimTokenRequest, +): Promise { + await api.post("/scim-tokens/update", data); +} + +export async function revokeScimToken( + data: IRevokeScimTokenRequest, +): Promise { + await api.post("/scim-tokens/revoke", data); +} diff --git a/apps/client/src/ee/scim/types/scim-token.types.ts b/apps/client/src/ee/scim/types/scim-token.types.ts new file mode 100644 index 00000000..07650129 --- /dev/null +++ b/apps/client/src/ee/scim/types/scim-token.types.ts @@ -0,0 +1,27 @@ +import { IUser } from "@/features/user/types/user.types.ts"; + +export interface IScimToken { + id: string; + name: string; + token?: string; + tokenLastFour: string; + isEnabled: boolean; + creatorId: string; + workspaceId: string; + lastUsedAt: string | null; + createdAt: string; + creator?: Partial; +} + +export interface ICreateScimTokenRequest { + name: string; +} + +export interface IUpdateScimTokenRequest { + tokenId: string; + name: string; +} + +export interface IRevokeScimTokenRequest { + tokenId: string; +} diff --git a/apps/client/src/ee/security/components/sso-provider-list.tsx b/apps/client/src/ee/security/components/sso-provider-list.tsx index 7aac7d11..3603f959 100644 --- a/apps/client/src/ee/security/components/sso-provider-list.tsx +++ b/apps/client/src/ee/security/components/sso-provider-list.tsx @@ -69,8 +69,8 @@ export default function SsoProviderList() { return ( <> - - + +
{t("Name")} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index 254809c7..ee634351 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -1,8 +1,18 @@ import { Helmet } from "react-helmet-async"; import { getAppName, isCloud } from "@/lib/config.ts"; import SettingsTitle from "@/components/settings/settings-title.tsx"; -import { Divider, Title } from "@mantine/core"; -import React from "react"; +import { + Alert, + Button, + Card, + Divider, + Group, + Space, + Title, + Tooltip, +} from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import React, { useState } from "react"; import useUserRole from "@/hooks/use-user-role.tsx"; import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx"; import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"; @@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx"; - +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; +import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query"; +import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel"; +import { ScimTokenTable } from "@/ee/scim/components/scim-token-table"; +import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal"; +import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal"; +import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal"; +import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal"; +import EnableScim from "@/ee/scim/components/enable-scim"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import Paginate from "@/components/common/paginate"; +import { IScimToken } from "@/ee/scim/types/scim-token.types"; + +const SCIM_TOKEN_LIMIT = 5; export default function Security() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); - const hasRetention = useHasFeature(Feature.RETENTION); - const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS); + const hasScim = useHasFeature(Feature.SCIM); + const [workspace] = useAtom(workspaceAtom); + const isScimEnabled = workspace?.isScimEnabled ?? false; + + const { cursor, goNext, goPrev } = useCursorPaginate(); + const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery( + hasScim && isScimEnabled ? { cursor } : undefined, + ); + + const [createOpen, setCreateOpen] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + const [updateTarget, setUpdateTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); if (!isAdmin) { return null; @@ -45,7 +80,7 @@ export default function Security() { - Single sign-on (SSO) + {t("Single sign-on (SSO)")} @@ -66,6 +101,102 @@ export default function Security() { )} + + {hasScim && ( + <> + + + + {t("SCIM provisioning")} + + + } + color="blue" + variant="light" + mb="md" + > + {t("SCIM takes precedence over SSO group sync while enabled.")} + + + + + + + + + {isScimEnabled && ( + <> + + + + {t("SCIM tokens")} + + + + + + + + + + + + {scimData?.items.length > 0 && ( + goNext(scimData?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + setCreateOpen(false)} + onSuccess={setCreatedToken} + /> + + setCreatedToken(null)} + scimToken={createdToken} + /> + + setUpdateTarget(null)} + scimToken={updateTarget} + /> + + setRevokeTarget(null)} + scimToken={revokeTarget} + /> + + )} + + )} ); } diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index f733f73a..eda89dd5 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -28,6 +28,7 @@ export interface IWorkspace { trashRetentionDays?: number; restrictApiToAdmins?: boolean; allowMemberTemplates?: boolean; + isScimEnabled?: boolean; } export interface IWorkspaceSettings { diff --git a/apps/server/package.json b/apps/server/package.json index 7c97763b..79492487 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -111,6 +111,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "sanitize-filename": "1.6.3", + "scimmy": "1.3.5", "socket.io": "^4.8.3", "stripe": "^17.7.0", "tlds": "^1.261.0", diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index 955a6ba5..d8be76f8 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -23,6 +23,11 @@ export const AuditEvent = { API_KEY_UPDATED: 'api_key.updated', API_KEY_DELETED: 'api_key.deleted', + // SCIM Tokens + SCIM_TOKEN_CREATED: 'scim_token.created', + SCIM_TOKEN_UPDATED: 'scim_token.updated', + SCIM_TOKEN_DELETED: 'scim_token.deleted', + // Space SPACE_CREATED: 'space.created', SPACE_UPDATED: 'space.updated', @@ -119,6 +124,7 @@ export const AuditResource = { COMMENT: 'comment', SHARE: 'share', API_KEY: 'api_key', + SCIM_TOKEN: 'scim_token', SSO_PROVIDER: 'sso_provider', WORKSPACE_INVITATION: 'workspace_invitation', ATTACHMENT: 'attachment', diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index f65067e1..21475c14 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -110,7 +110,7 @@ export function extractBearerTokenFromHeader( request: FastifyRequest, ): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; + return type?.toLowerCase() === 'bearer' ? token : undefined; } /** diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index 58ca8a89..2857f70f 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { GroupService } from './group.service'; -import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { InjectKysely } from 'nestjs-kysely'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @@ -20,6 +20,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { dbOrTx } from '@docmost/db/utils'; @Injectable() export class GroupUserService { @@ -54,17 +55,23 @@ export class GroupUserService { userIds: string[], groupId: string, workspaceId: string, + trx?: KyselyTransaction, ): Promise { - await this.groupService.findAndValidateGroup(groupId, workspaceId); + const db = dbOrTx(this.db, trx); + await this.groupService.findAndValidateGroup(groupId, workspaceId, trx); + + if (userIds.length === 0) return; // make sure we have valid workspace users - const validUsers = await this.db + const validUsers = await db .selectFrom('users') .select(['id', 'name']) .where('users.id', 'in', userIds) .where('users.workspaceId', '=', workspaceId) .execute(); + if (validUsers.length === 0) return; + // prepare users to add to group const groupUsersToInsert = []; for (const user of validUsers) { @@ -75,7 +82,7 @@ export class GroupUserService { } // batch insert new group users - await this.db + await db .insertInto('groupUsers') .values(groupUsersToInsert) .onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing()) diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 83837638..47de4693 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -216,8 +216,11 @@ export class GroupService { async findAndValidateGroup( groupId: string, workspaceId: string, + trx?: KyselyTransaction, ): Promise { - const group = await this.groupRepo.findById(groupId, workspaceId); + const group = await this.groupRepo.findById(groupId, workspaceId, { + trx, + }); if (!group) { throw new NotFoundException('Group not found'); } diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index a08b1e52..25697a4b 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -41,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() mcpEnabled: boolean; + @IsOptional() + @IsBoolean() + isScimEnabled: boolean; + @IsOptional() @IsBoolean() aiChat: boolean; diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 72608951..267eb13b 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -331,7 +331,8 @@ export class WorkspaceService { typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || typeof updateWorkspaceDto.mcpEnabled !== 'undefined' || typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' || - typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' + typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' || + typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ) { const ws = await this.db .selectFrom('workspaces') @@ -351,6 +352,14 @@ export class WorkspaceService { } } + if (typeof updateWorkspaceDto.isScimEnabled !== 'undefined') { + if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SCIM, ws.plan)) { + throw new ForbiddenException( + 'This feature requires a valid license', + ); + } + } + if ( typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' || typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || @@ -535,6 +544,7 @@ export class WorkspaceService { 'enforceSso', 'enforceMfa', 'emailDomains', + 'isScimEnabled', ], updateWorkspaceDto, workspaceBefore, diff --git a/apps/server/src/database/migrations/20260501T092214-scim.ts b/apps/server/src/database/migrations/20260501T092214-scim.ts new file mode 100644 index 00000000..69e4bf56 --- /dev/null +++ b/apps/server/src/database/migrations/20260501T092214-scim.ts @@ -0,0 +1,110 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('scim_tokens') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('token_hash', 'varchar', (col) => col.notNull()) + .addColumn('token_last_four', 'varchar(4)', (col) => col.notNull()) + .addColumn('last_used_at', 'timestamptz') + .addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true)) + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz') + .execute(); + + await db.schema + .createIndex('idx_scim_tokens_token_hash') + .ifNotExists() + .on('scim_tokens') + .column('token_hash') + .execute(); + + await db.schema + .createIndex('idx_scim_tokens_workspace_id') + .ifNotExists() + .on('scim_tokens') + .column('workspace_id') + .execute(); + + await db.schema + .alterTable('users') + .addColumn('scim_external_id', 'text') + .execute(); + + await db.schema + .createIndex('idx_users_workspace_scim_external_id') + .ifNotExists() + .on('users') + .columns(['workspace_id', 'scim_external_id']) + .where('scim_external_id', 'is not', null) + .unique() + .execute(); + + await db.schema + .alterTable('groups') + .addColumn('scim_external_id', 'text') + .execute(); + + await db.schema + .createIndex('idx_groups_workspace_scim_external_id') + .ifNotExists() + .on('groups') + .columns(['workspace_id', 'scim_external_id']) + .where('scim_external_id', 'is not', null) + .unique() + .execute(); + + await db.schema + .alterTable('groups') + .addColumn('is_external', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .execute(); + + // Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled + await sql` + UPDATE groups SET is_external = true + WHERE is_default = false + AND workspace_id IN ( + SELECT workspace_id FROM auth_providers WHERE group_sync = true + ) + `.execute(db); + + await db.schema + .alterTable('workspaces') + .addColumn('is_scim_enabled', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('scim_tokens').execute(); + + await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute(); + await db.schema.alterTable('users').dropColumn('scim_external_id').execute(); + + await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute(); + await db.schema.alterTable('groups').dropColumn('scim_external_id').execute(); + + await db.schema.alterTable('groups').dropColumn('is_external').execute(); + + await db.schema + .alterTable('workspaces') + .dropColumn('is_scim_enabled') + .execute(); +} diff --git a/apps/server/src/database/repos/group/group.repo.ts b/apps/server/src/database/repos/group/group.repo.ts index 3b8c1ea7..219a9077 100644 --- a/apps/server/src/database/repos/group/group.repo.ts +++ b/apps/server/src/database/repos/group/group.repo.ts @@ -9,7 +9,7 @@ import { } from '@docmost/db/types/entity.types'; import { ExpressionBuilder, sql } from 'kysely'; import { PaginationOptions } from '../../pagination/pagination-options'; -import { DB } from '@docmost/db/types/db'; +import { DB, Groups } from '@docmost/db/types/db'; import { DefaultGroup } from '../../../core/group/dto/create-group.dto'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; @@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin export class GroupRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} + private baseFields: Array = [ + 'id', + 'name', + 'description', + 'isDefault', + 'isExternal', + 'creatorId', + 'workspaceId', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + async findById( groupId: string, workspaceId: string, - opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, + opts?: { + includeMemberCount?: boolean; + includeScimExternalId?: boolean; + trx?: KyselyTransaction; + }, ): Promise { const db = dbOrTx(this.db, opts?.trx); return db .selectFrom('groups') - .selectAll('groups') + .select(this.baseFields) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId')) .where('id', '=', groupId) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -35,13 +53,18 @@ export class GroupRepo { async findByName( groupName: string, workspaceId: string, - opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, + opts?: { + includeMemberCount?: boolean; + includeScimExternalId?: boolean; + trx?: KyselyTransaction; + }, ): Promise { const db = dbOrTx(this.db, opts?.trx); return db .selectFrom('groups') - .selectAll('groups') + .select(this.baseFields) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId')) .where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -51,8 +74,11 @@ export class GroupRepo { updatableGroup: UpdatableGroup, groupId: string, workspaceId: string, + trx?: KyselyTransaction, ): Promise { - await this.db + const db = dbOrTx(this.db, trx); + + await db .updateTable('groups') .set({ ...updatableGroup, updatedAt: new Date() }) .where('id', '=', groupId) @@ -68,7 +94,7 @@ export class GroupRepo { return db .insertInto('groups') .values(insertableGroup) - .returningAll() + .returning(this.baseFields) .executeTakeFirst(); } @@ -80,7 +106,7 @@ export class GroupRepo { return ( db .selectFrom('groups') - .selectAll() + .select(this.baseFields) // .select((eb) => this.withMemberCount(eb)) .where('isDefault', '=', true) .where('workspaceId', '=', workspaceId) @@ -106,7 +132,7 @@ export class GroupRepo { async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { let baseQuery = this.db .selectFrom('groups') - .selectAll('groups') + .select(this.baseFields) .select((eb) => this.withMemberCount(eb)) .where('workspaceId', '=', workspaceId); diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index eaaa318e..6f9f51cf 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -44,6 +44,7 @@ export class UserRepo { opts?: { includePassword?: boolean; includeUserMfa?: boolean; + includeScimExternalId?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -53,6 +54,7 @@ export class UserRepo { .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) + .$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId')) .where('id', '=', userId) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -64,6 +66,7 @@ export class UserRepo { opts?: { includePassword?: boolean; includeUserMfa?: boolean; + includeScimExternalId?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -73,6 +76,7 @@ export class UserRepo { .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) + .$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId')) .where(sql`LOWER(email)`, '=', sql`LOWER(${email})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 4e399011..408c46ba 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -34,6 +34,7 @@ export class WorkspaceRepo { 'plan', 'enforceMfa', 'trashRetentionDays', + 'isScimEnabled', ]; constructor(@InjectKysely() private readonly db: KyselyDB) {} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0890df93..ef2c02a0 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -213,7 +213,9 @@ export interface Groups { description: string | null; id: Generated; isDefault: boolean; + isExternal: Generated; name: string; + scimExternalId: string | null; updatedAt: Generated; workspaceId: string; } @@ -338,6 +340,7 @@ export interface Users { name: string | null; password: string | null; role: string | null; + scimExternalId: string | null; settings: Json | null; timezone: string | null; updatedAt: Generated; @@ -381,6 +384,7 @@ export interface Workspaces { enforceMfa: Generated; enforceSso: Generated; hostname: string | null; + isScimEnabled: Generated; id: Generated; licenseKey: string | null; logo: string | null; @@ -410,6 +414,20 @@ export interface Notifications { createdAt: Generated; } +export interface ScimTokens { + createdAt: Generated; + deletedAt: Timestamp | null; + id: Generated; + isEnabled: Generated; + lastUsedAt: Timestamp | null; + name: string; + tokenHash: string; + tokenLastFour: string; + creatorId: string | null; + updatedAt: Generated; + workspaceId: string; +} + export interface Watchers { id: Generated; userId: string; @@ -558,6 +576,7 @@ export interface DB { pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; pages: Pages; + scimTokens: ScimTokens; shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 481321bc..ffac8406 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -29,6 +29,7 @@ import { UserMfa as _UserMFA, UserSessions, ApiKeys, + ScimTokens, Watchers, Audit as _Audit, Templates, @@ -159,6 +160,11 @@ export type ApiKey = Selectable; export type InsertableApiKey = Insertable; export type UpdatableApiKey = Updateable>; +// Scim Tokens +export type ScimToken = Selectable; +export type InsertableScimToken = Insertable; +export type UpdatableScimToken = Updateable>; + // Page Embedding export type PageEmbedding = Selectable; export type InsertablePageEmbedding = Insertable; diff --git a/apps/server/src/ee b/apps/server/src/ee index fabe2729..10982907 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fabe2729879e0543518f0c42bfdb3b403afe3c4a +Subproject commit 109829076c8d81f6d77836820a5caa63ae8d073f diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index d47bf547..7e82b6d9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -50,6 +50,22 @@ async function bootstrap() { await app.register(fastifyMultipart); await app.register(fastifyCookie); + app + .getHttpAdapter() + .getInstance() + .addContentTypeParser( + 'application/scim+json', + { parseAs: 'string' }, + (_, body, done) => { + try { + const json = JSON.parse(body.toString()); + done(null, json); + } catch (err: any) { + done(err); + } + }, + ); + app .getHttpAdapter() .getInstance() diff --git a/package.json b/package.json index 58972162..cd2fb127 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "packageManager": "pnpm@10.4.0", "pnpm": { "patchedDependencies": { - "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch" + "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch", + "scimmy@1.3.5": "patches/scimmy@1.3.5.patch" }, "overrides": { "prosemirror-changeset": "2.4.0", diff --git a/patches/@tiptap__core.patch b/patches/@tiptap__core.patch deleted file mode 100644 index 58f580c8..00000000 --- a/patches/@tiptap__core.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/dist/index.cjs b/dist/index.cjs -index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644 ---- a/dist/index.cjs -+++ b/dist/index.cjs -@@ -5463,7 +5463,10 @@ var ResizableNodeView = class { - this.container.classList.remove(this.classNames.resizing); - } - document.removeEventListener("mousemove", this.handleMouseMove); -+ document.removeEventListener("touchmove", this.handleTouchMove); - document.removeEventListener("mouseup", this.handleMouseUp); -+ document.removeEventListener("touchend", this.handleMouseUp); -+ window.removeEventListener("blur", this.handleMouseUp); - document.removeEventListener("keydown", this.handleKeyDown); - document.removeEventListener("keyup", this.handleKeyUp); - }; -@@ -5593,7 +5596,10 @@ var ResizableNodeView = class { - this.container.classList.remove(this.classNames.resizing); - } - document.removeEventListener("mousemove", this.handleMouseMove); -+ document.removeEventListener("touchmove", this.handleTouchMove); - document.removeEventListener("mouseup", this.handleMouseUp); -+ document.removeEventListener("touchend", this.handleMouseUp); -+ window.removeEventListener("blur", this.handleMouseUp); - document.removeEventListener("keydown", this.handleKeyDown); - document.removeEventListener("keyup", this.handleKeyUp); - this.isResizing = false; -@@ -5796,6 +5802,8 @@ var ResizableNodeView = class { - document.addEventListener("mousemove", this.handleMouseMove); - document.addEventListener("touchmove", this.handleTouchMove); - document.addEventListener("mouseup", this.handleMouseUp); -+ document.addEventListener("touchend", this.handleMouseUp); -+ window.addEventListener("blur", this.handleMouseUp); - document.addEventListener("keydown", this.handleKeyDown); - document.addEventListener("keyup", this.handleKeyUp); - } -diff --git a/dist/index.js b/dist/index.js -index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -5330,7 +5330,10 @@ var ResizableNodeView = class { - this.container.classList.remove(this.classNames.resizing); - } - document.removeEventListener("mousemove", this.handleMouseMove); -+ document.removeEventListener("touchmove", this.handleTouchMove); - document.removeEventListener("mouseup", this.handleMouseUp); -+ document.removeEventListener("touchend", this.handleMouseUp); -+ window.removeEventListener("blur", this.handleMouseUp); - document.removeEventListener("keydown", this.handleKeyDown); - document.removeEventListener("keyup", this.handleKeyUp); - }; -@@ -5460,7 +5463,10 @@ var ResizableNodeView = class { - this.container.classList.remove(this.classNames.resizing); - } - document.removeEventListener("mousemove", this.handleMouseMove); -+ document.removeEventListener("touchmove", this.handleTouchMove); - document.removeEventListener("mouseup", this.handleMouseUp); -+ document.removeEventListener("touchend", this.handleMouseUp); -+ window.removeEventListener("blur", this.handleMouseUp); - document.removeEventListener("keydown", this.handleKeyDown); - document.removeEventListener("keyup", this.handleKeyUp); - this.isResizing = false; -@@ -5663,6 +5669,8 @@ var ResizableNodeView = class { - document.addEventListener("mousemove", this.handleMouseMove); - document.addEventListener("touchmove", this.handleTouchMove); - document.addEventListener("mouseup", this.handleMouseUp); -+ document.addEventListener("touchend", this.handleMouseUp); -+ window.addEventListener("blur", this.handleMouseUp); - document.addEventListener("keydown", this.handleKeyDown); - document.addEventListener("keyup", this.handleKeyUp); - } -diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts -index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644 ---- a/src/lib/ResizableNodeView.ts -+++ b/src/lib/ResizableNodeView.ts -@@ -523,7 +523,10 @@ export class ResizableNodeView { - } - - document.removeEventListener('mousemove', this.handleMouseMove) -+ document.removeEventListener('touchmove', this.handleTouchMove) - document.removeEventListener('mouseup', this.handleMouseUp) -+ document.removeEventListener('touchend', this.handleMouseUp) -+ window.removeEventListener('blur', this.handleMouseUp) - document.removeEventListener('keydown', this.handleKeyDown) - document.removeEventListener('keyup', this.handleKeyUp) - this.isResizing = false -@@ -774,6 +777,8 @@ export class ResizableNodeView { - document.addEventListener('mousemove', this.handleMouseMove) - document.addEventListener('touchmove', this.handleTouchMove) - document.addEventListener('mouseup', this.handleMouseUp) -+ document.addEventListener('touchend', this.handleMouseUp) -+ window.addEventListener('blur', this.handleMouseUp) - document.addEventListener('keydown', this.handleKeyDown) - document.addEventListener('keyup', this.handleKeyUp) - } -@@ -859,7 +864,10 @@ export class ResizableNodeView { - - // Clean up document-level listeners - document.removeEventListener('mousemove', this.handleMouseMove) -+ document.removeEventListener('touchmove', this.handleTouchMove) - document.removeEventListener('mouseup', this.handleMouseUp) -+ document.removeEventListener('touchend', this.handleMouseUp) -+ window.removeEventListener('blur', this.handleMouseUp) - document.removeEventListener('keydown', this.handleKeyDown) - document.removeEventListener('keyup', this.handleKeyUp) - } diff --git a/patches/scimmy@1.3.5.patch b/patches/scimmy@1.3.5.patch new file mode 100644 index 00000000..fd206034 --- /dev/null +++ b/patches/scimmy@1.3.5.patch @@ -0,0 +1,23 @@ +diff --git a/dist/cjs/lib/messages.cjs b/dist/cjs/lib/messages.cjs +index e74b8f52137e3267f3d065c4210a1114c4f32dd1..5740606b18851c0ac4f55cfa333152359e0ad135 100644 +--- a/dist/cjs/lib/messages.cjs ++++ b/dist/cjs/lib/messages.cjs +@@ -502,10 +502,15 @@ class PatchOp { + } + } + } +- ++ ++ /** Reason: Commented out to avoid failing patch requests when filters don't match. ++ * Some IdPs send patch paths like `addresses[type eq "work"].country` even if no such address exists. We can't always decide what the end user IdPs send. ++ * Since we manually control patch application, we safely ignore these cases. ++ * example error: "noTarget","detail":"Filter 'addresses[type eq \"work\"].country' does not match any values for 'add' op of operation 5 in PatchOp request body ++ */ + // No targets, bail out! +- if (targets.length === 0 && op !== "remove") +- throw new lib_types.default.Error(400, "noTarget", `Filter '${path}' does not match any values for '${op}' op of operation ${index} in PatchOp request body`); ++ // if (targets.length === 0 && op !== "remove") ++ // throw new lib_types.default.Error(400, "noTarget", `Filter '${path}' does not match any values for '${op}' op of operation ${index} in PatchOp request body`); + + /** + * @typedef {Object} PatchOpDetails diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ceb2977..3985bc75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ patchedDependencies: react-arborist@3.4.0: hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a path: patches/react-arborist@3.4.0.patch + scimmy@1.3.5: + hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673 + path: patches/scimmy@1.3.5.patch importers: @@ -701,6 +704,9 @@ importers: sanitize-filename: specifier: 1.6.3 version: 1.6.3 + scimmy: + specifier: 1.3.5 + version: 1.3.5(patch_hash=775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673) socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -9604,6 +9610,10 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + scimmy@1.3.5: + resolution: {integrity: sha512-JTrUOoqH1gMH2zZhgk01hGgY7cH9v4qUli5b3OGVVOzjAwY8h4Z2mSNH8kXjW2pz8ypzpiRuMEtFGBaWQWJz7w==} + engines: {node: '>=16'} + secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} @@ -20944,6 +20954,8 @@ snapshots: ajv-formats: 2.1.1(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0) + scimmy@1.3.5(patch_hash=775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673): {} + secure-json-parse@4.0.0: {} selderee@0.11.0: