diff --git a/.changeset/calm-kings-draw.md b/.changeset/calm-kings-draw.md new file mode 100644 index 00000000000..89af9fb3ffa --- /dev/null +++ b/.changeset/calm-kings-draw.md @@ -0,0 +1,9 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +List passkeys under security in UserProfile. +- Supports renaming a passkey. +- Supports deleting a passkey. diff --git a/packages/clerk-js/src/core/resources/Passkey.ts b/packages/clerk-js/src/core/resources/Passkey.ts index ea2e554b641..e348f5eeb48 100644 --- a/packages/clerk-js/src/core/resources/Passkey.ts +++ b/packages/clerk-js/src/core/resources/Passkey.ts @@ -105,7 +105,7 @@ export class Passkey extends BaseResource implements PasskeyResource { delete = async (): Promise => { const json = ( await BaseResource._fetch({ - path: `${this.path()}/${this.id}`, + path: this.path(), method: 'DELETE', }) )?.response as unknown as DeletedObjectJSON; diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx new file mode 100644 index 00000000000..60741efc600 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx @@ -0,0 +1,211 @@ +import { useUser } from '@clerk/shared/react'; +import type { PasskeyResource } from '@clerk/types'; +import React from 'react'; + +import { Col, Flex, localizationKeys, Text, useLocalizations } from '../../customizables'; +import { + Form, + FormButtons, + FormContainer, + type FormProps, + ProfileSection, + ThreeDotsMenu, + useCardState, + withCardStateProvider, +} from '../../elements'; +import { Action } from '../../elements/Action'; +import { useActionContext } from '../../elements/Action/ActionRoot'; +import type { PropsOfComponent } from '../../styledSystem'; +import { mqu } from '../../styledSystem'; +import { getRelativeToNowDateKey, handleError, useFormControl } from '../../utils'; +import { RemovePasskeyForm } from './RemoveResourceForm'; + +const RemovePasskeyScreen = (props: PasskeyScreenProps) => { + const { close } = useActionContext(); + return ( + + ); +}; + +type PasskeyScreenProps = { passkey: PasskeyResource }; + +type UpdatePasskeyFormProps = FormProps & PasskeyScreenProps; +const PasskeyScreen = (props: PasskeyScreenProps) => { + const { close } = useActionContext(); + return ( + + ); +}; + +export const UpdatePasskeyForm = withCardStateProvider((props: UpdatePasskeyFormProps) => { + const { onSuccess, onReset, passkey } = props; + const card = useCardState(); + + const passkeyNameField = useFormControl('passkeyName', passkey.name || '', { + type: 'text', + label: localizationKeys('__experimental_formFieldLabel__passkeyName'), + isRequired: true, + }); + + const canSubmit = passkeyNameField.value.length > 1 && passkey.name !== passkeyNameField.value; + + const addEmail = async (e: React.FormEvent) => { + e.preventDefault(); + return passkey + .update({ name: passkeyNameField.value }) + .then(onSuccess) + .catch(e => handleError(e, [passkeyNameField], card.setError)); + }; + + return ( + + + + + + + + + ); +}); + +export const PasskeySection = () => { + const { user } = useUser(); + + if (!user) { + return null; + } + + return ( + + + + {user.__experimental_passkeys.map(passkey => ( + + + + + + + + + + + + + + + + ))} + + + + + + ); +}; + +const PasskeyItem = (props: PasskeyResource) => { + return ( + + + + + ); +}; + +const PasskeyInfo = (props: PasskeyResource) => { + const { name, createdAt, lastUsedAt } = props; + const { t } = useLocalizations(); + + return ( + ({ + width: '100%', + overflow: 'hidden', + gap: t.space.$4, + [mqu.sm]: { gap: t.space.$2 }, + })} + > + + {name} + Created: {t(getRelativeToNowDateKey(createdAt))} + {lastUsedAt && Last used: {t(getRelativeToNowDateKey(lastUsedAt))}} + + + ); +}; + +const ActiveDeviceMenu = () => { + const { open } = useActionContext(); + + const actions = [ + { + label: localizationKeys('userProfile.start.__experimental_passkeysSection.menuAction__rename'), + onClick: () => open('rename'), + }, + { + label: localizationKeys('userProfile.start.__experimental_passkeysSection.menuAction__destructive'), + isDestructive: true, + onClick: () => open('remove'), + }, + ] satisfies PropsOfComponent['actions']; + + return ; +}; + +// TODO-PASSKEYS: Should the error be scope to the section ? +const AddPasskeyButton = () => { + const card = useCardState(); + const { user } = useUser(); + + const handleCreatePasskey = async () => { + try { + await user?.__experimental_createPasskey(); + } catch (e) { + handleError(e, [], card.setError); + } + }; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserProfile/RemoveResourceForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/RemoveResourceForm.tsx index 9db5e0c3653..a6abd780d41 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/RemoveResourceForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/RemoveResourceForm.tsx @@ -1,4 +1,5 @@ import { useUser } from '@clerk/shared/react'; +import type { PasskeyResource } from '@clerk/types'; import React from 'react'; import { RemoveResourceForm } from '../../common'; @@ -200,3 +201,21 @@ export const RemoveMfaTOTPForm = (props: RemoveMfaTOTPFormProps) => { /> ); }; + +type RemovePasskeyFormProps = FormProps & { passkey: PasskeyResource }; + +export const RemovePasskeyForm = (props: RemovePasskeyFormProps) => { + const { onSuccess, onReset, passkey } = props; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui/components/UserProfile/SecurityPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/SecurityPage.tsx index d00d590ba58..5e668d58d53 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/SecurityPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/SecurityPage.tsx @@ -6,6 +6,7 @@ import { Card, Header, useCardState, withCardStateProvider } from '../../element import { ActiveDevicesSection } from './ActiveDevicesSection'; import { DeleteSection } from './DeleteSection'; import { MfaSection } from './MfaSection'; +import { PasskeySection } from './PasskeySection'; import { PasswordSection } from './PasswordSection'; import { getSecondFactors } from './utils'; @@ -14,6 +15,7 @@ export const SecurityPage = withCardStateProvider(() => { const card = useCardState(); const { user } = useUser(); const showPassword = instanceIsPasswordBased; + const showPasskey = attributes.passkey.enabled; const showMfa = getSecondFactors(attributes).length > 0; const showDelete = user?.deleteSelfEnabled; @@ -35,6 +37,7 @@ export const SecurityPage = withCardStateProvider(() => { {card.error} {showPassword && } + {showPasskey && } {showMfa && } {showDelete && } diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx new file mode 100644 index 00000000000..368135b9452 --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasskeysSection.test.tsx @@ -0,0 +1,207 @@ +import type { PasskeyJSON, PasskeyResource } from '@clerk/types'; +import { describe, it } from '@jest/globals'; +import { act } from '@testing-library/react'; + +import { render, waitFor } from '../../../../testUtils'; +import { CardStateProvider } from '../../../elements'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { PasskeySection } from '../PasskeySection'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +const passkeys = [ + { + object: 'passkey', + id: '1234', + name: 'Chrome on Mac', + created_at: Date.now(), + last_used_at: Date.now(), + verification: null, + updated_at: Date.now(), + credential_id: 'some_id', + }, +] satisfies PasskeyJSON[]; + +const withPasskeys = createFixtures.config(f => { + f.withPasskey(); + f.withUser({ + passkeys, + }); +}); + +const getMenuItemFromText = (element: HTMLElement) => { + return element.parentElement?.parentElement?.parentElement?.children[1]; +}; + +describe('PasskeySection', () => { + it('renders the section', async () => { + const { wrapper, fixtures } = await createFixtures(withPasskeys); + fixtures.clerk.user!.getSessions.mockReturnValue(Promise.resolve([])); + + const { getByText } = render( + + + , + { wrapper }, + ); + getByText(/Passkeys/i); + passkeys.forEach(passkey => getByText(passkey.name)); + }); + + describe('Add passkey', () => { + it('renders add passkey button', async () => { + const { wrapper } = await createFixtures(withPasskeys); + + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + await userEvent.click(getByRole('button', { name: 'Add a passkey' })); + }); + + it('create a new passkey', async () => { + const { wrapper, fixtures } = await createFixtures(withPasskeys); + + fixtures.clerk.user?.__experimental_createPasskey.mockReturnValueOnce(Promise.resolve({} as any)); + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Add a passkey' })); + expect(fixtures.clerk.user?.__experimental_createPasskey).toHaveBeenCalled(); + }); + }); + + describe('Update a passkey', () => { + it('Renders the update screen', async () => { + const { wrapper } = await createFixtures(withPasskeys); + + const { getByText, userEvent, getByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + await userEvent.click(getByRole('menuitem', { name: /rename/i })); + await waitFor(() => getByRole('heading', { name: /Rename passkey/i })); + getByText('You can change the passkey name to make it easier to find.'); + }); + + it('update the name of a new passkey', async () => { + const { wrapper, fixtures } = await createFixtures(withPasskeys); + + fixtures.clerk.user?.__experimental_passkeys[0].update.mockResolvedValue({} as PasskeyResource); + const { getByRole, userEvent, getByText, getByLabelText } = render( + + + , + { wrapper }, + ); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + await userEvent.click(getByRole('menuitem', { name: /rename/i })); + await waitFor(() => getByLabelText(/Name of Passkey/i)); + expect(getByRole('button', { name: /save$/i })).toHaveAttribute('disabled'); + await userEvent.type(getByLabelText(/Name of Passkey/i), 'os'); + expect(getByRole('button', { name: /save$/i })).not.toHaveAttribute('disabled'); + await userEvent.click(getByRole('button', { name: /save$/i })); + expect(fixtures.clerk.user?.__experimental_passkeys[0].update).toHaveBeenCalledWith({ name: 'Chrome on Macos' }); + }); + }); + + describe('Remove passkey', () => { + it('Renders remove screen', async () => { + const { wrapper } = await createFixtures(withPasskeys); + + const { getByText, userEvent, getByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove/i }); + await userEvent.click(getByRole('menuitem', { name: /remove/i })); + await waitFor(() => getByRole('heading', { name: /remove passkey/i })); + getByText(`${passkeys[0].name} will be removed from this account.`); + }); + + it('removes a passkey', async () => { + const { wrapper, fixtures } = await createFixtures(withPasskeys); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.__experimental_passkeys[0].delete.mockResolvedValue({ + object: 'passkey', + deleted: true, + }); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove/i }); + await userEvent.click(getByRole('menuitem', { name: /remove/i })); + await waitFor(() => getByRole('heading', { name: /remove passkey/i })); + + await userEvent.click(getByRole('button', { name: /remove/i })); + expect(fixtures.clerk.user?.__experimental_passkeys[0].delete).toHaveBeenCalled(); + + await waitFor(() => expect(queryByRole('heading', { name: /remove passkey/i })).not.toBeInTheDocument()); + }); + + describe('Form buttons', () => { + it('hides screen when when pressing cancel', async () => { + const { wrapper } = await createFixtures(withPasskeys); + const { getByRole, userEvent, getByText, queryByRole } = render( + + + , + { wrapper }, + ); + + const item = getByText(passkeys[0].name); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove/i }); + await userEvent.click(getByRole('menuitem', { name: /remove/i })); + await waitFor(() => getByRole('heading', { name: /remove passkey/i })); + + await userEvent.click(getByRole('button', { name: /cancel$/i })); + await waitFor(() => expect(queryByRole('heading', { name: /remove passkey/i })).not.toBeInTheDocument()); + }); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/SecurityPage.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/SecurityPage.test.tsx index 58e45652ce3..f4c8ed948ba 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/SecurityPage.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/SecurityPage.test.tsx @@ -27,6 +27,7 @@ describe('SecurityPage', () => { await waitFor(() => expect(fixtures.clerk.user?.getSessions).toHaveBeenCalled()); expect(queryByText(/^password/i)).not.toBeInTheDocument(); expect(queryByText(/^Two-step verification/i)).not.toBeInTheDocument(); + expect(queryByText(/^passkeys/i)).not.toBeInTheDocument(); }); it('renders the Password section if instance is password based', async () => { @@ -56,6 +57,34 @@ describe('SecurityPage', () => { await waitFor(() => getByText('Two-step verification')); }); + it('renders the Passkeys section if instance supports it', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + email_addresses: ['test@clerk.com'], + passkeys: [ + { + object: 'passkey', + id: '1234', + name: 'Chrome on Mac', + created_at: Date.now(), + last_used_at: Date.now(), + verification: null, + updated_at: Date.now(), + credential_id: 'some_id', + }, + ], + }); + f.withPasskey(); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + const { getByText } = render(, { wrapper }); + await waitFor(() => getByText('Passkeys')); + getByText('Chrome on Mac'); + getByText(/^Created:/); + getByText(/^Last used:/); + }); + it('shows the active devices of the user and has appropriate buttons', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withSocialProvider({ provider: 'google' }); diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 63cb98d78f2..51960ba35a6 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -356,6 +356,21 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { }; }; + const withPasskey = (opts?: Partial) => { + us.attributes.passkey = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + ...opts, + }; + }; + const withUsername = (opts?: Partial) => { us.attributes.username = { ...emptyAttribute, @@ -458,5 +473,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { withSaml, withBackupCode, withAuthenticatorApp, + withPasskey, }; }; diff --git a/packages/clerk-js/src/ui/utils/test/fixtures.ts b/packages/clerk-js/src/ui/utils/test/fixtures.ts index a503fee5f4a..7520f9b7707 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtures.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtures.ts @@ -100,6 +100,7 @@ const attributes = Object.freeze( 'password', 'authenticator_app', 'backup_code', + 'passkey', ]), ); diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index c509469eff2..52ffd6624ff 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -45,6 +45,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked mockMethodsOf(m)); }); mockProp(clerk, 'navigate'); mockProp(clerk, 'setActive'); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 936543e5ec2..0a53eaa09ed 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -76,6 +76,7 @@ export const enUS: LocalizationResource = { formFieldLabel__role: 'Role', formFieldLabel__signOutOfOtherSessions: 'Sign out of all other devices', formFieldLabel__username: 'Username', + __experimental_formFieldLabel__passkeyName: 'Name of passkey', impersonationFab: { action__signOut: 'Sign out', title: 'Signed in as {{identifier}}', @@ -725,6 +726,11 @@ export const enUS: LocalizationResource = { primaryButton__updatePassword: 'Update password', title: 'Password', }, + __experimental_passkeysSection: { + title: 'Passkeys', + menuAction__rename: 'Rename', + menuAction__destructive: 'Remove', + }, phoneNumbersSection: { destructiveAction: 'Remove phone number', detailsAction__nonPrimary: 'Set as primary', @@ -753,6 +759,14 @@ export const enUS: LocalizationResource = { title__set: 'Set username', title__update: 'Update username', }, + __experimental_passkeyScreen: { + title__rename: 'Rename Passkey', + subtitle__rename: 'You can change the passkey name to make it easier to find.', + removeResource: { + title: 'Remove passkey', + messageLine1: '{{name}} will be removed from this account.', + }, + }, web3WalletPage: { removeResource: { messageLine1: '{{identifier}} will be removed from this account.', diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts index 3b6e3b4d564..c15292fce10 100644 --- a/packages/types/src/elementIds.ts +++ b/packages/types/src/elementIds.ts @@ -9,6 +9,7 @@ export type FieldId = | 'currentPassword' | 'newPassword' | 'signOutOfOtherSessions' + | 'passkeyName' | 'password' | 'confirmPassword' | 'identifier' @@ -29,6 +30,7 @@ export type ProfileSectionId = | 'enterpriseAccounts' | 'web3Wallets' | 'password' + | 'passkeys' | 'mfa' | 'danger' | 'activeDevices' diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 9c9e12ae742..a4ac7110e2f 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -47,6 +47,10 @@ type _LocalizationResource = { formFieldLabel__organizationDomainDeletePending: LocalizationValue; formFieldLabel__confirmDeletion: LocalizationValue; formFieldLabel__role: LocalizationValue; + /** + * @experimental + */ + __experimental_formFieldLabel__passkeyName: LocalizationValue; formFieldInputPlaceholder__emailAddress: LocalizationValue; formFieldInputPlaceholder__emailAddresses: LocalizationValue; formFieldInputPlaceholder__phoneNumber: LocalizationValue; @@ -319,6 +323,14 @@ type _LocalizationResource = { primaryButton__updatePassword: LocalizationValue; primaryButton__setPassword: LocalizationValue; }; + /** + * @experimental + */ + __experimental_passkeysSection: { + title: LocalizationValue; + menuAction__rename: LocalizationValue; + menuAction__destructive: LocalizationValue; + }; mfaSection: { title: LocalizationValue; primaryButton: LocalizationValue; @@ -389,6 +401,17 @@ type _LocalizationResource = { successMessage: LocalizationValue; }; }; + /** + * @experimental + */ + __experimental_passkeyScreen: { + title__rename: LocalizationValue; + subtitle__rename: LocalizationValue; + removeResource: { + title: LocalizationValue; + messageLine1: LocalizationValue; + }; + }; phoneNumberPage: { title: LocalizationValue; verifyTitle: LocalizationValue;