From 884deff4d99d2aa51d07e08f7d1ca6b085ee77ea Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 23 Sep 2024 19:33:20 -0300 Subject: [PATCH] feat: disable adding additional identifiers when saml connection with flag is active --- .changeset/fresh-forks-talk.md | 7 + packages/backend/src/api/resources/JSON.ts | 15 ++ .../backend/src/api/resources/SamlAccount.ts | 3 + .../src/api/resources/SamlConnection.ts | 31 +++- packages/clerk-js/bundlewatch.config.json | 2 +- .../src/core/resources/SamlAccount.ts | 51 ++++++- .../ui/components/UserProfile/AccountPage.tsx | 15 +- .../UserProfile/ConnectedAccountsSection.tsx | 70 ++++----- .../components/UserProfile/EmailsSection.tsx | 30 ++-- .../components/UserProfile/PhoneSection.tsx | 35 +++-- .../ui/components/UserProfile/Web3Section.tsx | 139 +++++++++--------- .../__tests__/AccountPage.test.tsx | 86 +++++++++++ packages/types/src/index.ts | 1 + packages/types/src/json.ts | 15 ++ packages/types/src/samlAccount.ts | 2 + packages/types/src/samlConnection.ts | 15 ++ 16 files changed, 382 insertions(+), 135 deletions(-) create mode 100644 .changeset/fresh-forks-talk.md create mode 100644 packages/types/src/samlConnection.ts diff --git a/.changeset/fresh-forks-talk.md b/.changeset/fresh-forks-talk.md new file mode 100644 index 00000000000..7b63f2b72d5 --- /dev/null +++ b/.changeset/fresh-forks-talk.md @@ -0,0 +1,7 @@ +--- +"@clerk/clerk-js": patch +"@clerk/backend": patch +"@clerk/types": patch +--- + +Conditionally renders identification sections on `UserProfile` based on the SAML connection configuration for disabling additional identifiers. diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index fbb3fcf57be..7b769f4495e 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -114,6 +114,7 @@ export interface SamlAccountJSON extends ClerkResourceJSON { first_name: string; last_name: string; verification: VerificationJSON | null; + saml_connection: SamlAccountConnectionJSON | null; } export interface IdentificationLinkJSON extends ClerkResourceJSON { @@ -399,3 +400,17 @@ export interface PermissionJSON extends ClerkResourceJSON { created_at: number; updated_at: number; } + +export interface SamlAccountConnectionJSON extends ClerkResourceJSON { + id: string; + name: string; + domain: string; + active: boolean; + provider: string; + sync_user_attributes: boolean; + allow_subdomains: boolean; + allow_idp_initiated: boolean; + disable_additional_identifications: boolean; + created_at: number; + updated_at: number; +} diff --git a/packages/backend/src/api/resources/SamlAccount.ts b/packages/backend/src/api/resources/SamlAccount.ts index 1c4a06f5a78..ca74d751954 100644 --- a/packages/backend/src/api/resources/SamlAccount.ts +++ b/packages/backend/src/api/resources/SamlAccount.ts @@ -1,4 +1,5 @@ import type { SamlAccountJSON } from './JSON'; +import { SamlAccountConnection } from './SamlConnection'; import { Verification } from './Verification'; export class SamlAccount { @@ -11,6 +12,7 @@ export class SamlAccount { readonly firstName: string, readonly lastName: string, readonly verification: Verification | null, + readonly samlConnection: SamlAccountConnection | null, ) {} static fromJSON(data: SamlAccountJSON): SamlAccount { @@ -23,6 +25,7 @@ export class SamlAccount { data.first_name, data.last_name, data.verification && Verification.fromJSON(data.verification), + data.saml_connection && SamlAccountConnection.fromJSON(data.saml_connection), ); } } diff --git a/packages/backend/src/api/resources/SamlConnection.ts b/packages/backend/src/api/resources/SamlConnection.ts index 90695beed50..d32f0495f48 100644 --- a/packages/backend/src/api/resources/SamlConnection.ts +++ b/packages/backend/src/api/resources/SamlConnection.ts @@ -1,4 +1,4 @@ -import type { AttributeMappingJSON, SamlConnectionJSON } from './JSON'; +import type { AttributeMappingJSON, SamlAccountConnectionJSON, SamlConnectionJSON } from './JSON'; export class SamlConnection { constructor( @@ -49,6 +49,35 @@ export class SamlConnection { } } +export class SamlAccountConnection { + constructor( + readonly id: string, + readonly name: string, + readonly domain: string, + readonly active: boolean, + readonly provider: string, + readonly syncUserAttributes: boolean, + readonly allowSubdomains: boolean, + readonly allowIdpInitiated: boolean, + readonly createdAt: number, + readonly updatedAt: number, + ) {} + static fromJSON(data: SamlAccountConnectionJSON): SamlAccountConnection { + return new SamlAccountConnection( + data.id, + data.name, + data.domain, + data.active, + data.provider, + data.sync_user_attributes, + data.allow_subdomains, + data.allow_idp_initiated, + data.created_at, + data.updated_at, + ); + } +} + class AttributeMapping { constructor( readonly userId: string, diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f0f2208f716..6e8b142720e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.browser.js", "maxSize": "64.1kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "65kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "43kB" }, { "path": "./dist/ui-common*.js", "maxSize": "86KB" }, { "path": "./dist/vendors*.js", "maxSize": "70KB" }, diff --git a/packages/clerk-js/src/core/resources/SamlAccount.ts b/packages/clerk-js/src/core/resources/SamlAccount.ts index 2b7988c20ea..8a01897d3bb 100644 --- a/packages/clerk-js/src/core/resources/SamlAccount.ts +++ b/packages/clerk-js/src/core/resources/SamlAccount.ts @@ -1,5 +1,13 @@ -import type { SamlAccountJSON, SamlAccountResource, SamlIdpSlug, VerificationResource } from '@clerk/types'; +import type { + SamlAccountConnectionJSON, + SamlAccountConnectionResource, + SamlAccountJSON, + SamlAccountResource, + SamlIdpSlug, + VerificationResource, +} from '@clerk/types'; +import { unixEpochToDate } from '../../utils/date'; import { BaseResource } from './Base'; import { Verification } from './Verification'; @@ -12,6 +20,7 @@ export class SamlAccount extends BaseResource implements SamlAccountResource { firstName = ''; lastName = ''; verification: VerificationResource | null = null; + samlConnection: SamlAccountConnectionResource | null = null; public constructor(data: Partial, pathRoot: string); public constructor(data: SamlAccountJSON, pathRoot: string) { @@ -37,6 +46,46 @@ export class SamlAccount extends BaseResource implements SamlAccountResource { this.verification = new Verification(data.verification); } + if (data.saml_connection) { + this.samlConnection = new SamlAccountConnection(data.saml_connection); + } + + return this; + } +} + +export class SamlAccountConnection extends BaseResource implements SamlAccountConnectionResource { + id!: string; + name!: string; + domain!: string; + active!: boolean; + provider!: string; + syncUserAttributes!: boolean; + allowSubdomains!: boolean; + allowIdpInitiated!: boolean; + disableAdditionalIdentifications!: boolean; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: SamlAccountConnectionJSON | null) { + super(); + this.fromJSON(data); + } + protected fromJSON(data: SamlAccountConnectionJSON | null): this { + if (data) { + this.id = data.id; + this.name = data.name; + this.domain = data.domain; + this.active = data.active; + this.provider = data.provider; + this.syncUserAttributes = data.sync_user_attributes; + this.allowSubdomains = data.allow_subdomains; + this.allowIdpInitiated = data.allow_idp_initiated; + this.disableAdditionalIdentifications = data.disable_additional_identifications; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + } + return this; } } diff --git a/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx index d9ce6b1c6dd..7b4d57d5a4a 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx @@ -15,6 +15,7 @@ export const AccountPage = withCardStateProvider(() => { const { attributes, saml, social } = useEnvironment().userSettings; const card = useCardState(); const { user } = useUser(); + const showUsername = attributes.username.enabled; const showEmail = attributes.email_address.enabled; const showPhone = attributes.phone_number.enabled; @@ -22,6 +23,12 @@ export const AccountPage = withCardStateProvider(() => { const showSamlAccounts = saml && saml.enabled && user && user.samlAccounts.length > 0; const showWeb3 = attributes.web3_wallet.enabled; + const shouldAllowIdentificationCreation = + !showSamlAccounts || + !user?.samlAccounts?.some( + samlAccount => samlAccount.active && samlAccount.samlConnection?.disableAdditionalIdentifications, + ); + return ( { {showUsername && } - {showEmail && } - {showPhone && } - {showConnectedAccounts && } + {showEmail && } + {showPhone && } + {showConnectedAccounts && } {showSamlAccounts && } - {showWeb3 && } + {showWeb3 && } ); diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx index ba53696dc28..5bb00d36c07 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx @@ -46,41 +46,43 @@ const errorCodesForReconnect = [ 'external_account_email_address_verification_required', ]; -export const ConnectedAccountsSection = withCardStateProvider(() => { - const { user } = useUser(); - const card = useCardState(); - - if (!user) { - return null; - } - - const accounts = [ - ...user.verifiedExternalAccounts, - ...user.unverifiedExternalAccounts.filter(a => a.verification?.error), - ]; +export const ConnectedAccountsSection = withCardStateProvider( + ({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => { + const { user } = useUser(); + const card = useCardState(); + const hasExternalAccounts = Boolean(user?.externalAccounts?.length); + + if (!user || (!shouldAllowCreation && !hasExternalAccounts)) { + return null; + } - return ( - - {card.error} - - - {accounts.map(account => ( - - ))} - - - - - - ); -}); + const accounts = [ + ...user.verifiedExternalAccounts, + ...user.unverifiedExternalAccounts.filter(a => a.verification?.error), + ]; + + return ( + + {card.error} + + + {accounts.map(account => ( + + ))} + + {shouldAllowCreation && } + + + ); + }, +); const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => { const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx index 37cafecaf5e..3f4558e7178 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx @@ -35,7 +35,7 @@ const EmailScreen = (props: EmailScreenProps) => { ); }; -export const EmailsSection = () => { +export const EmailsSection = ({ shouldAllowCreation = true }) => { const { user } = useUser(); return ( @@ -79,19 +79,21 @@ export const EmailsSection = () => { ))} - - - - - - - - - - + {shouldAllowCreation && ( + <> + + + + + + + + + + )} diff --git a/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx index 2308d68e437..8759237044d 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PhoneSection.tsx @@ -35,8 +35,13 @@ const PhoneScreen = (props: PhoneScreenProps) => { ); }; -export const PhoneSection = () => { +export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => { const { user } = useUser(); + const hasPhoneNumbers = Boolean(user?.phoneNumbers?.length); + + if (!shouldAllowCreation && !hasPhoneNumbers) { + return null; + } return ( { ))} - - - - - - - - - - + {shouldAllowCreation && ( + <> + + + + + + + + + + )} diff --git a/packages/clerk-js/src/ui/components/UserProfile/Web3Section.tsx b/packages/clerk-js/src/ui/components/UserProfile/Web3Section.tsx index cd1444a94f1..492bef55c29 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/Web3Section.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/Web3Section.tsx @@ -28,75 +28,82 @@ const shortenWeb3Address = (address: string) => { return address.slice(0, 6) + '...' + address.slice(-4); }; -export const Web3Section = withCardStateProvider(() => { - const { user } = useUser(); - const card = useCardState(); - const { strategyToDisplayData } = useEnabledThirdPartyProviders(); +export const Web3Section = withCardStateProvider( + ({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => { + const { user } = useUser(); + const card = useCardState(); + const { strategyToDisplayData } = useEnabledThirdPartyProviders(); + const hasWeb3Wallets = Boolean(user?.web3Wallets?.length); - return ( - - {card.error} - - - {user?.web3Wallets.map(wallet => { - const strategy = wallet.verification.strategy as keyof typeof strategyToDisplayData; + if (!shouldAllowCreation && !hasWeb3Wallets) { + return null; + } - return ( - strategyToDisplayData[strategy] && ( - - - - ({ alignItems: 'center', gap: t.space.$2, width: '100%' })}> - {strategyToDisplayData[strategy].iconUrl && ( - {strategyToDisplayData[strategy].name} ({ width: theme.sizes.$4 })} - /> - )} - - - - {strategyToDisplayData[strategy].name} ({shortenWeb3Address(wallet.web3Wallet)}) - - {user?.primaryWeb3WalletId === wallet.id && ( - - )} - {wallet.verification.status !== 'verified' && ( - - )} - - - - - - + return ( + + {card.error} + + + {user?.web3Wallets.map(wallet => { + const strategy = wallet.verification.strategy as keyof typeof strategyToDisplayData; - - - - - - - ) - ); - })} - - - - - ); -}); + return ( + strategyToDisplayData[strategy] && ( + + + + ({ alignItems: 'center', gap: t.space.$2, width: '100%' })}> + {strategyToDisplayData[strategy].iconUrl && ( + {strategyToDisplayData[strategy].name} ({ width: theme.sizes.$4 })} + /> + )} + + + + {strategyToDisplayData[strategy].name} ({shortenWeb3Address(wallet.web3Wallet)}) + + {user?.primaryWeb3WalletId === wallet.id && ( + + )} + {wallet.verification.status !== 'verified' && ( + + )} + + + + + + + + + + + + + + ) + ); + })} + + {shouldAllowCreation && } + + + ); + }, +); const Web3WalletMenu = () => { const { open } = useActionContext(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/AccountPage.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/AccountPage.test.tsx index 4435d68f8ae..65e9e3e1e9f 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/AccountPage.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/AccountPage.test.tsx @@ -1,3 +1,4 @@ +import type { SamlAccountJSON } from '@clerk/types'; import { describe, it } from '@jest/globals'; import { render, screen, waitFor } from '../../../../testUtils'; @@ -110,5 +111,90 @@ describe('AccountPage', () => { screen.getByText(/Enterprise Accounts/i); screen.getByText(/Okta Workforce/i); }); + + describe('with `disable_additional_identifications`', () => { + const emailAddress = 'george@jungle.com'; + const phoneNumber = '+301234567890'; + const firstName = 'George'; + const lastName = 'Clerk'; + + const samlAccount: SamlAccountJSON = { + id: 'samlacc_foo', + provider: 'saml_okta', + email_address: emailAddress, + first_name: firstName, + last_name: lastName, + saml_connection: { + id: 'samlc_foo', + active: true, + disable_additional_identifications: true, + allow_idp_initiated: true, + allow_subdomains: true, + domain: 'foo.com', + name: 'Foo', + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + object: 'saml_connection', + provider: 'saml_okta', + sync_user_attributes: true, + }, + active: true, + object: 'saml_account', + provider_user_id: '', + }; + + it('shows only the enterprise accounts of the user', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withSocialProvider({ provider: 'google' }); + f.withSaml(); + f.withUser({ + email_addresses: [emailAddress], + saml_accounts: [samlAccount], + first_name: firstName, + last_name: lastName, + }); + }); + + render(, { wrapper }); + + expect(screen.queryByText(/Add email address/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Phone numbers/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Connected Accounts/i)).not.toBeInTheDocument(); + screen.getByText(/Enterprise Accounts/i); + screen.getByText(/Okta Workforce/i); + }); + + it('shows the enterprise accounts of the user, and the other sections, but hides the add button', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withSocialProvider({ provider: 'google' }); + f.withSaml(); + f.withUser({ + email_addresses: [emailAddress], + phone_numbers: [phoneNumber], + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + saml_accounts: [samlAccount], + first_name: firstName, + last_name: lastName, + }); + }); + + render(, { wrapper }); + + screen.getByText(/Email addresses/i); + screen.getByText(/Phone numbers/i); + screen.getByText(/Connected Accounts/i); + screen.getByText(/Enterprise Accounts/i); + screen.getByText(/Okta Workforce/i); + + // Add buttons should be hidden + expect(screen.queryByText(/Add email address/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Add phone number/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Connect account/i)).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 547ac4ae763..482d807e42d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -59,3 +59,4 @@ export * from './customPages'; export * from './pagination'; export * from './passkey'; export * from './customMenuItems'; +export * from './samlConnection'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index f01d00870df..3d0f1424b60 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -199,6 +199,7 @@ export interface SamlAccountJSON extends ClerkResourceJSON { first_name: string; last_name: string; verification?: VerificationJSON; + saml_connection?: SamlAccountConnectionJSON; } export interface UserJSON extends ClerkResourceJSON { @@ -512,3 +513,17 @@ export interface PublicKeyCredentialRequestOptionsJSON { timeout: number; userVerification: 'discouraged' | 'preferred' | 'required'; } + +export interface SamlAccountConnectionJSON extends ClerkResourceJSON { + id: string; + name: string; + domain: string; + active: boolean; + provider: string; + sync_user_attributes: boolean; + allow_subdomains: boolean; + allow_idp_initiated: boolean; + disable_additional_identifications: boolean; + created_at: number; + updated_at: number; +} diff --git a/packages/types/src/samlAccount.ts b/packages/types/src/samlAccount.ts index 5a1fff291c6..432bbd53d3c 100644 --- a/packages/types/src/samlAccount.ts +++ b/packages/types/src/samlAccount.ts @@ -1,5 +1,6 @@ import type { ClerkResource } from './resource'; import type { SamlIdpSlug } from './saml'; +import type { SamlAccountConnectionResource } from './samlConnection'; import type { VerificationResource } from './verification'; export interface SamlAccountResource extends ClerkResource { @@ -10,4 +11,5 @@ export interface SamlAccountResource extends ClerkResource { firstName: string; lastName: string; verification: VerificationResource | null; + samlConnection: SamlAccountConnectionResource | null; } diff --git a/packages/types/src/samlConnection.ts b/packages/types/src/samlConnection.ts new file mode 100644 index 00000000000..f3b693e3fbf --- /dev/null +++ b/packages/types/src/samlConnection.ts @@ -0,0 +1,15 @@ +import type { ClerkResource } from './resource'; + +export interface SamlAccountConnectionResource extends ClerkResource { + id: string; + name: string; + domain: string; + active: boolean; + provider: string; + syncUserAttributes: boolean; + allowSubdomains: boolean; + allowIdpInitiated: boolean; + disableAdditionalIdentifications: boolean; + createdAt: Date; + updatedAt: Date; +}