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
This commit is contained in:
Philip Okugbe
2026-05-01 14:53:30 +01:00
committed by GitHub
parent 1d2486455f
commit 641ce142df
43 changed files with 1136 additions and 148 deletions
@@ -608,25 +608,21 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully", "Image removed successfully": "Image removed successfully",
"API key": "API key", "API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys", "API keys": "API keys",
"API management": "API management", "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", "Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name", "Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration", "Expiration": "Expiration",
"Expired": "Expired", "Expired": "Expired",
"Expires": "Expires", "Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used", "Last use": "Last Used",
"No API keys found": "No API keys found", "No API keys found": "No API keys found",
"No expiration": "No expiration", "No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully", "Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date", "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.", "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", "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", "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.", "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 a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "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"
} }
@@ -116,7 +116,9 @@ export default function GlobalAppShell({
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main>
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900}>{children}</Container> <Container size={900} pb={80}>
{children}
</Container>
) : ( ) : (
children children
)} )}
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params: QueryParams = { limit: 100, query: "" };
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params), queryFn: () => getVerificationList(params),
}); });
}; };
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,6 +31,7 @@ import {
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
prefetchScimTokens,
prefetchShares, prefetchShares,
prefetchSpaces, prefetchSpaces,
prefetchSsoProviders, prefetchSsoProviders,
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
} }
break; break;
case "Security & SSO": case "Security & SSO":
prefetchHandler = prefetchSsoProviders; prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
break; break;
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("API key created")} title={t("{{credential}} created", { credential: t("API key") })}
size="lg" size="lg"
> >
<Stack gap="md"> <Stack gap="md">
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
color="red" color="red"
> >
{t( {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") },
)} )}
</Alert> </Alert>
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
</div> </div>
<Button fullWidth onClick={onClose} mt="md"> <Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")} {t("I've saved my {{credential}}", { credential: t("API key") })}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={handleClose} onClose={handleClose}
title={t("Create API Key")} title={t("Create {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Revoke API key")} title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
{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"),
})}{" "}
<strong>{apiKey?.name}</strong>? <strong>{apiKey?.name}</strong>?
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Update API key")} title={t("Update {{credential}}", { credential: t("API key") })}
size="md" size="md"
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({ return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data), mutationFn: (data) => createApiKey(data),
onSuccess: () => { onSuccess: () => {
notifications.show({ message: t("API key created successfully") }); notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string), ["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key", "api_key.updated": "Updated API key",
"api_key.deleted": "Deleted 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.created": "Created space",
"space.updated": "Updated space", "space.updated": "Updated space",
"space.deleted": "Deleted space", "space.deleted": "Deleted space",
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" }, { 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", group: "License",
items: [ items: [
@@ -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<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
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 (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -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<HTMLInputElement>) => {
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 (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
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") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -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 (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -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 (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -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<typeof formSchema>;
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<FormValues>({
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 (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -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<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
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<void, Error, IUpdateScimTokenRequest>({
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<void, Error, IRevokeScimTokenRequest>({
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" });
},
});
}
@@ -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<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -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<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return ( return (
<> <>
<Card shadow="sm" radius="sm"> <Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600}> <Table.ScrollContainer minWidth={600} maxHeight={400}>
<Table verticalSpacing="sm"> <Table verticalSpacing="sm" stickyHeader>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Name")}</Table.Th> <Table.Th>{t("Name")}</Table.Th>
+137 -6
View File
@@ -1,8 +1,18 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core"; import {
import React from "react"; 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 useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx"; import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.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 EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.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 { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; 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() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION); const hasScim = useHasFeature(Feature.SCIM);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS); 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<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -45,7 +80,7 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) {t("Single sign-on (SSO)")}
</Title> </Title>
<EnforceSso /> <EnforceSso />
@@ -66,6 +101,102 @@ export default function Security() {
)} )}
<SsoProviderList /> <SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</> </>
); );
} }
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number; trashRetentionDays?: number;
restrictApiToAdmins?: boolean; restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean; allowMemberTemplates?: boolean;
isScimEnabled?: boolean;
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
+1
View File
@@ -111,6 +111,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"scimmy": "1.3.5",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"stripe": "^17.7.0", "stripe": "^17.7.0",
"tlds": "^1.261.0", "tlds": "^1.261.0",
@@ -23,6 +23,11 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated', API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted', 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
SPACE_CREATED: 'space.created', SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated', SPACE_UPDATED: 'space.updated',
@@ -119,6 +124,7 @@ export const AuditResource = {
COMMENT: 'comment', COMMENT: 'comment',
SHARE: 'share', SHARE: 'share',
API_KEY: 'api_key', API_KEY: 'api_key',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider', SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation', WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment', ATTACHMENT: 'attachment',
+1 -1
View File
@@ -110,7 +110,7 @@ export function extractBearerTokenFromHeader(
request: FastifyRequest, request: FastifyRequest,
): string | undefined { ): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? []; const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined; return type?.toLowerCase() === 'bearer' ? token : undefined;
} }
/** /**
@@ -7,7 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service'; 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 { InjectKysely } from 'nestjs-kysely';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@@ -20,6 +20,7 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { dbOrTx } from '@docmost/db/utils';
@Injectable() @Injectable()
export class GroupUserService { export class GroupUserService {
@@ -54,17 +55,23 @@ export class GroupUserService {
userIds: string[], userIds: string[],
groupId: string, groupId: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> { ): Promise<void> {
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 // make sure we have valid workspace users
const validUsers = await this.db const validUsers = await db
.selectFrom('users') .selectFrom('users')
.select(['id', 'name']) .select(['id', 'name'])
.where('users.id', 'in', userIds) .where('users.id', 'in', userIds)
.where('users.workspaceId', '=', workspaceId) .where('users.workspaceId', '=', workspaceId)
.execute(); .execute();
if (validUsers.length === 0) return;
// prepare users to add to group // prepare users to add to group
const groupUsersToInsert = []; const groupUsersToInsert = [];
for (const user of validUsers) { for (const user of validUsers) {
@@ -75,7 +82,7 @@ export class GroupUserService {
} }
// batch insert new group users // batch insert new group users
await this.db await db
.insertInto('groupUsers') .insertInto('groupUsers')
.values(groupUsersToInsert) .values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing()) .onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
@@ -216,8 +216,11 @@ export class GroupService {
async findAndValidateGroup( async findAndValidateGroup(
groupId: string, groupId: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction,
): Promise<Group> { ): Promise<Group> {
const group = await this.groupRepo.findById(groupId, workspaceId); const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) { if (!group) {
throw new NotFoundException('Group not found'); throw new NotFoundException('Group not found');
} }
@@ -41,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean() @IsBoolean()
mcpEnabled: boolean; mcpEnabled: boolean;
@IsOptional()
@IsBoolean()
isScimEnabled: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
aiChat: boolean; aiChat: boolean;
@@ -331,7 +331,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' || typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' || typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
) { ) {
const ws = await this.db const ws = await this.db
.selectFrom('workspaces') .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 ( if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' || typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -535,6 +544,7 @@ export class WorkspaceService {
'enforceSso', 'enforceSso',
'enforceMfa', 'enforceMfa',
'emailDomains', 'emailDomains',
'isScimEnabled',
], ],
updateWorkspaceDto, updateWorkspaceDto,
workspaceBefore, workspaceBefore,
@@ -0,0 +1,110 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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();
}
@@ -9,7 +9,7 @@ import {
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options'; 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 { DefaultGroup } from '../../../core/group/dto/create-group.dto';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
export class GroupRepo { export class GroupRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Groups> = [
'id',
'name',
'description',
'isDefault',
'isExternal',
'creatorId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById( async findById(
groupId: string, groupId: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> { ): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx); const db = dbOrTx(this.db, opts?.trx);
return db return db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', groupId) .where('id', '=', groupId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -35,13 +53,18 @@ export class GroupRepo {
async findByName( async findByName(
groupName: string, groupName: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> { ): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx); const db = dbOrTx(this.db, opts?.trx);
return db return db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`) .where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -51,8 +74,11 @@ export class GroupRepo {
updatableGroup: UpdatableGroup, updatableGroup: UpdatableGroup,
groupId: string, groupId: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> { ): Promise<void> {
await this.db const db = dbOrTx(this.db, trx);
await db
.updateTable('groups') .updateTable('groups')
.set({ ...updatableGroup, updatedAt: new Date() }) .set({ ...updatableGroup, updatedAt: new Date() })
.where('id', '=', groupId) .where('id', '=', groupId)
@@ -68,7 +94,7 @@ export class GroupRepo {
return db return db
.insertInto('groups') .insertInto('groups')
.values(insertableGroup) .values(insertableGroup)
.returningAll() .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -80,7 +106,7 @@ export class GroupRepo {
return ( return (
db db
.selectFrom('groups') .selectFrom('groups')
.selectAll() .select(this.baseFields)
// .select((eb) => this.withMemberCount(eb)) // .select((eb) => this.withMemberCount(eb))
.where('isDefault', '=', true) .where('isDefault', '=', true)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
@@ -106,7 +132,7 @@ export class GroupRepo {
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let baseQuery = this.db let baseQuery = this.db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .select(this.baseFields)
.select((eb) => this.withMemberCount(eb)) .select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId); .where('workspaceId', '=', workspaceId);
@@ -44,6 +44,7 @@ export class UserRepo {
opts?: { opts?: {
includePassword?: boolean; includePassword?: boolean;
includeUserMfa?: boolean; includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction; trx?: KyselyTransaction;
}, },
): Promise<User> { ): Promise<User> {
@@ -53,6 +54,7 @@ export class UserRepo {
.select(this.baseFields) .select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password')) .$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', userId) .where('id', '=', userId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -64,6 +66,7 @@ export class UserRepo {
opts?: { opts?: {
includePassword?: boolean; includePassword?: boolean;
includeUserMfa?: boolean; includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction; trx?: KyselyTransaction;
}, },
): Promise<User> { ): Promise<User> {
@@ -73,6 +76,7 @@ export class UserRepo {
.select(this.baseFields) .select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password')) .$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`) .where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
'plan', 'plan',
'enforceMfa', 'enforceMfa',
'trashRetentionDays', 'trashRetentionDays',
'isScimEnabled',
]; ];
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
+19
View File
@@ -213,7 +213,9 @@ export interface Groups {
description: string | null; description: string | null;
id: Generated<string>; id: Generated<string>;
isDefault: boolean; isDefault: boolean;
isExternal: Generated<boolean>;
name: string; name: string;
scimExternalId: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
@@ -338,6 +340,7 @@ export interface Users {
name: string | null; name: string | null;
password: string | null; password: string | null;
role: string | null; role: string | null;
scimExternalId: string | null;
settings: Json | null; settings: Json | null;
timezone: string | null; timezone: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
@@ -381,6 +384,7 @@ export interface Workspaces {
enforceMfa: Generated<boolean | null>; enforceMfa: Generated<boolean | null>;
enforceSso: Generated<boolean>; enforceSso: Generated<boolean>;
hostname: string | null; hostname: string | null;
isScimEnabled: Generated<boolean>;
id: Generated<string>; id: Generated<string>;
licenseKey: string | null; licenseKey: string | null;
logo: string | null; logo: string | null;
@@ -410,6 +414,20 @@ export interface Notifications {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
} }
export interface ScimTokens {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
lastUsedAt: Timestamp | null;
name: string;
tokenHash: string;
tokenLastFour: string;
creatorId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Watchers { export interface Watchers {
id: Generated<string>; id: Generated<string>;
userId: string; userId: string;
@@ -558,6 +576,7 @@ export interface DB {
pageVerifications: PageVerifications; pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers; pageVerifiers: PageVerifiers;
pages: Pages; pages: Pages;
scimTokens: ScimTokens;
shares: Shares; shares: Shares;
spaceMembers: SpaceMembers; spaceMembers: SpaceMembers;
spaces: Spaces; spaces: Spaces;
@@ -29,6 +29,7 @@ import {
UserMfa as _UserMFA, UserMfa as _UserMFA,
UserSessions, UserSessions,
ApiKeys, ApiKeys,
ScimTokens,
Watchers, Watchers,
Audit as _Audit, Audit as _Audit,
Templates, Templates,
@@ -159,6 +160,11 @@ export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>; export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>; export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Scim Tokens
export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// Page Embedding // Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>; export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>; export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
+16
View File
@@ -50,6 +50,22 @@ async function bootstrap() {
await app.register(fastifyMultipart); await app.register(fastifyMultipart);
await app.register(fastifyCookie); 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 app
.getHttpAdapter() .getHttpAdapter()
.getInstance() .getInstance()
+2 -1
View File
@@ -95,7 +95,8 @@
"packageManager": "pnpm@10.4.0", "packageManager": "pnpm@10.4.0",
"pnpm": { "pnpm": {
"patchedDependencies": { "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": { "overrides": {
"prosemirror-changeset": "2.4.0", "prosemirror-changeset": "2.4.0",
-105
View File
@@ -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)
}
+23
View File
@@ -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
+12
View File
@@ -46,6 +46,9 @@ patchedDependencies:
react-arborist@3.4.0: react-arborist@3.4.0:
hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a
path: patches/react-arborist@3.4.0.patch path: patches/react-arborist@3.4.0.patch
scimmy@1.3.5:
hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673
path: patches/scimmy@1.3.5.patch
importers: importers:
@@ -701,6 +704,9 @@ importers:
sanitize-filename: sanitize-filename:
specifier: 1.6.3 specifier: 1.6.3
version: 1.6.3 version: 1.6.3
scimmy:
specifier: 1.3.5
version: 1.3.5(patch_hash=775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673)
socket.io: socket.io:
specifier: ^4.8.3 specifier: ^4.8.3
version: 4.8.3 version: 4.8.3
@@ -9604,6 +9610,10 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
scimmy@1.3.5:
resolution: {integrity: sha512-JTrUOoqH1gMH2zZhgk01hGgY7cH9v4qUli5b3OGVVOzjAwY8h4Z2mSNH8kXjW2pz8ypzpiRuMEtFGBaWQWJz7w==}
engines: {node: '>=16'}
secure-json-parse@4.0.0: secure-json-parse@4.0.0:
resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==}
@@ -20944,6 +20954,8 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.18.0) ajv-formats: 2.1.1(ajv@8.18.0)
ajv-keywords: 5.1.0(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: {} secure-json-parse@4.0.0: {}
selderee@0.11.0: selderee@0.11.0: