diff --git a/.changeset/cyan-shirts-prove.md b/.changeset/cyan-shirts-prove.md new file mode 100644 index 0000000000..294a45fb0f --- /dev/null +++ b/.changeset/cyan-shirts-prove.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Surface enterprise accounts in `UserProfile`, allowing to display more protocols besides SAML diff --git a/packages/clerk-js/src/core/resources/EnterpriseAccount.ts b/packages/clerk-js/src/core/resources/EnterpriseAccount.ts new file mode 100644 index 0000000000..3e15da29d5 --- /dev/null +++ b/packages/clerk-js/src/core/resources/EnterpriseAccount.ts @@ -0,0 +1,98 @@ +import type { + EnterpriseAccountConnectionJSON, + EnterpriseAccountConnectionResource, + EnterpriseAccountJSON, + EnterpriseAccountResource, + VerificationResource, +} from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './Base'; +import { Verification } from './Verification'; + +export class EnterpriseAccount extends BaseResource implements EnterpriseAccountResource { + id!: string; + protocol!: EnterpriseAccountResource['protocol']; + provider!: EnterpriseAccountResource['provider']; + providerUserId: string | null = null; + active!: boolean; + emailAddress = ''; + firstName: string | null = ''; + lastName: string | null = ''; + publicMetadata = {}; + verification: VerificationResource | null = null; + enterpriseConnection: EnterpriseAccountConnectionResource | null = null; + + public constructor(data: Partial, pathRoot: string); + public constructor(data: EnterpriseAccountJSON, pathRoot: string) { + super(); + this.pathRoot = pathRoot; + this.fromJSON(data); + } + + protected fromJSON(data: EnterpriseAccountJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.provider = data.provider; + this.protocol = data.protocol; + this.providerUserId = data.provider_user_id; + this.active = data.active; + this.emailAddress = data.email_address; + this.firstName = data.first_name; + this.lastName = data.last_name; + this.publicMetadata = data.public_metadata; + + if (data.verification) { + this.verification = new Verification(data.verification); + } + + if (data.enterprise_connection) { + this.enterpriseConnection = new EnterpriseAccountConnection(data.enterprise_connection); + } + + return this; + } +} + +export class EnterpriseAccountConnection extends BaseResource implements EnterpriseAccountConnectionResource { + id!: string; + active!: boolean; + allowIdpInitiated!: boolean; + allowSubdomains!: boolean; + disableAdditionalIdentifications!: boolean; + domain!: string; + logoPublicUrl: string | null = ''; + name!: string; + protocol!: EnterpriseAccountResource['protocol']; + provider!: EnterpriseAccountResource['provider']; + syncUserAttributes!: boolean; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: EnterpriseAccountConnectionJSON | null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: EnterpriseAccountConnectionJSON | 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.logoPublicUrl = data.logo_public_url; + 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/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index c46c4180c2..a42e14b4d0 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -8,6 +8,7 @@ import type { DeletedObjectJSON, DeletedObjectResource, EmailAddressResource, + EnterpriseAccountResource, ExternalAccountJSON, ExternalAccountResource, GetOrganizationMemberships, @@ -38,6 +39,7 @@ import { BaseResource, DeletedObject, EmailAddress, + EnterpriseAccount, ExternalAccount, Image, OrganizationMembership, @@ -61,6 +63,7 @@ export class User extends BaseResource implements UserResource { phoneNumbers: PhoneNumberResource[] = []; web3Wallets: Web3WalletResource[] = []; externalAccounts: ExternalAccountResource[] = []; + enterpriseAccounts: EnterpriseAccountResource[] = []; passkeys: PasskeyResource[] = []; samlAccounts: SamlAccountResource[] = []; @@ -346,6 +349,10 @@ export class User extends BaseResource implements UserResource { this.samlAccounts = (data.saml_accounts || []).map(sa => new SamlAccount(sa, this.path() + '/saml_accounts')); + this.enterpriseAccounts = (data.enterprise_accounts || []).map( + ea => new EnterpriseAccount(ea, this.path() + '/enterprise_accounts'), + ); + this.publicMetadata = data.public_metadata; this.unsafeMetadata = data.unsafe_metadata; diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 9777cf01dd..860dd16786 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -9,6 +9,7 @@ export * from './EmailAddress'; export * from './Environment'; export * from './Error'; export * from './ExternalAccount'; +export * from './EnterpriseAccount'; export * from './IdentificationLink'; export * from './Image'; export * from './PhoneNumber'; diff --git a/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx index a4673fa9af..8588ed7a20 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx @@ -12,7 +12,7 @@ import { UserProfileSection } from './UserProfileSection'; import { Web3Section } from './Web3Section'; export const AccountPage = withCardStateProvider(() => { - const { attributes, saml, social } = useEnvironment().userSettings; + const { attributes, social } = useEnvironment().userSettings; const card = useCardState(); const { user } = useUser(); @@ -20,13 +20,14 @@ export const AccountPage = withCardStateProvider(() => { const showEmail = attributes.email_address.enabled; const showPhone = attributes.phone_number.enabled; const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0; - const showSamlAccounts = saml && saml.enabled && user && user.samlAccounts.length > 0; + const showEnterpriseAccounts = user && user.enterpriseAccounts.length > 0; const showWeb3 = attributes.web3_wallet.enabled; const shouldAllowIdentificationCreation = - !showSamlAccounts || - !user?.samlAccounts?.some( - samlAccount => samlAccount.active && samlAccount.samlConnection?.disableAdditionalIdentifications, + !showEnterpriseAccounts || + !user.enterpriseAccounts.some( + enterpriseAccount => + enterpriseAccount.active && enterpriseAccount.enterpriseConnection?.disableAdditionalIdentifications, ); return ( @@ -55,7 +56,7 @@ export const AccountPage = withCardStateProvider(() => { {showConnectedAccounts && } {/*TODO-STEP-UP: Verify that these work as expected*/} - {showSamlAccounts && } + {showEnterpriseAccounts && } {showWeb3 && } diff --git a/packages/clerk-js/src/ui/components/UserProfile/EnterpriseAccountsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/EnterpriseAccountsSection.tsx index 6c49a841ad..674e402d60 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EnterpriseAccountsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EnterpriseAccountsSection.tsx @@ -1,12 +1,21 @@ +import { iconImageUrl } from '@clerk/shared/constants'; import { useUser } from '@clerk/shared/react'; +import type { EnterpriseAccountResource, OAuthProvider } from '@clerk/types'; +import { ProviderInitialIcon } from '../../common'; import { Badge, Box, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables'; import { ProfileSection } from '../../elements'; -import { useSaml } from '../../hooks'; export const EnterpriseAccountsSection = () => { const { user } = useUser(); - const { getSamlProviderLogoUrl, getSamlProviderName } = useSaml(); + + const activeEnterpriseAccounts = user?.enterpriseAccounts.filter( + ({ enterpriseConnection }) => enterpriseConnection?.active, + ); + + if (!activeEnterpriseAccounts?.length) { + return null; + } return ( { centered={false} > - {user?.samlAccounts.map(account => { - const label = account.emailAddress; - const providerName = getSamlProviderName(account.provider); - const providerLogoUrl = getSamlProviderLogoUrl(account.provider); - const error = account.verification?.error?.longMessage; - - return ( - ({ - gap: t.space.$2, - justifyContent: 'start', - })} - key={account.id} - > - {providerName} ({ width: theme.sizes.$4 })} - /> - - - - {providerName} - - - {label ? `• ${label}` : ''} - - {error && ( - - )} - - - - ); - })} + {activeEnterpriseAccounts.map(account => ( + + ))} ); }; + +const EnterpriseAccount = ({ account }: { account: EnterpriseAccountResource }) => { + const label = account.emailAddress; + const connectionName = account?.enterpriseConnection?.name; + const error = account.verification?.error?.longMessage; + + return ( + ({ + gap: t.space.$2, + justifyContent: 'start', + })} + key={account.id} + > + + + + + {connectionName} + + + {label ? `• ${label}` : ''} + + {error && ( + + )} + + + + ); +}; + +const EnterpriseAccountProviderIcon = ({ account }: { account: EnterpriseAccountResource }) => { + const { provider, enterpriseConnection } = account; + + const isCustomOAuthProvider = provider.startsWith('oauth_custom_'); + const providerWithoutPrefix = provider.replace(/(oauth_|saml_)/, '').trim() as OAuthProvider; + const connectionName = enterpriseConnection?.name ?? providerWithoutPrefix; + + const commonImageProps = { + elementDescriptor: [descriptors.providerIcon], + alt: connectionName, + sx: (theme: any) => ({ width: theme.sizes.$4 }), + elementId: descriptors.enterpriseButtonsProviderIcon.setId(account.provider), + }; + + if (!isCustomOAuthProvider) { + return ( + + ); + } + + return enterpriseConnection?.logoPublicUrl ? ( + + ) : ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx index 2e1639e650..07f6fb7e2a 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx @@ -60,7 +60,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => : localizationKeys('userProfile.passwordPage.title__set'); const card = useCardState(); - const passwordEditDisabled = user.samlAccounts.some(sa => sa.active); + const passwordEditDisabled = user.enterpriseAccounts.some(ea => ea.active); // Ensure that messages will not use the updated state of User after a password has been set or changed const successPagePropsRef = useRef[0]>({ diff --git a/packages/clerk-js/src/ui/components/UserProfile/ProfileForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/ProfileForm.tsx index 16981171ed..a14d425422 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ProfileForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ProfileForm.tsx @@ -47,7 +47,7 @@ export const ProfileForm = withCardStateProvider((props: ProfileFormProps) => { const requiredFieldsFilled = hasRequiredFields && !!lastNameField.value && !!firstNameField.value && optionalFieldsChanged; - const nameEditDisabled = user.samlAccounts.some(sa => sa.active); + const nameEditDisabled = user.enterpriseAccounts.some(ea => ea.active); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); 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 65e9e3e1e9..c22ffc0637 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,4 +1,4 @@ -import type { SamlAccountJSON } from '@clerk/types'; +import type { EnterpriseAccountJSON } from '@clerk/types'; import { describe, it } from '@jest/globals'; import { render, screen, waitFor } from '../../../../testUtils'; @@ -83,33 +83,135 @@ describe('AccountPage', () => { screen.getByText(/google/i); }); - it('shows the enterprise accounts of the user', async () => { - const emailAddress = 'george@jungle.com'; - const firstName = 'George'; - const lastName = 'Clerk'; + describe('with active enterprise connection', () => { + it('shows the enterprise accounts of the user', async () => { + const emailAddress = 'george@jungle.com'; + const firstName = 'George'; + const lastName = 'Clerk'; - const { wrapper } = await createFixtures(f => { - f.withEmailAddress(); - f.withSaml(); - f.withUser({ - email_addresses: [emailAddress], - saml_accounts: [ - { - id: 'samlacc_foo', - provider: 'saml_okta', - email_address: emailAddress, - first_name: firstName, - last_name: lastName, - }, - ], - first_name: firstName, - last_name: lastName, + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withSaml(); + f.withUser({ + email_addresses: [emailAddress], + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + first_name: firstName, + last_name: lastName, + }); }); + + render(, { wrapper }); + screen.getByText(/Enterprise Accounts/i); + screen.getByText(/Okta Workforce/i); }); + }); - render(, { wrapper }); - screen.getByText(/Enterprise Accounts/i); - screen.getByText(/Okta Workforce/i); + describe('with inactive enterprise connection', () => { + it('does not show the enterprise accounts of the user', async () => { + const emailAddress = 'george@jungle.com'; + const firstName = 'George'; + const lastName = 'Clerk'; + + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + email_addresses: [emailAddress], + enterprise_accounts: [ + { + object: 'enterprise_account', + active: false, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: false, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + first_name: firstName, + last_name: lastName, + }); + }); + + render(, { wrapper }); + expect(screen.queryByText(/Enterprise Accounts/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Okta Workforce/i)).not.toBeInTheDocument(); + }); }); describe('with `disable_additional_identifications`', () => { @@ -118,29 +220,47 @@ describe('AccountPage', () => { const firstName = 'George'; const lastName = 'Clerk'; - const samlAccount: SamlAccountJSON = { - id: 'samlacc_foo', + const enterpriseAccount: EnterpriseAccountJSON = { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', provider: 'saml_okta', - email_address: emailAddress, - first_name: firstName, - last_name: lastName, - saml_connection: { - id: 'samlc_foo', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', active: true, + allow_idp_initiated: false, + allow_subdomains: false, 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, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', }, - active: true, - object: 'saml_account', - provider_user_id: '', + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }; it('shows only the enterprise accounts of the user', async () => { @@ -151,7 +271,7 @@ describe('AccountPage', () => { f.withSaml(); f.withUser({ email_addresses: [emailAddress], - saml_accounts: [samlAccount], + enterprise_accounts: [enterpriseAccount], first_name: firstName, last_name: lastName, }); @@ -176,7 +296,7 @@ describe('AccountPage', () => { email_addresses: [emailAddress], phone_numbers: [phoneNumber], external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], - saml_accounts: [samlAccount], + enterprise_accounts: [enterpriseAccount], first_name: firstName, last_name: lastName, }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx new file mode 100644 index 0000000000..0191ebafbd --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx @@ -0,0 +1,296 @@ +import { describe, it } from '@jest/globals'; +import React from 'react'; + +import { render } from '../../../../testUtils'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { EnterpriseAccountsSection } from '../EnterpriseAccountsSection'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +const withoutEnterpriseConnection = createFixtures.config(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + f.withUser({ + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); +}); + +const withInactiveEnterpriseConnection = createFixtures.config(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + f.withUser({ + enterprise_accounts: [ + { + object: 'enterprise_account', + active: false, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: false, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: 'https://img.clerk.com/static/okta.svg', + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); +}); + +const withOAuthBuiltInEnterpriseConnection = createFixtures.config(f => { + f.withUser({ + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'oauth', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'oauth_google', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'oauth_google', + name: 'Google', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: 'https://img.clerk.com/static/google.svg', + protocol: 'oauth', + }, + verification: { + status: 'verified', + strategy: 'oauth_google', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); +}); + +const withOAuthCustomEnterpriseConnection = (logoPublicUrl: string | null) => + createFixtures.config(f => { + f.withUser({ + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'oauth', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'oauth_custom_roblox', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'oauth_custom_roblox', + name: 'Roblox', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: logoPublicUrl, + protocol: 'oauth', + }, + verification: { + status: 'verified', + strategy: 'oauth_custom_roblox', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); + }); + +const withSamlEnterpriseConnection = createFixtures.config(f => { + f.withUser({ + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: 'https://img.clerk.com/static/okta.svg', + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); +}); + +describe('EnterpriseAccountsSection ', () => { + describe('without enterprise accounts', () => { + it('does not render the component', async () => { + const { wrapper } = await createFixtures(withoutEnterpriseConnection); + + const { queryByText } = render(, { wrapper }); + + expect(queryByText(/^Enterprise accounts/i)).not.toBeInTheDocument(); + }); + }); + + describe('with inactive enterprise accounts accounts', () => { + it('does not render the component', async () => { + const { wrapper } = await createFixtures(withInactiveEnterpriseConnection); + + const { queryByText } = render(, { wrapper }); + + expect(queryByText(/^Enterprise accounts/i)).not.toBeInTheDocument(); + }); + }); + + describe('with oauth built-in', () => { + it('renders connection', async () => { + const { wrapper } = await createFixtures(withOAuthBuiltInEnterpriseConnection); + + const { getByText, getByRole } = render(, { wrapper }); + + getByText(/^Enterprise accounts/i); + getByText(/google/i); + const img = getByRole('img', { name: /google/i }); + expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/google.svg?width=160'); + getByText(/test@clerk.com/i); + }); + }); + + describe('with oauth custom', () => { + describe('with logo', () => { + it('renders connection with logo', async () => { + const mockLogoUrl = 'https://mycdn.com/satic/foo.png'; + + const { wrapper } = await createFixtures(withOAuthCustomEnterpriseConnection(mockLogoUrl)); + + const { getByText, getByRole } = render(, { wrapper }); + + getByText(/^Enterprise accounts/i); + getByText(/roblox/i); + const img = getByRole('img', { name: /roblox/i }); + expect(img.getAttribute('src')).toContain(mockLogoUrl); + getByText(/test@clerk.com/i); + }); + }); + + describe('without logo', () => { + it('renders connection with initial icon', async () => { + const { wrapper } = await createFixtures(withOAuthCustomEnterpriseConnection(null)); + + const { getByText } = render(, { wrapper }); + + getByText(/^Enterprise accounts/i); + getByText(/roblox/i); + getByText('R', { exact: true }); + getByText(/test@clerk.com/i); + }); + }); + }); + + describe('with saml', () => { + it('renders connection', async () => { + const { wrapper } = await createFixtures(withSamlEnterpriseConnection); + + const { getByText, getByRole } = render(, { wrapper }); + + getByText(/^Enterprise accounts/i); + getByText(/okta workforce/i); + const img = getByRole('img', { name: /okta/i }); + expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/okta.svg?width=160'); + getByText(/test@clerk.com/i); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx index 59d99f13f2..a89d1f76bd 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx @@ -115,12 +115,48 @@ describe('PasswordSection', () => { f.withSaml(); f.withUser({ email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: true, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); @@ -152,12 +188,48 @@ describe('PasswordSection', () => { f.withSaml(); f.withUser({ email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: false, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: false, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); @@ -253,12 +325,48 @@ describe('PasswordSection', () => { f.withUser({ password_enabled: true, email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: true, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); @@ -291,12 +399,48 @@ describe('PasswordSection', () => { f.withUser({ password_enabled: true, email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: false, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: false, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfileSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfileSection.test.tsx index dd55e66a99..75e076ab97 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfileSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfileSection.test.tsx @@ -65,12 +65,48 @@ describe('ProfileSection', () => { first_name: firstName, last_name: lastName, email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: true, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); @@ -104,12 +140,48 @@ describe('ProfileSection', () => { first_name: firstName, last_name: lastName, email_addresses: [emailAddress], - saml_accounts: [ + enterprise_accounts: [ { - id: 'samlacc_foo', - provider: 'saml_okta', + object: 'enterprise_account', active: false, - email_address: emailAddress, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: false, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'saml', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', }, ], }); diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index c60bab033a..2f49c54046 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -55,6 +55,10 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'socialButtonsProviderInitialIcon', 'enterpriseButtonsProviderIcon', + + 'providerIcon', + 'providerInitialIcon', + 'alternativeMethods', 'alternativeMethodsBlockButton', 'alternativeMethodsBlockButtonText', @@ -271,7 +275,6 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'badge', 'notificationBadge', 'buttonArrowIcon', - 'providerIcon', 'spinner', ] as const).map(camelize) as (keyof ElementsConfig)[]; diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 06141362e1..3ebd8cfcb8 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -1,5 +1,4 @@ export * from './useDelayedVisibility'; -export * from './useSaml'; export * from './useWindowEventListener'; export * from './useEmailLink'; export * from './useClipboard'; diff --git a/packages/clerk-js/src/ui/hooks/useSaml.ts b/packages/clerk-js/src/ui/hooks/useSaml.ts deleted file mode 100644 index b5fb5aac1c..0000000000 --- a/packages/clerk-js/src/ui/hooks/useSaml.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { iconImageUrl } from '@clerk/shared/constants'; -import type { SamlIdpSlug } from '@clerk/types'; -import { SAML_IDPS } from '@clerk/types'; - -function getSamlProviderLogoUrl(provider: SamlIdpSlug = 'saml_custom'): string { - return iconImageUrl(SAML_IDPS[provider]?.logo); -} - -function getSamlProviderName(provider: SamlIdpSlug = 'saml_custom'): string { - return SAML_IDPS[provider]?.name; -} - -export const useSaml = () => { - return { - getSamlProviderLogoUrl, - getSamlProviderName, - }; -}; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index eed90e0fc9..ce8ccc7518 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -11,6 +11,7 @@ import type { SelectId, UserPreviewId, } from './elementIds'; +import type { EnterpriseProvider } from './enterpriseAccount'; import type { OAuthProvider } from './oauth'; import type { SamlIdpSlug } from './saml'; import type { BuiltInColors, TransparentColor } from './theme'; @@ -172,7 +173,10 @@ export type ElementsConfig = { socialButtonsProviderIcon: WithOptions; socialButtonsProviderInitialIcon: WithOptions; - enterpriseButtonsProviderIcon: WithOptions; + enterpriseButtonsProviderIcon: WithOptions; + + providerIcon: WithOptions; + providerInitialIcon: WithOptions; alternativeMethods: WithOptions; alternativeMethodsBlockButton: WithOptions; @@ -397,7 +401,6 @@ export type ElementsConfig = { badge: WithOptions<'primary' | 'actionRequired'>; notificationBadge: WithOptions; buttonArrowIcon: WithOptions; - providerIcon: WithOptions; spinner: WithOptions; }; diff --git a/packages/types/src/enterpriseAccount.ts b/packages/types/src/enterpriseAccount.ts new file mode 100644 index 0000000000..c2f5f0f3af --- /dev/null +++ b/packages/types/src/enterpriseAccount.ts @@ -0,0 +1,35 @@ +import type { OAuthProvider } from 'oauth'; +import type { SamlIdpSlug } from 'saml'; +import type { VerificationResource } from 'verification'; + +import type { ClerkResource } from './resource'; + +export type EnterpriseProtocol = 'saml' | 'oauth'; + +export type EnterpriseProvider = SamlIdpSlug | `oauth_${OAuthProvider}`; + +export interface EnterpriseAccountResource extends ClerkResource { + active: boolean; + emailAddress: string; + enterpriseConnection: EnterpriseAccountConnectionResource | null; + firstName: string | null; + lastName: string | null; + protocol: EnterpriseProtocol; + provider: EnterpriseProvider; + providerUserId: string | null; + publicMetadata: Record | null; + verification: VerificationResource | null; +} + +export interface EnterpriseAccountConnectionResource extends ClerkResource { + active: boolean; + allowIdpInitiated: boolean; + allowSubdomains: boolean; + disableAdditionalIdentifications: boolean; + domain: string; + logoPublicUrl: string | null; + name: string; + protocol: EnterpriseProtocol; + provider: EnterpriseProvider; + syncUserAttributes: boolean; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 95f8505e8a..108545cecc 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ export * from './displayConfig'; export * from './emailAddress'; export * from './environment'; export * from './externalAccount'; +export * from './enterpriseAccount'; export * from './factors'; export * from './identificationLink'; export * from './identifiers'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index ab3f4a6955..152ed6dd08 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -3,6 +3,7 @@ */ import type { DisplayConfigJSON } from './displayConfig'; +import type { EnterpriseProtocol, EnterpriseProvider } from './enterpriseAccount'; import type { ActJWTClaim } from './jwt'; import type { OAuthProvider } from './oauth'; import type { OrganizationDomainVerificationStatus, OrganizationEnrollmentMode } from './organizationDomain'; @@ -192,6 +193,35 @@ export interface ExternalAccountJSON extends ClerkResourceJSON { verification?: VerificationJSON; } +export interface EnterpriseAccountJSON extends ClerkResourceJSON { + object: 'enterprise_account'; + active: boolean; + email_address: string; + enterprise_connection: EnterpriseAccountConnectionJSON | null; + first_name: string | null; + last_name: string | null; + protocol: EnterpriseProtocol; + provider: EnterpriseProvider; + provider_user_id: string | null; + public_metadata: Record; + verification: VerificationJSON | null; +} + +export interface EnterpriseAccountConnectionJSON extends ClerkResourceJSON { + active: boolean; + allow_idp_initiated: boolean; + allow_subdomains: boolean; + disable_additional_identifications: boolean; + domain: string; + logo_public_url: string | null; + name: string; + protocol: EnterpriseProtocol; + provider: EnterpriseProvider; + sync_user_attributes: boolean; + created_at: number; + updated_at: number; +} + export interface SamlAccountJSON extends ClerkResourceJSON { object: 'saml_account'; provider: SamlIdpSlug; @@ -218,7 +248,11 @@ export interface UserJSON extends ClerkResourceJSON { phone_numbers: PhoneNumberJSON[]; web3_wallets: Web3WalletJSON[]; external_accounts: ExternalAccountJSON[]; + enterprise_accounts: EnterpriseAccountJSON[]; passkeys: PasskeyJSON[]; + /** + * @deprecated use `enterprise_accounts` instead + */ saml_accounts: SamlAccountJSON[]; organization_memberships: OrganizationMembershipJSON[]; diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index d45359d634..a19440eec4 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -1,6 +1,7 @@ import type { BackupCodeResource } from './backupCode'; import type { DeletedObjectResource } from './deletedObject'; import type { EmailAddressResource } from './emailAddress'; +import type { EnterpriseAccountResource } from './enterpriseAccount'; import type { ExternalAccountResource } from './externalAccount'; import type { ImageResource } from './image'; import type { UserJSON } from './json'; @@ -68,7 +69,11 @@ export interface UserResource extends ClerkResource { phoneNumbers: PhoneNumberResource[]; web3Wallets: Web3WalletResource[]; externalAccounts: ExternalAccountResource[]; + enterpriseAccounts: EnterpriseAccountResource[]; passkeys: PasskeyResource[]; + /** + * @deprecated use `enterpriseAccounts` instead + */ samlAccounts: SamlAccountResource[]; organizationMemberships: OrganizationMembershipResource[];