@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user