Add SCIM management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-04 19:03:00 +01:00
parent b6799cbb01
commit cfc514c8e0
405 changed files with 4888 additions and 88927 deletions

View File

@@ -0,0 +1,60 @@
import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import type { SCIMSettingsPageQuery } from "/__generated__/iam/SCIMSettingsPageQuery.graphql";
import { useTranslate } from "@probo/i18n";
import { SCIMConfiguration } from "./_components/SCIMConfiguration";
import { SCIMEventList } from "./_components/SCIMEventList";
export const scimSettingsPageQuery = graphql`
query SCIMSettingsPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) @required(action: THROW) {
__typename
... on Organization {
id
canCreateSCIMConfiguration: permission(
action: "iam:scim-configuration:create"
)
canDeleteSCIMConfiguration: permission(
action: "iam:scim-configuration:delete"
)
scimConfiguration {
...SCIMConfigurationFragment
...SCIMEventListFragment
}
}
}
}
`;
export function SCIMSettingsPage(props: {
queryRef: PreloadedQuery<SCIMSettingsPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const { organization } = usePreloadedQuery(scimSettingsPageQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("invalid node type");
}
return (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-base font-medium">{__("SCIM Provisioning")}</h2>
<SCIMConfiguration
organizationId={organization.id}
fKey={organization.scimConfiguration ?? null}
canCreate={organization.canCreateSCIMConfiguration}
canDelete={organization.canDeleteSCIMConfiguration}
/>
</div>
{organization.scimConfiguration && (
<div className="space-y-4">
<h2 className="text-base font-medium">{__("SCIM Event History")}</h2>
<SCIMEventList fKey={organization.scimConfiguration} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useQueryLoader } from "react-relay";
import { SCIMSettingsPage, scimSettingsPageQuery } from "./SCIMSettingsPage";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { IAMRelayProvider } from "/providers/IAMRelayProvider";
import { useEffect } from "react";
import type { SCIMSettingsPageQuery } from "/__generated__/iam/SCIMSettingsPageQuery.graphql";
function SCIMSettingsPageLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<SCIMSettingsPageQuery>(
scimSettingsPageQuery
);
useEffect(() => {
loadQuery({
organizationId,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return null;
}
return <SCIMSettingsPage queryRef={queryRef} />;
}
export default function () {
return (
<IAMRelayProvider>
<SCIMSettingsPageLoader />
</IAMRelayProvider>
);
}

View File

@@ -1,4 +1,5 @@
import {
IconKey,
IconLock,
IconPeopleAdd,
IconSettingsGear2,
@@ -36,6 +37,10 @@ export default function () {
<IconLock size={20} />
{__("SAML SSO")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/settings/scim`}>
<IconKey size={20} />
{__("SCIM")}
</TabLink>
</Tabs>
<Outlet />

View File

@@ -0,0 +1,354 @@
import { useState } from "react";
import { graphql, useFragment, useMutation } from "react-relay";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
Dialog,
useDialogRef,
useToast,
IconSquareBehindSquare2,
IconRotateCw,
IconTrashCan,
} from "@probo/ui";
import type { SCIMConfigurationCreateMutation } from "/__generated__/iam/SCIMConfigurationCreateMutation.graphql";
import type { SCIMConfigurationDeleteMutation } from "/__generated__/iam/SCIMConfigurationDeleteMutation.graphql";
import type { SCIMConfigurationRegenerateTokenMutation } from "/__generated__/iam/SCIMConfigurationRegenerateTokenMutation.graphql";
import type { SCIMConfigurationFragment$key } from "/__generated__/iam/SCIMConfigurationFragment.graphql";
const SCIMConfigurationFragment = graphql`
fragment SCIMConfigurationFragment on SCIMConfiguration {
id
endpointUrl
}
`;
const createSCIMConfigurationMutation = graphql`
mutation SCIMConfigurationCreateMutation(
$input: CreateSCIMConfigurationInput!
) {
createSCIMConfiguration(input: $input) {
scimConfiguration {
organization {
id
scimConfiguration {
id
endpointUrl
createdAt
updatedAt
}
}
}
token
}
}
`;
const deleteSCIMConfigurationMutation = graphql`
mutation SCIMConfigurationDeleteMutation(
$input: DeleteSCIMConfigurationInput!
) {
deleteSCIMConfiguration(input: $input) {
deletedScimConfigurationId @deleteRecord
}
}
`;
const regenerateSCIMTokenMutation = graphql`
mutation SCIMConfigurationRegenerateTokenMutation(
$input: RegenerateSCIMTokenInput!
) {
regenerateSCIMToken(input: $input) {
scimConfiguration {
id
endpointUrl
createdAt
updatedAt
}
token
}
}
`;
export function SCIMConfiguration(props: {
organizationId: string;
fKey: SCIMConfigurationFragment$key | null;
canCreate: boolean;
canDelete: boolean;
}) {
const { organizationId, canCreate, canDelete, fKey } = props;
const scimConfiguration = useFragment(SCIMConfigurationFragment, fKey);
const { __ } = useTranslate();
const { toast } = useToast();
const [token, setToken] = useState<string | null>(null);
const deleteDialogRef = useDialogRef();
const [createSCIMConfiguration, isCreatingSAMLConfiguration] =
useMutation<SCIMConfigurationCreateMutation>(
createSCIMConfigurationMutation
);
const [deleteSCIMConfiguration, isDeletingSCIMConfiguration] =
useMutation<SCIMConfigurationDeleteMutation>(
deleteSCIMConfigurationMutation
);
const [regenerateSCIMToken, isRegeneratingSCIMToken] =
useMutation<SCIMConfigurationRegenerateTokenMutation>(
regenerateSCIMTokenMutation
);
const handleCreate = () => {
createSCIMConfiguration({
variables: {
input: {
organizationId,
},
},
onCompleted: (response) => {
if (response.createSCIMConfiguration) {
setToken(response.createSCIMConfiguration.token);
}
toast({
title: __("SCIM Configuration Created"),
description: __(
"Copy the bearer token now. It will not be shown again."
),
variant: "success",
});
},
onError: (error: Error) => {
toast({
variant: "error",
title: __("Error"),
description: error.message,
});
},
});
};
const handleDelete = () => {
if (!scimConfiguration) return;
deleteSCIMConfiguration({
variables: {
input: {
organizationId,
scimConfigurationId: scimConfiguration.id,
},
},
onCompleted: () => {
deleteDialogRef.current?.close();
setToken(null);
toast({
title: __("SCIM Configuration Deleted"),
description: __(
"All SCIM-provisioned memberships have been changed to manual source."
),
variant: "success",
});
},
onError: (error: Error) => {
toast({
variant: "error",
title: __("Error"),
description: error.message,
});
},
});
};
const handleRegenerate = () => {
if (!scimConfiguration) return;
regenerateSCIMToken({
variables: {
input: {
organizationId,
scimConfigurationId: scimConfiguration.id,
},
},
onCompleted: (response) => {
if (response.regenerateSCIMToken) {
setToken(response.regenerateSCIMToken.token);
}
toast({
title: __("Bearer Token Regenerated"),
description: __(
"Copy the new bearer token now. It will not be shown again."
),
variant: "success",
});
},
onError: (error: Error) => {
toast({
variant: "error",
title: __("Error"),
description: error.message,
});
},
});
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast({
title: __("Copied to clipboard"),
description: label,
variant: "success",
});
};
if (!scimConfiguration) {
return (
<Card padded>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{__("SCIM is not configured")}</h3>
<p className="text-sm text-txt-secondary mt-1">
{__(
"Enable SCIM to automatically provision users from your identity provider."
)}
</p>
</div>
{canCreate && (
<Button
onClick={handleCreate}
disabled={isCreatingSAMLConfiguration}
>
{isCreatingSAMLConfiguration
? __("Enabling...")
: __("Enable SCIM")}
</Button>
)}
</div>
</Card>
);
}
return (
<>
<Card padded>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{__("SCIM Provisioning Active")}</h3>
<p className="text-sm text-txt-secondary">
{__(
"Automatic user provisioning is enabled for this organization."
)}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{__("SCIM Endpoint URL")}
</label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 bg-subtle p-2 rounded text-sm font-mono">
{scimConfiguration.endpointUrl}
</code>
<Button
variant="secondary"
onClick={() =>
copyToClipboard(
scimConfiguration.endpointUrl,
__("SCIM Endpoint URL")
)
}
icon={IconSquareBehindSquare2}
/>
</div>
</div>
{token && (
<div>
<label className="text-sm font-medium">
{__("Bearer Token")}
</label>
<p className="text-xs text-txt-warning mb-1">
{__("This token will only be shown once. Copy it now.")}
</p>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 bg-subtle p-2 rounded text-sm font-mono break-all">
{token}
</code>
<Button
variant="secondary"
onClick={() => copyToClipboard(token, __("Bearer Token"))}
icon={IconSquareBehindSquare2}
/>
</div>
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-border-low">
<Button
variant="secondary"
onClick={handleRegenerate}
disabled={isRegeneratingSCIMToken}
icon={IconRotateCw}
>
{isRegeneratingSCIMToken
? __("Regenerating...")
: __("Regenerate Token")}
</Button>
{canDelete && (
<Button
variant="danger"
onClick={() => deleteDialogRef.current?.open()}
icon={IconTrashCan}
>
{__("Delete Configuration")}
</Button>
)}
</div>
</div>
</div>
</Card>
<Dialog
ref={deleteDialogRef}
title={__("Delete SCIM Configuration")}
onClose={() => deleteDialogRef.current?.close()}
>
<div className="p-4 space-y-4">
<p>
{__(
"Are you sure you want to delete the SCIM configuration? This will:"
)}
</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>{__("Disable automatic user provisioning")}</li>
<li>
{__("Change all SCIM-provisioned memberships to manual source")}
</li>
<li>{__("Invalidate the current bearer token")}</li>
</ul>
<p className="text-sm text-txt-secondary">
{__(
"Existing users will not be removed, only their membership source will change."
)}
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => deleteDialogRef.current?.close()}
>
{__("Cancel")}
</Button>
<Button
variant="danger"
onClick={handleDelete}
disabled={isDeletingSCIMConfiguration}
>
{isDeletingSCIMConfiguration ? __("Deleting...") : __("Delete")}
</Button>
</div>
</div>
</Dialog>
</>
);
}