Skip to content
9 changes: 9 additions & 0 deletions .changeset/calm-kings-draw.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/resources/Passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class Passkey extends BaseResource implements PasskeyResource {
delete = async (): Promise<DeletedObjectResource> => {
const json = (
await BaseResource._fetch<DeletedObjectJSON>({
path: `${this.path()}/${this.id}`,
path: this.path(),
method: 'DELETE',
})
)?.response as unknown as DeletedObjectJSON;
Expand Down
211 changes: 211 additions & 0 deletions packages/clerk-js/src/ui/components/UserProfile/PasskeySection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RemovePasskeyForm
onSuccess={close}
onReset={close}
{...props}
/>
);
};

type PasskeyScreenProps = { passkey: PasskeyResource };

type UpdatePasskeyFormProps = FormProps & PasskeyScreenProps;
const PasskeyScreen = (props: PasskeyScreenProps) => {
const { close } = useActionContext();
return (
<UpdatePasskeyForm
onSuccess={close}
onReset={close}
passkey={props.passkey}
/>
);
};

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 (
<FormContainer
headerTitle={localizationKeys('userProfile.__experimental_passkeyScreen.title__rename')}
headerSubtitle={localizationKeys('userProfile.__experimental_passkeyScreen.subtitle__rename')}
>
<Form.Root onSubmit={addEmail}>
<Form.ControlRow elementId={passkeyNameField.id}>
<Form.PlainInput
{...passkeyNameField.props}
autoComplete={'off'}
/>
</Form.ControlRow>
<FormButtons
submitLabel={localizationKeys('userProfile.formButtonPrimary__save')}
isDisabled={!canSubmit}
onReset={onReset}
/>
</Form.Root>
</FormContainer>
);
});

export const PasskeySection = () => {
const { user } = useUser();

if (!user) {
return null;
}

return (
<ProfileSection.Root
title={localizationKeys('userProfile.start.__experimental_passkeysSection.title')}
centered={false}
id='passkeys'
>
<Action.Root>
<ProfileSection.ItemList id='passkeys'>
{user.__experimental_passkeys.map(passkey => (
<Action.Root key={passkey.id}>
<PasskeyItem
key={passkey.id}
{...passkey}
/>

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemovePasskeyScreen passkey={passkey} />
</Action.Card>
</Action.Open>

<Action.Open value='rename'>
<Action.Card>
<PasskeyScreen passkey={passkey} />
</Action.Card>
</Action.Open>
</Action.Root>
))}

<AddPasskeyButton />
</ProfileSection.ItemList>
</Action.Root>
</ProfileSection.Root>
);
};

const PasskeyItem = (props: PasskeyResource) => {
return (
<ProfileSection.Item
id='passkeys'
hoverable
sx={{
alignItems: 'flex-start',
}}
>
<PasskeyInfo {...props} />
<ActiveDeviceMenu />
</ProfileSection.Item>
);
};

const PasskeyInfo = (props: PasskeyResource) => {
const { name, createdAt, lastUsedAt } = props;
const { t } = useLocalizations();

return (
<Flex
sx={t => ({
width: '100%',
overflow: 'hidden',
gap: t.space.$4,
[mqu.sm]: { gap: t.space.$2 },
})}
>
<Col
align='start'
gap={1}
>
<Text>{name}</Text>
<Text colorScheme='neutral'>Created: {t(getRelativeToNowDateKey(createdAt))}</Text>
{lastUsedAt && <Text colorScheme='neutral'>Last used: {t(getRelativeToNowDateKey(lastUsedAt))}</Text>}
</Col>
</Flex>
);
};

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<typeof ThreeDotsMenu>['actions'];

return <ThreeDotsMenu actions={actions} />;
};

// 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 (
<ProfileSection.ArrowButton
id='passkeys'
localizationKey={'Add a passkey'}
onClick={handleCreatePasskey}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useUser } from '@clerk/shared/react';
import type { PasskeyResource } from '@clerk/types';
import React from 'react';

import { RemoveResourceForm } from '../../common';
Expand Down Expand Up @@ -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 (
<RemoveResourceForm
title={localizationKeys('userProfile.__experimental_passkeyScreen.removeResource.title')}
messageLine1={localizationKeys('userProfile.__experimental_passkeyScreen.removeResource.messageLine1', {
name: passkey.name,
})}
deleteResource={passkey.delete}
onSuccess={onSuccess}
onReset={onReset}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;

Expand All @@ -35,6 +37,7 @@ export const SecurityPage = withCardStateProvider(() => {
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
{showPassword && <PasswordSection />}
{showPasskey && <PasskeySection />}
{showMfa && <MfaSection />}
<ActiveDevicesSection />
{showDelete && <DeleteSection />}
Expand Down
Loading