diff --git a/packages/clerk-js/src/core/resources/EnterpriseAccount.ts b/packages/clerk-js/src/core/resources/EnterpriseAccount.ts index e41f63ffb1f..25450e6b14d 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseAccount.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseAccount.ts @@ -61,6 +61,8 @@ export class EnterpriseAccount extends BaseResource implements EnterpriseAccount return this; } + destroy = (): Promise => this._baseDelete(); + public __internal_toSnapshot(): EnterpriseAccountJSONSnapshot { return { object: 'enterprise_account', @@ -93,6 +95,7 @@ export class EnterpriseAccountConnection extends BaseResource implements Enterpr protocol!: EnterpriseAccountResource['protocol']; provider!: EnterpriseAccountResource['provider']; syncUserAttributes!: boolean; + allowAccountLinking!: boolean; createdAt!: Date; updatedAt!: Date; enterpriseConnectionId: string | null = ''; @@ -114,6 +117,7 @@ export class EnterpriseAccountConnection extends BaseResource implements Enterpr this.allowSubdomains = data.allow_subdomains; this.allowIdpInitiated = data.allow_idp_initiated; this.disableAdditionalIdentifications = data.disable_additional_identifications; + this.allowAccountLinking = data.allow_account_linking; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.enterpriseConnectionId = data.enterprise_connection_id; @@ -136,6 +140,7 @@ export class EnterpriseAccountConnection extends BaseResource implements Enterpr allow_subdomains: this.allowSubdomains, allow_idp_initiated: this.allowIdpInitiated, disable_additional_identifications: this.disableAdditionalIdentifications, + allow_account_linking: this.allowAccountLinking, enterprise_connection_id: this.enterpriseConnectionId, created_at: this.createdAt.getTime(), updated_at: this.updatedAt.getTime(), diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 079b7ae2f75..58c1b2a8717 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -9,6 +9,8 @@ import type { DeletedObjectJSON, DeletedObjectResource, EmailAddressResource, + EnterpriseAccountConnectionJSON, + EnterpriseAccountConnectionResource, EnterpriseAccountResource, ExternalAccountJSON, ExternalAccountResource, @@ -42,6 +44,7 @@ import { DeletedObject, EmailAddress, EnterpriseAccount, + EnterpriseAccountConnection, ExternalAccount, Image, OrganizationMembership, @@ -156,7 +159,7 @@ export class User extends BaseResource implements UserResource { }; createExternalAccount = async (params: CreateExternalAccountParams): Promise => { - const { strategy, redirectUrl, additionalScopes } = params || {}; + const { strategy, redirectUrl, additionalScopes, enterpriseConnectionId } = params || {}; const json = ( await BaseResource._fetch({ @@ -166,6 +169,7 @@ export class User extends BaseResource implements UserResource { strategy, redirect_url: redirectUrl, additional_scope: additionalScopes, + enterprise_connection_id: enterpriseConnectionId, } as any, }) )?.response as unknown as ExternalAccountJSON; @@ -289,6 +293,17 @@ export class User extends BaseResource implements UserResource { return new DeletedObject(json); }; + getEnterpriseConnections = async (): Promise => { + const json = ( + await BaseResource._fetch({ + path: '/me/enterprise_connections', + method: 'GET', + }) + )?.response as unknown as EnterpriseAccountConnectionJSON[]; + + return (json || []).map(connection => new EnterpriseAccountConnection(connection)); + }; + initializePaymentMethod: typeof initializePaymentMethod = params => { return initializePaymentMethod(params); }; diff --git a/packages/clerk-js/src/core/resources/__tests__/User.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.test.ts index 60eaccf4dcf..fafdc4cd78f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/User.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/User.test.ts @@ -42,6 +42,88 @@ describe('User', () => { }); }); + it('creates an external account with enterprise connection id', async () => { + const externalAccountJSON = { + object: 'external_account', + provider: 'saml_okta', + verification: { + external_verification_redirect_url: 'https://www.example.com', + }, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: externalAccountJSON })); + + const user = new User({ + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + } as unknown as UserJSON); + + await user.createExternalAccount({ + enterpriseConnectionId: 'ec_123', + redirectUrl: 'https://www.example.com', + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/me/external_accounts', + body: { + strategy: undefined, + redirect_url: 'https://www.example.com', + additional_scope: undefined, + enterprise_connection_id: 'ec_123', + }, + }); + }); + + it('fetches enterprise connections', async () => { + const enterpriseConnectionsJSON = [ + { + id: 'ec_123', + object: 'enterprise_account_connection', + name: 'Acme Corp SSO', + active: true, + allow_account_linking: true, + domain: 'acme.com', + protocol: 'saml', + provider: 'saml_okta', + logo_public_url: null, + sync_user_attributes: true, + allow_subdomains: false, + allow_idp_initiated: false, + disable_additional_identifications: false, + enterprise_connection_id: 'ec_123', + created_at: 1234567890, + updated_at: 1234567890, + }, + ]; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: enterpriseConnectionsJSON })); + + const user = new User({ + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + } as unknown as UserJSON); + + const connections = await user.getEnterpriseConnections(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'GET', + path: '/me/enterprise_connections', + }); + + expect(connections).toHaveLength(1); + expect(connections[0].name).toBe('Acme Corp SSO'); + expect(connections[0].allowAccountLinking).toBe(true); + }); + it('creates a web3 wallet', async () => { const targetWeb3Wallet = '0x0000000000000000000000000000000000000000'; const web3WalletJSON = { diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index cd8b0200914..12670e4933d 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1188,6 +1188,15 @@ export const enUS: LocalizationResource = { successMessage: 'The provider has been added to your account', title: 'Add connected account', }, + enterpriseAccountPage: { + removeResource: { + messageLine1: '{{identifier}} will be removed from this account.', + messageLine2: + 'You will no longer be able to use this enterprise account and any dependent features will no longer work.', + successMessage: '{{enterpriseAccount}} has been removed from your account.', + title: 'Remove enterprise account', + }, + }, deletePage: { actionDescription: 'Type "Delete account" below to continue.', confirm: 'Delete account', @@ -1360,6 +1369,8 @@ export const enUS: LocalizationResource = { title: 'Email addresses', }, enterpriseAccountsSection: { + destructiveActionTitle: 'Remove', + primaryButton: 'Connect account', title: 'Enterprise accounts', }, headerTitle__account: 'Profile details', diff --git a/packages/shared/src/types/enterpriseAccount.ts b/packages/shared/src/types/enterpriseAccount.ts index 049b19b62e7..eb3a5b2aec5 100644 --- a/packages/shared/src/types/enterpriseAccount.ts +++ b/packages/shared/src/types/enterpriseAccount.ts @@ -21,6 +21,7 @@ export interface EnterpriseAccountResource extends ClerkResource { publicMetadata: Record | null; verification: VerificationResource | null; lastAuthenticatedAt: Date | null; + destroy: () => Promise; __internal_toSnapshot: () => EnterpriseAccountJSONSnapshot; } @@ -35,6 +36,7 @@ export interface EnterpriseAccountConnectionResource extends ClerkResource { protocol: EnterpriseProtocol; provider: EnterpriseProvider; syncUserAttributes: boolean; + allowAccountLinking: boolean; enterpriseConnectionId: string | null; __internal_toSnapshot: () => EnterpriseAccountConnectionJSONSnapshot; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index c9bf7ddb2b6..12300facab2 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -272,6 +272,7 @@ export interface EnterpriseAccountConnectionJSON extends ClerkResourceJSON { protocol: EnterpriseProtocol; provider: EnterpriseProvider; sync_user_attributes: boolean; + allow_account_linking: boolean; created_at: number; updated_at: number; enterprise_connection_id: string | null; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 63ad4de0f21..509e8bd4c28 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -683,6 +683,8 @@ export type __internal_LocalizationResource = { }; enterpriseAccountsSection: { title: LocalizationValue; + primaryButton: LocalizationValue; + destructiveActionTitle: LocalizationValue; }; passwordSection: { title: LocalizationValue; @@ -819,6 +821,14 @@ export type __internal_LocalizationResource = { successMessage: LocalizationValue<'connectedAccount'>; }; }; + enterpriseAccountPage: { + removeResource: { + title: LocalizationValue; + messageLine1: LocalizationValue<'identifier'>; + messageLine2: LocalizationValue; + successMessage: LocalizationValue<'enterpriseAccount'>; + }; + }; web3WalletPage: { title: LocalizationValue; subtitle__availableWallets: LocalizationValue; diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index ac1c40b2fbd..2fd41c39a50 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -2,7 +2,7 @@ import type { BackupCodeResource } from './backupCode'; import type { BillingPayerMethods } from './billing'; import type { DeletedObjectResource } from './deletedObject'; import type { EmailAddressResource } from './emailAddress'; -import type { EnterpriseAccountResource } from './enterpriseAccount'; +import type { EnterpriseAccountConnectionResource, EnterpriseAccountResource } from './enterpriseAccount'; import type { ExternalAccountResource } from './externalAccount'; import type { ImageResource } from './image'; import type { UserJSON } from './json'; @@ -118,6 +118,7 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { ) => Promise>; getOrganizationCreationDefaults: () => Promise; leaveOrganization: (organizationId: string) => Promise; + getEnterpriseConnections: () => Promise; createTOTP: () => Promise; verifyTOTP: (params: VerifyTOTPParams) => Promise; disableTOTP: () => Promise; @@ -141,7 +142,8 @@ export type CreatePhoneNumberParams = { phoneNumber: string }; export type CreateWeb3WalletParams = { web3Wallet: string }; export type SetProfileImageParams = { file: Blob | File | string | null }; export type CreateExternalAccountParams = { - strategy: OAuthStrategy; + strategy?: OAuthStrategy; + enterpriseConnectionId?: string; redirectUrl?: string; additionalScopes?: OAuthScope[]; oidcPrompt?: string; diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx index deaa2c31de5..ca867ed1bb9 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx @@ -46,7 +46,7 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi card.setLoading(strategy); return createExternalAccount() .then(res => { - if (res && res.verification?.externalVerificationRedirectURL) { + if (res?.verification?.externalVerificationRedirectURL) { void sleep(2000).then(() => card.setIdle(strategy)); void navigate(res.verification.externalVerificationRedirectURL.href); } diff --git a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx index 49654e7c6f9..99005c3c876 100644 --- a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx @@ -1,19 +1,159 @@ -import { useUser } from '@clerk/shared/react'; -import type { EnterpriseAccountResource, OAuthProvider } from '@clerk/shared/types'; +import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; +import { useReverification, useUser } from '@clerk/shared/react'; +import type { + EnterpriseAccountConnectionResource, + EnterpriseAccountResource, + OAuthProvider, +} from '@clerk/shared/types'; +import { Fragment, useEffect, useState } from 'react'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +import { handleError } from '@/ui/utils/errorHandler'; +import { sleep } from '@/ui/utils/sleep'; import { ProviderIcon } from '../../common'; +import { useUserProfileContext } from '../../contexts'; import { Badge, Box, descriptors, Flex, localizationKeys, Text } from '../../customizables'; +import { Action } from '../../elements/Action'; +import { useActionContext } from '../../elements/Action/ActionRoot'; +import { useRouter } from '../../router'; +import type { PropsOfComponent } from '../../styledSystem'; +import { RemoveEnterpriseAccountForm } from './RemoveResourceForm'; -export const EnterpriseAccountsSection = () => { +const EnterpriseConnectMenuButton = (props: { connection: EnterpriseAccountConnectionResource }) => { + const { connection } = props; + const card = useCardState(); const { user } = useUser(); + const { navigate } = useRouter(); + const { componentName, mode } = useUserProfileContext(); + const isModal = mode === 'modal'; + const loadingKey = `enterprise_${connection.id}`; + + const createExternalAccount = useReverification(() => { + const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href; + + return user?.createExternalAccount({ + enterpriseConnectionId: connection.id, + redirectUrl, + }); + }); + + const connect = () => { + if (!user) { + return; + } + + card.setLoading(loadingKey); + return createExternalAccount() + .then(res => { + if (res?.verification?.externalVerificationRedirectURL) { + void sleep(2000).then(() => card.setIdle(loadingKey)); + void navigate(res.verification.externalVerificationRedirectURL.href); + } + }) + .catch(err => { + handleError(err, [], card.setError); + card.setIdle(loadingKey); + }); + }; + + const providerWithoutPrefix = connection?.name; + + return ( + ({ + justifyContent: 'start', + gap: t.space.$2, + })} + leftIcon={ + + } + /> + ); +}; + +const AddEnterpriseAccount = ({ + onClick, + enterpriseConnections, +}: { + onClick?: () => void; + enterpriseConnections: EnterpriseAccountConnectionResource[]; +}) => { + if (enterpriseConnections.length === 0) { + return null; + } + + return ( + + {enterpriseConnections.map(connection => ( + + ))} + + ); +}; + +type RemoveEnterpriseAccountScreenProps = { accountId: string }; +const RemoveEnterpriseAccountScreen = (props: RemoveEnterpriseAccountScreenProps) => { + const { close } = useActionContext(); + return ( + + ); +}; + +export const EnterpriseAccountsSection = withCardStateProvider(() => { + const { user } = useUser(); + const card = useCardState(); + const [enterpriseConnections, setEnterpriseConnections] = useState([]); + const [actionValue, setActionValue] = useState(null); + + useEffect(() => { + user?.getEnterpriseConnections?.().then(connections => { + setEnterpriseConnections(connections.filter(c => c.allowAccountLinking)); + }); + }, [user]); const activeEnterpriseAccounts = user?.enterpriseAccounts.filter( ({ enterpriseConnection }) => enterpriseConnection?.active, ); - if (!activeEnterpriseAccounts?.length) { + const hasActiveAccounts = Boolean(activeEnterpriseAccounts?.length); + const hasAvailableConnections = enterpriseConnections.length > 0; + + if (!hasActiveAccounts && !hasAvailableConnections) { return null; } @@ -23,63 +163,102 @@ export const EnterpriseAccountsSection = () => { id='enterpriseAccounts' centered={false} > - - {activeEnterpriseAccounts.map(account => ( - - ))} - + {card.error} + + + {activeEnterpriseAccounts?.map(account => ( + + ))} + + setActionValue(null)} + /> + ); -}; +}); const EnterpriseAccount = ({ account }: { account: EnterpriseAccountResource }) => { const label = account.emailAddress; const connectionName = account?.enterpriseConnection?.name; const error = account.verification?.error?.longMessage; + const accountId = account.id; return ( - ({ - gap: t.space.$2, - justifyContent: 'start', - })} - key={account.id} - > - - - - - {connectionName} - - - {label ? `• ${label}` : ''} - - {error && ( - - )} + + ({ + gap: t.space.$2, + justifyContent: 'start', + })} + > + ({ overflow: 'hidden', gap: t.space.$2 })}> + + + + + {connectionName} + + + {label ? `• ${label}` : ''} + + {error && ( + + )} + + - - + + + + + + + + + + ); }; +const EnterpriseAccountMenu = ({ account }: { account: EnterpriseAccountResource }) => { + const { open } = useActionContext(); + const accountId = account.id; + + const actions = ( + [ + { + label: localizationKeys('userProfile.start.enterpriseAccountsSection.destructiveActionTitle'), + isDestructive: true, + onClick: () => open(`remove-${accountId}`), + }, + ] satisfies (PropsOfComponent['actions'][0] | null)[] + ).filter(a => a !== null) as PropsOfComponent['actions']; + + return ; +}; + const EnterpriseAccountProviderIcon = ({ account }: { account: EnterpriseAccountResource }) => { const { provider, enterpriseConnection } = account; diff --git a/packages/ui/src/components/UserProfile/RemoveResourceForm.tsx b/packages/ui/src/components/UserProfile/RemoveResourceForm.tsx index 5832d485b39..2937d6287bc 100644 --- a/packages/ui/src/components/UserProfile/RemoveResourceForm.tsx +++ b/packages/ui/src/components/UserProfile/RemoveResourceForm.tsx @@ -220,3 +220,34 @@ export const RemovePasskeyForm = (props: RemovePasskeyFormProps) => { /> ); }; + +type RemoveEnterpriseAccountFormProps = FormProps & { + accountId: string; +}; + +export const RemoveEnterpriseAccountForm = (props: RemoveEnterpriseAccountFormProps) => { + const { accountId: id, onSuccess, onReset } = props; + const { user } = useUser(); + const resource = user?.enterpriseAccounts.find(e => e.id === id); + const ref = React.useRef(resource?.enterpriseConnection?.name || resource?.emailAddress); + + if (!ref.current) { + return null; + } + + return ( + Promise.resolve(resource?.destroy())} + onSuccess={onSuccess} + onReset={onReset} + /> + ); +};