Skip to content

Commit 44fd6f3

Browse files
committed
feat(ui): Immutable attributes in UserProfile
1 parent d2317f5 commit 44fd6f3

10 files changed

Lines changed: 300 additions & 21 deletions

File tree

.changeset/bumpy-wings-travel.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/ui': minor
5+
---
6+
7+
Prevent modification of immutable attributes in UserProfile

packages/shared/src/types/userSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type OAuthProviderSettings = {
3030
export type AttributeDataJSON = {
3131
enabled: boolean;
3232
required: boolean;
33+
immutable?: boolean;
3334
verifications: VerificationStrategy[];
3435
used_for_first_factor: boolean;
3536
first_factors: VerificationStrategy[];

packages/ui/src/components/UserProfile/AccountPage.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const AccountPage = withCardStateProvider(() => {
1818
const { attributes, social, enterpriseSSO } = useEnvironment().userSettings;
1919
const card = useCardState();
2020
const { user } = useUser();
21-
const { shouldAllowIdentificationCreation } = useUserProfileContext();
21+
const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext();
2222

2323
const showUsername = attributes.username?.enabled;
2424
const showEmail = attributes.email_address?.enabled;
@@ -27,6 +27,12 @@ export const AccountPage = withCardStateProvider(() => {
2727
const showEnterpriseAccounts = user && enterpriseSSO.enabled;
2828
const showWeb3 = attributes.web3_wallet?.enabled;
2929

30+
const isEmailImmutable = immutableAttributes.has('email_address');
31+
const isPhoneImmutable = immutableAttributes.has('phone_number');
32+
const isUsernameImmutable = immutableAttributes.has('username');
33+
34+
console.log('[clerk-ui] immutableAttributes:', [...immutableAttributes]);
35+
3036
return (
3137
<Col
3238
elementDescriptor={descriptors.page}
@@ -47,9 +53,19 @@ export const AccountPage = withCardStateProvider(() => {
4753
<Card.Alert>{card.error}</Card.Alert>
4854

4955
<UserProfileSection />
50-
{showUsername && <UsernameSection />}
51-
{showEmail && <EmailsSection shouldAllowCreation={shouldAllowIdentificationCreation} />}
52-
{showPhone && <PhoneSection shouldAllowCreation={shouldAllowIdentificationCreation} />}
56+
{showUsername && <UsernameSection isImmutable={isUsernameImmutable} />}
57+
{showEmail && (
58+
<EmailsSection
59+
shouldAllowCreation={shouldAllowIdentificationCreation && !isEmailImmutable}
60+
shouldAllowDeletion={!isEmailImmutable}
61+
/>
62+
)}
63+
{showPhone && (
64+
<PhoneSection
65+
shouldAllowCreation={shouldAllowIdentificationCreation && !isPhoneImmutable}
66+
shouldAllowDeletion={!isPhoneImmutable}
67+
/>
68+
)}
5369
{showConnectedAccounts && <ConnectedAccountsSection shouldAllowCreation={shouldAllowIdentificationCreation} />}
5470

5571
{/*TODO-STEP-UP: Verify that these work as expected*/}

packages/ui/src/components/UserProfile/EmailsSection.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ const EmailScreen = (props: EmailScreenProps) => {
3939
);
4040
};
4141

42-
export const EmailsSection = ({ shouldAllowCreation = true }) => {
42+
export const EmailsSection = ({
43+
shouldAllowCreation = true,
44+
shouldAllowDeletion = true,
45+
}: {
46+
shouldAllowCreation?: boolean;
47+
shouldAllowDeletion?: boolean;
48+
}) => {
4349
const { user } = useUser();
4450

4551
return (
@@ -69,7 +75,10 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => {
6975
<Badge localizationKey={localizationKeys('badge__unverified')} />
7076
)}
7177
</Flex>
72-
<EmailMenu email={email} />
78+
<EmailMenu
79+
email={email}
80+
shouldAllowDeletion={shouldAllowDeletion}
81+
/>
7382
</ProfileSection.Item>
7483

7584
<Action.Open value={`remove-${emailId}`}>
@@ -107,7 +116,13 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => {
107116
);
108117
};
109118

110-
const EmailMenu = ({ email }: { email: EmailAddressResource }) => {
119+
const EmailMenu = ({
120+
email,
121+
shouldAllowDeletion = true,
122+
}: {
123+
email: EmailAddressResource;
124+
shouldAllowDeletion?: boolean;
125+
}) => {
111126
const card = useCardState();
112127
const { user } = useUser();
113128
const { open } = useActionContext();
@@ -140,13 +155,19 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => {
140155
onClick: () => open(`verify-${emailId}`),
141156
}
142157
: null,
143-
{
144-
label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'),
145-
isDestructive: true,
146-
onClick: () => open(`remove-${emailId}`),
147-
},
158+
shouldAllowDeletion
159+
? {
160+
label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'),
161+
isDestructive: true,
162+
onClick: () => open(`remove-${emailId}`),
163+
}
164+
: null,
148165
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
149166
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
150167

168+
if (actions.length === 0) {
169+
return null;
170+
}
171+
151172
return <ThreeDotsMenu actions={actions} />;
152173
};

packages/ui/src/components/UserProfile/PhoneSection.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ const PhoneScreen = (props: PhoneScreenProps) => {
4040
);
4141
};
4242

43-
export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => {
43+
export const PhoneSection = ({
44+
shouldAllowCreation = true,
45+
shouldAllowDeletion = true,
46+
}: {
47+
shouldAllowCreation?: boolean;
48+
shouldAllowDeletion?: boolean;
49+
}) => {
4450
const { user } = useUser();
4551
const hasPhoneNumbers = Boolean(user?.phoneNumbers?.length);
4652

@@ -78,7 +84,10 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati
7884
</Flex>
7985
</Box>
8086

81-
<PhoneMenu phone={phone} />
87+
<PhoneMenu
88+
phone={phone}
89+
shouldAllowDeletion={shouldAllowDeletion}
90+
/>
8291
</ProfileSection.Item>
8392

8493
<Action.Open value={`remove-${phoneId}`}>
@@ -116,7 +125,13 @@ export const PhoneSection = ({ shouldAllowCreation = true }: { shouldAllowCreati
116125
);
117126
};
118127

119-
const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => {
128+
const PhoneMenu = ({
129+
phone,
130+
shouldAllowDeletion = true,
131+
}: {
132+
phone: PhoneNumberResource;
133+
shouldAllowDeletion?: boolean;
134+
}) => {
120135
const card = useCardState();
121136
const { open } = useActionContext();
122137
const { user } = useUser();
@@ -152,13 +167,19 @@ const PhoneMenu = ({ phone }: { phone: PhoneNumberResource }) => {
152167
onClick: () => open(`verify-${phoneId}`),
153168
}
154169
: null,
155-
{
156-
label: localizationKeys('userProfile.start.phoneNumbersSection.destructiveAction'),
157-
isDestructive: true,
158-
onClick: () => open(`remove-${phoneId}`),
159-
},
170+
shouldAllowDeletion
171+
? {
172+
label: localizationKeys('userProfile.start.phoneNumbersSection.destructiveAction'),
173+
isDestructive: true,
174+
onClick: () => open(`remove-${phoneId}`),
175+
}
176+
: null,
160177
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
161178
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
162179

180+
if (actions.length === 0) {
181+
return null;
182+
}
183+
163184
return <ThreeDotsMenu actions={actions} />;
164185
};

packages/ui/src/components/UserProfile/UsernameSection.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,36 @@ const UsernameScreen = () => {
1818
);
1919
};
2020

21-
export const UsernameSection = () => {
21+
export const UsernameSection = ({ isImmutable }: { isImmutable?: boolean }) => {
2222
const { user } = useUser();
2323

2424
if (!user) {
2525
return null;
2626
}
2727

28+
if (isImmutable) {
29+
if (!user.username) {
30+
return null;
31+
}
32+
33+
return (
34+
<ProfileSection.Root
35+
title={localizationKeys('userProfile.start.usernameSection.title')}
36+
id='username'
37+
sx={{ alignItems: 'center', [mqu.md]: { alignItems: 'flex-start' } }}
38+
>
39+
<ProfileSection.Item id='username'>
40+
<Text
41+
truncate
42+
sx={t => ({ color: t.colors.$colorForeground })}
43+
>
44+
{user.username}
45+
</Text>
46+
</ProfileSection.Item>
47+
</ProfileSection.Root>
48+
);
49+
}
50+
2851
return (
2952
<ProfileSection.Root
3053
title={localizationKeys('userProfile.start.usernameSection.title')}

packages/ui/src/components/UserProfile/__tests__/EmailsSection.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,83 @@ describe('EmailSection', () => {
190190
});
191191
});
192192

193+
describe('Immutable email addresses', () => {
194+
const withImmutableEmails = createFixtures.config(f => {
195+
f.withEmailAddress({ immutable: true });
196+
f.withUser({
197+
email_addresses: ['test@clerk.com', 'test2@clerk.com'],
198+
});
199+
});
200+
201+
it('hides the "Add email address" button when email is immutable', async () => {
202+
const { wrapper } = await createFixtures(withImmutableEmails);
203+
204+
const { queryByRole } = render(
205+
<CardStateProvider>
206+
<EmailsSection shouldAllowCreation={false} />
207+
</CardStateProvider>,
208+
{ wrapper },
209+
);
210+
211+
expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument();
212+
});
213+
214+
it('hides the "Remove" menu action when email is immutable', async () => {
215+
const { wrapper } = await createFixtures(withImmutableEmails);
216+
217+
const { getByText, userEvent, queryByRole } = render(
218+
<CardStateProvider>
219+
<EmailsSection
220+
shouldAllowCreation={false}
221+
shouldAllowDeletion={false}
222+
/>
223+
</CardStateProvider>,
224+
{ wrapper },
225+
);
226+
227+
const item = getByText('test@clerk.com');
228+
const menuButton = getMenuItemFromText(item);
229+
await act(async () => {
230+
await userEvent.click(menuButton!);
231+
});
232+
233+
expect(queryByRole('menuitem', { name: /remove email/i })).not.toBeInTheDocument();
234+
});
235+
236+
it('still shows verify and set-as-primary actions when email is immutable', async () => {
237+
const { wrapper } = await createFixtures(
238+
createFixtures.config(f => {
239+
f.withEmailAddress({ immutable: true });
240+
f.withUser({
241+
email_addresses: [
242+
{ email_address: 'primary@clerk.com', id: 'email_primary', verification: { status: 'verified' } },
243+
{ email_address: 'secondary@clerk.com', id: 'email_secondary', verification: { status: 'verified' } },
244+
],
245+
primary_email_address_id: 'email_primary',
246+
});
247+
}),
248+
);
249+
250+
const { getByText, userEvent, getByRole } = render(
251+
<CardStateProvider>
252+
<EmailsSection
253+
shouldAllowCreation={false}
254+
shouldAllowDeletion={false}
255+
/>
256+
</CardStateProvider>,
257+
{ wrapper },
258+
);
259+
260+
const item = getByText('secondary@clerk.com');
261+
const menuButton = getMenuItemFromText(item);
262+
await act(async () => {
263+
await userEvent.click(menuButton!);
264+
});
265+
266+
getByRole('menuitem', { name: /set as primary/i });
267+
});
268+
});
269+
193270
describe('Handles opening/closing actions', () => {
194271
it('closes add email form when remove an email address action is clicked', async () => {
195272
const { wrapper, fixtures } = await createFixtures(withEmails);

0 commit comments

Comments
 (0)