Skip to content

Commit

Permalink
feat(clerk-js,types): Surface enterprise accounts in UserProfile (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
LauraBeatris authored Nov 13, 2024
1 parent 1783025 commit 1c0b500
Show file tree
Hide file tree
Showing 20 changed files with 1,007 additions and 152 deletions.
6 changes: 6 additions & 0 deletions .changeset/cyan-shirts-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Surface enterprise accounts in `UserProfile`, allowing to display more protocols besides SAML
98 changes: 98 additions & 0 deletions packages/clerk-js/src/core/resources/EnterpriseAccount.ts
Original file line number Diff line number Diff line change
@@ -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<EnterpriseAccountJSON>, 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;
}
}
7 changes: 7 additions & 0 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
DeletedObjectJSON,
DeletedObjectResource,
EmailAddressResource,
EnterpriseAccountResource,
ExternalAccountJSON,
ExternalAccountResource,
GetOrganizationMemberships,
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
BaseResource,
DeletedObject,
EmailAddress,
EnterpriseAccount,
ExternalAccount,
Image,
OrganizationMembership,
Expand All @@ -61,6 +63,7 @@ export class User extends BaseResource implements UserResource {
phoneNumbers: PhoneNumberResource[] = [];
web3Wallets: Web3WalletResource[] = [];
externalAccounts: ExternalAccountResource[] = [];
enterpriseAccounts: EnterpriseAccountResource[] = [];
passkeys: PasskeyResource[] = [];

samlAccounts: SamlAccountResource[] = [];
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 7 additions & 6 deletions packages/clerk-js/src/ui/components/UserProfile/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,22 @@ 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();

const showUsername = attributes.username.enabled;
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 (
Expand Down Expand Up @@ -55,7 +56,7 @@ export const AccountPage = withCardStateProvider(() => {
{showConnectedAccounts && <ConnectedAccountsSection shouldAllowCreation={shouldAllowIdentificationCreation} />}

{/*TODO-STEP-UP: Verify that these work as expected*/}
{showSamlAccounts && <EnterpriseAccountsSection />}
{showEnterpriseAccounts && <EnterpriseAccountsSection />}
{showWeb3 && <Web3Section shouldAllowCreation={shouldAllowIdentificationCreation} />}
</Col>
</Col>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ProfileSection.Root
Expand All @@ -15,58 +24,97 @@ export const EnterpriseAccountsSection = () => {
centered={false}
>
<ProfileSection.ItemList id='enterpriseAccounts'>
{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 (
<ProfileSection.Item
id='enterpriseAccounts'
sx={t => ({
gap: t.space.$2,
justifyContent: 'start',
})}
key={account.id}
>
<Image
elementDescriptor={[descriptors.providerIcon]}
elementId={descriptors.enterpriseButtonsProviderIcon.setId(account.provider)}
alt={providerName}
src={providerLogoUrl}
sx={theme => ({ width: theme.sizes.$4 })}
/>
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
<Text
truncate
colorScheme='body'
>
{providerName}
</Text>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
{error && (
<Badge
colorScheme='danger'
localizationKey={localizationKeys('badge__requiresAction')}
/>
)}
</Flex>
</Box>
</ProfileSection.Item>
);
})}
{activeEnterpriseAccounts.map(account => (
<EnterpriseAccount
key={account.id}
account={account}
/>
))}
</ProfileSection.ItemList>
</ProfileSection.Root>
);
};

const EnterpriseAccount = ({ account }: { account: EnterpriseAccountResource }) => {
const label = account.emailAddress;
const connectionName = account?.enterpriseConnection?.name;
const error = account.verification?.error?.longMessage;

return (
<ProfileSection.Item
id='enterpriseAccounts'
sx={t => ({
gap: t.space.$2,
justifyContent: 'start',
})}
key={account.id}
>
<EnterpriseAccountProviderIcon account={account} />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
<Text
truncate
colorScheme='body'
>
{connectionName}
</Text>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
{error && (
<Badge
colorScheme='danger'
localizationKey={localizationKeys('badge__requiresAction')}
/>
)}
</Flex>
</Box>
</ProfileSection.Item>
);
};

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 (
<Image
{...commonImageProps}
src={iconImageUrl(providerWithoutPrefix)}
/>
);
}

return enterpriseConnection?.logoPublicUrl ? (
<Image
{...commonImageProps}
src={enterpriseConnection.logoPublicUrl}
/>
) : (
<ProviderInitialIcon
id={providerWithoutPrefix}
value={connectionName}
aria-label={`${connectionName}'s icon`}
elementDescriptor={[descriptors.providerIcon, descriptors.providerInitialIcon]}
elementId={descriptors.providerInitialIcon.setId(providerWithoutPrefix)}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof SuccessPage>[0]>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 1c0b500

Please sign in to comment.