Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eced539
Add `EnterpriseAccount` resource to backend layer
LauraBeatris Nov 7, 2024
a3ca6c9
Add `EnterpriseAccount` to types layer
LauraBeatris Nov 7, 2024
107dc77
Add `EnterpriseAccountResource` to clerk-js
LauraBeatris Nov 7, 2024
0fe99f8
Populate `enterprise_accounts` on `User` resource
LauraBeatris Nov 7, 2024
7b6c6de
Verify if there are enterprise accounts to display
LauraBeatris Nov 7, 2024
c83c34b
Update UI description with new enterprise provider union
LauraBeatris Nov 7, 2024
b18329d
Display enterprise accounts from `User` resource
LauraBeatris Nov 7, 2024
9ae6104
Disallow user profile changes for multiple enterprise connection types
LauraBeatris Nov 7, 2024
7f5056d
Introduce UI tests
LauraBeatris Nov 7, 2024
f6e67ee
Fix icon for SAML providers
LauraBeatris Nov 12, 2024
0b2e26f
Update changeset
LauraBeatris Nov 12, 2024
b037709
Update `firstName` and `lastName` to be nullable
LauraBeatris Nov 12, 2024
efa346e
Update tests to rely on `enterprise_accounts`
LauraBeatris Nov 12, 2024
38750b2
Add deprecation JSDocs
LauraBeatris Nov 12, 2024
75c913b
Treat provider as strategy
LauraBeatris Nov 12, 2024
63c8e3c
Add test for inactive connection
LauraBeatris Nov 12, 2024
6f33bb6
Add generic descriptor for SSO connections
LauraBeatris Nov 12, 2024
2c3ec62
Include generic `providerInitialIcon` descriptor
LauraBeatris Nov 13, 2024
3e3cee6
Preserve `ProviderInitialIcon` descriptors
LauraBeatris Nov 13, 2024
d4cdd22
Rely on FAPI for connection name and logo
LauraBeatris Nov 13, 2024
e71267b
Use CDN for provider logo
LauraBeatris Nov 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading