diff --git a/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPage.tsx new file mode 100644 index 000000000..93b12f230 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPage.tsx @@ -0,0 +1,123 @@ +import { + graphql, + usePreloadedQuery, + useQueryLoader, + type PreloadedQuery, +} from "react-relay"; +import type { SAMLSettingsPageQuery } from "./__generated__/SAMLSettingsPageQuery.graphql"; +import { Suspense, use, useState } from "react"; +import { PermissionsContext } from "/providers/PermissionsContext"; +import { Breadcrumb, Button, Dialog, useDialogRef } from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import { SAMLConfigurationList } from "./_components/SAMLConfigurationList"; +import { NewSAMLConfigurationForm } from "./_components/NewSAMLConfigurationForm"; +import { + EditSAMLConfigurationForm, + samlConfigurationFormQuery, +} from "./_components/EditSAMLConfigurationForm"; +import type { EditSAMLConfigurationFormQuery } from "./_components/__generated__/EditSAMLConfigurationFormQuery.graphql"; +import { SAMLDomainVerifyDialog } from "./_components/SAMLDomainVerifyDialog"; + +export const samlSettingsPageQuery = graphql` + query SAMLSettingsPageQuery($organizationId: ID!) { + organization: node(id: $organizationId) @required(action: THROW) { + ...SAMLConfigurationListFragment + } + } +`; + +export function SAMLSettingsPage(props: { + queryRef: PreloadedQuery; +}) { + const { queryRef } = props; + + const formDialogRef = useDialogRef(); + const domainDialogRef = useDialogRef(); + const [isEditing, setIsEditing] = useState(); + const [domainVerificationToken, setDomainVerificationToken] = + useState(); + + const { __ } = useTranslate(); + const { isAuthorized } = use(PermissionsContext); + + const { organization } = usePreloadedQuery(samlSettingsPageQuery, queryRef); + const [formQueryRef, loadFormQuery] = + useQueryLoader(samlConfigurationFormQuery); + + const handleOpenFormDialog = (samlConfigurationId?: string) => { + setIsEditing(!!samlConfigurationId); + if (samlConfigurationId) { + loadFormQuery({ samlConfigurationId }, { fetchPolicy: "network-only" }); + } + formDialogRef.current?.open(); + }; + const handleCloseFormDialog = () => { + setIsEditing(false); + formDialogRef.current?.close(); + }; + + const handleOpenVerifyDomainDialog = (domainVerificationToken: string) => { + setDomainVerificationToken(domainVerificationToken); + domainDialogRef.current?.open(); + }; + const handleCloseVerifyDomainDialog = () => { + setDomainVerificationToken(""); + formDialogRef.current?.close(); + }; + + return ( + <> +
+
+

{__("SAML Single Sign-On")}

+ {isAuthorized("Organization", "createSAMLConfiguration") && ( + + )} +
+ + handleOpenFormDialog(id)} + onVerifyDomain={handleOpenVerifyDomainDialog} + /> +
+ + } + > + {isEditing ? ( + + {formQueryRef && ( + + )} + + ) : ( + + )} + + + + } + > + {domainVerificationToken && ( + + )} + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPageLoader.tsx b/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPageLoader.tsx new file mode 100644 index 000000000..282dae6de --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/SAMLSettingsPageLoader.tsx @@ -0,0 +1,33 @@ +import { useQueryLoader } from "react-relay"; +import { SAMLSettingsPage, samlSettingsPageQuery } from "./SAMLSettingsPage"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; +import { useEffect } from "react"; +import type { SAMLSettingsPageQuery } from "./__generated__/SAMLSettingsPageQuery.graphql"; + +function SAMLSettingsPageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = useQueryLoader( + samlSettingsPageQuery, + ); + + useEffect(() => { + loadQuery({ + organizationId, + }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return null; + } + + return ; +} + +export default function () { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/__generated__/SAMLSettingsPageQuery.graphql.ts b/apps/console/src/pages/iam/organizations/settings/__generated__/SAMLSettingsPageQuery.graphql.ts new file mode 100644 index 000000000..1eb6e5228 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/__generated__/SAMLSettingsPageQuery.graphql.ts @@ -0,0 +1,245 @@ +/** + * @generated SignedSource<<57eb2489b4c62cb4b1df1a61bc135313>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type SAMLSettingsPageQuery$variables = { + organizationId: string; +}; +export type SAMLSettingsPageQuery$data = { + readonly organization: { + readonly " $fragmentSpreads": FragmentRefs<"SAMLConfigurationListFragment">; + }; +}; +export type SAMLSettingsPageQuery = { + response: SAMLSettingsPageQuery$data; + variables: SAMLSettingsPageQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v3 = [ + { + "kind": "Literal", + "name": "first", + "value": 1000 + } +], +v4 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "SAMLSettingsPageQuery", + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "SAMLConfigurationListFragment" + } + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "SAMLSettingsPageQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": (v3/*: any*/), + "concreteType": "SAMLConfigurationConnection", + "kind": "LinkedField", + "name": "samlConfigurations", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "SAMLConfigurationEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "SAMLConfiguration", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v4/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "emailDomain", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "enforcementPolicy", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "domainVerificationToken", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "domainVerifiedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "testLoginUrl", + "storageKey": null + }, + (v2/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "samlConfigurations(first:1000)" + }, + { + "alias": null, + "args": (v3/*: any*/), + "filters": null, + "handle": "connection", + "key": "SAMLConfigurationListFragment_samlConfigurations", + "kind": "LinkedHandle", + "name": "samlConfigurations" + } + ], + "type": "Organization", + "abstractKey": null + }, + (v4/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "45740d8e7e6fba1ec2576fd62487e41e", + "id": null, + "metadata": {}, + "name": "SAMLSettingsPageQuery", + "operationKind": "query", + "text": "query SAMLSettingsPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ...SAMLConfigurationListFragment\n id\n }\n}\n\nfragment SAMLConfigurationListFragment on Organization {\n samlConfigurations(first: 1000) {\n edges {\n node {\n id\n emailDomain\n enforcementPolicy\n domainVerificationToken\n domainVerifiedAt\n testLoginUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "d2d6ef2a90f81a75df7ab0dd83ef52cf"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/EditSAMLConfigurationForm.tsx b/apps/console/src/pages/iam/organizations/settings/_components/EditSAMLConfigurationForm.tsx new file mode 100644 index 000000000..f5e3615a4 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/EditSAMLConfigurationForm.tsx @@ -0,0 +1,111 @@ +import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; +import { graphql } from "relay-runtime"; +import type { EditSAMLConfigurationForm_updateMutation } from "./__generated__/EditSAMLConfigurationForm_updateMutation.graphql"; +import { useCallback } from "react"; +import type { EditSAMLConfigurationFormQuery } from "./__generated__/EditSAMLConfigurationFormQuery.graphql"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { + SAMLConfigurationForm, + type SAMLConfigurationFormData, +} from "./SAMLConfigurationForm"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; + +export const samlConfigurationFormQuery = graphql` + query EditSAMLConfigurationFormQuery($samlConfigurationId: ID!) { + samlConfiguration: node(id: $samlConfigurationId) @required(action: THROW) { + __typename + ... on SAMLConfiguration { + id + emailDomain + enforcementPolicy + domainVerificationToken + domainVerifiedAt + testLoginUrl + idpEntityId + idpSsoUrl + idpCertificate + attributeMappings { + email + firstName + lastName + role + } + autoSignupEnabled + } + } + } +`; + +const updateSAMLConfigurationMutation = graphql` + mutation EditSAMLConfigurationForm_updateMutation( + $input: UpdateSAMLConfigurationInput! + ) { + updateSAMLConfiguration(input: $input) { + samlConfiguration { + id + emailDomain + enforcementPolicy + domainVerificationToken + domainVerifiedAt + testLoginUrl + } + } + } +`; + +export function EditSAMLConfigurationForm(props: { + onUpdate: () => void; + queryRef: PreloadedQuery; +}) { + const { onUpdate, queryRef } = props; + + const organizationId = useOrganizationId(); + + const { samlConfiguration } = + usePreloadedQuery( + samlConfigurationFormQuery, + queryRef, + ); + if (samlConfiguration.__typename !== "SAMLConfiguration") { + throw new Error("node is not a SAML configuration"); + } + + const [update, isUpdating] = + useMutationWithToasts( + updateSAMLConfigurationMutation, + { + successMessage: "SAML configuration updated successfully.", + errorMessage: "Failed to update SAML configuration. Please try again.", + }, + ); + + const handleUpdate = useCallback( + (data: SAMLConfigurationFormData) => { + update({ + variables: { + input: { + samlConfigurationId: samlConfiguration.id, + organizationId, + idpEntityId: data.idpEntityId, + idpSsoUrl: data.idpSsoUrl, + idpCertificate: data.idpCertificate, + autoSignupEnabled: data.autoSignupEnabled, + enforcementPolicy: data.enforcementPolicy, + attributeMappings: data.attributeMappings, + }, + }, + onCompleted: onUpdate, + }); + }, + [onUpdate, organizationId, samlConfiguration.id, update], + ); + + return ( + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/NewSAMLConfigurationForm.tsx b/apps/console/src/pages/iam/organizations/settings/_components/NewSAMLConfigurationForm.tsx new file mode 100644 index 000000000..24637ea56 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/NewSAMLConfigurationForm.tsx @@ -0,0 +1,73 @@ +import { ConnectionHandler, graphql } from "react-relay"; +import type { NewSAMLConfigurationForm_createMutation } from "./__generated__/NewSAMLConfigurationForm_createMutation.graphql"; +import { + SAMLConfigurationForm, + type SAMLConfigurationFormData, +} from "./SAMLConfigurationForm"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { useCallback } from "react"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; + +const createSAMLConfigurationMutation = graphql` + mutation NewSAMLConfigurationForm_createMutation( + $input: CreateSAMLConfigurationInput! + $connections: [ID!]! + ) { + createSAMLConfiguration(input: $input) { + samlConfigurationEdge @prependEdge(connections: $connections) { + node { + id + emailDomain + enforcementPolicy + domainVerificationToken + domainVerifiedAt + testLoginUrl + } + } + } + } +`; + +export function NewSAMLConfigurationForm(props: { onCreate: () => void }) { + const { onCreate } = props; + const organizationId = useOrganizationId(); + + const [create, isCreating] = + useMutationWithToasts( + createSAMLConfigurationMutation, + { + successMessage: "SAML configuration created successfully.", + errorMessage: "Failed to create SAML configuration", + }, + ); + + const handleCreate = useCallback( + (data: SAMLConfigurationFormData) => { + const connectionID = ConnectionHandler.getConnectionID( + organizationId, + "SAMLConfigurationListFragment_samlConfigurations", + ); + + create({ + variables: { + input: { + organizationId, + emailDomain: data.emailDomain, + idpEntityId: data.idpEntityId, + idpSsoUrl: data.idpSsoUrl, + idpCertificate: data.idpCertificate, + autoSignupEnabled: data.autoSignupEnabled, + attributeMappings: data.attributeMappings, + }, + connections: [connectionID], + }, + onCompleted: onCreate, + }); + }, + [organizationId, create, onCreate], + ); + + return ( + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/SAMLConfigurationForm.tsx b/apps/console/src/pages/iam/organizations/settings/_components/SAMLConfigurationForm.tsx new file mode 100644 index 000000000..8d8e79c6c --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/SAMLConfigurationForm.tsx @@ -0,0 +1,255 @@ +import { useTranslate } from "@probo/i18n"; +import { + Button, + Checkbox, + DialogContent, + DialogFooter, + Field, + Label, + Option, + Select, + Textarea, +} from "@probo/ui"; +import { Controller } from "react-hook-form"; +import z from "zod"; +import { useFormWithSchema } from "/hooks/useFormWithSchema"; + +const defaultValues: SAMLConfigurationFormData = { + emailDomain: "", + enforcementPolicy: "OPTIONAL" as const, + idpEntityId: "", + idpSsoUrl: "", + idpCertificate: "", + attributeMappings: { + email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + firstName: + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + lastName: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", + role: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role", + }, + autoSignupEnabled: false, +}; + +const getEnforcementPolicyLabel = ( + policy: string, + __: (key: string) => string, +) => { + switch (policy) { + case "OFF": + return __( + "Your team members can't use single sign-on and must use their password", + ); + case "REQUIRED": + return __("Your team members must use single sign-on to log in"); + case "OPTIONAL": + default: + return __( + "Your team members may use either single sign-on or their password to log in", + ); + } +}; + +const samlConfigSchema = z.object({ + emailDomain: z + .string() + .min(1, "Email domain is required") + .regex( + /^[a-z0-9.-]+\.[a-z]{2,}$/i, + "Must be a valid domain (e.g., example.com)", + ), + enforcementPolicy: z.enum(["OFF", "OPTIONAL", "REQUIRED"]), + spCertificate: z.string().optional(), + spPrivateKey: z.string().optional(), + idpEntityId: z.string().min(1, "IdP Entity ID is required"), + idpSsoUrl: z.string().url("IdP SSO URL must be a valid URL"), + idpCertificate: z.string().min(1, "IdP Certificate is required"), + attributeMappings: z.object({ + email: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + role: z.string().optional(), + }), + autoSignupEnabled: z.boolean().default(false), +}); + +export type SAMLConfigurationFormData = z.infer; + +export function SAMLConfigurationForm(props: { + isEditing?: boolean; + disabled: boolean; + initialValues?: SAMLConfigurationFormData; + onSubmit: (data: SAMLConfigurationFormData) => void; +}) { + const { + disabled, + initialValues = defaultValues, + isEditing, + onSubmit, + } = props; + const { __ } = useTranslate(); + + const form = useFormWithSchema(samlConfigSchema, { + defaultValues: initialValues, + }); + + return ( +
{ + e.preventDefault(); + await form.handleSubmit(onSubmit)(); + form.reset(form.getValues()); + }} + > + +
+

+ {__("Basic Configuration")} +

+
+
+ +

+ {isEditing + ? __("Email domain cannot be changed after creation") + : __( + "The email domain this SAML configuration applies to (e.g., example.com)", + )} +

+
+ {isEditing && ( +
+ + ( +
+ +
+ )} + /> + {form.watch("enforcementPolicy") && ( +

+ {getEnforcementPolicyLabel( + form.watch("enforcementPolicy"), + __, + )} +

+ )} +
+ )} +
+
+ +
+

+ {__("Identity Provider Configuration")} +

+
+ + +
+ +