Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import type { CorbadoConnectPasskeyListConfig } from '@corbado/types';
import type { FC } from 'react';
import React from 'react';
import React, { useRef } from 'react';

import ManageProcessProvider from '../contexts/ManageProcessProvider';
import { ManageScreenType } from '../types/screenTypes';
import CorbadoConnectPasskeyListContainer from './passkeyList/CorbadoConnectPasskeyListContainer';

const CorbadoConnectPasskeyList: FC<CorbadoConnectPasskeyListConfig> = config => {
const containerRef = useRef<HTMLDivElement>(null);

return (
<div className='cb-connect-container cb-connect-passkey-list'>
<div
className='cb-connect-container cb-connect-passkey-list'
ref={containerRef}
>
<ManageProcessProvider
config={config}
initialScreenType={ManageScreenType.Init}
containerRef={containerRef}
>
<CorbadoConnectPasskeyListContainer />
</ManageProcessProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { BaseModal } from '../shared/BaseModal';
import { PasskeyListItem } from '../shared/PasskeyListItem';

type Props = {
onDeleteClick: (id: string) => Promise<void>;
onDeleteClick: (passkey: Passkey) => Promise<void>;
hide: () => void;
passkey: Passkey;
};

const DeleteModal = ({ onDeleteClick, hide, passkey }: Props) => (
<BaseModal
onPrimaryButton={() => onDeleteClick(passkey.id)}
onPrimaryButton={() => onDeleteClick(passkey)}
onCloseButton={() => hide()}
onSecondaryButton={() => hide()}
headerText='Delete passkey'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CorbadoConnectPasskeyListConfig } from '@corbado/types';
import type { ConnectPasskeyDeleteEvent, CorbadoConnectPasskeyListConfig } from '@corbado/types';
import type { ConnectError, Passkey } from '@corbado/web-core';
import { ConnectErrorType } from '@corbado/web-core';
import log from 'loglevel';
Expand All @@ -9,6 +9,7 @@ import useModal from '../../hooks/useModal';
import useShared from '../../hooks/useShared';
import { getPasskeyListErrorMessage, PasskeyListSituationCode } from '../../types/situations';
import { ConnectTokenType } from '../../types/tokens';
import { emitPasskeyDeleteEvent, toPasskeyDeleteDetail } from '../../utils/connectEvents';
import { withLowEventWindow } from '../../utils/lowEventWindow';
import { StatefulLoader } from '../../utils/statefulLoader';
import AlreadyExistingModal from './AlreadyExistingModal';
Expand All @@ -18,7 +19,7 @@ import PasskeyAppendNotSupportedModal from './PasskeyAppendNotSupportedModal';
import PasskeyList, { PasskeyListState } from './PasskeyList';

const PasskeyListScreen = () => {
const { config } = useManageProcess();
const { config, containerRef } = useManageProcess();
const { setPasskeyListToken, passkeyListToken } = useManageProcess();
const { show, hide } = useModal();
const { getConnectService } = useShared();
Expand Down Expand Up @@ -82,25 +83,41 @@ const PasskeyListScreen = () => {
};
}, [getConnectService]);

const onDeleteClick = async (credentialsId?: string) => {
const notifyPasskeyDelete = (event: ConnectPasskeyDeleteEvent) =>
emitPasskeyDeleteEvent(containerRef?.current, config.onPasskeyDelete, event);

const onDeleteClick = async (passkey: Passkey) => {
const credentialsId = passkey.id;
if (!credentialsId) {
return;
}

const detail = toPasskeyDeleteDetail(passkey);
const onDeleteError = (situationCode: PasskeyListSituationCode, error?: ConnectError) => {
notifyPasskeyDelete({ type: 'error', passkey: detail, reason: PasskeyListSituationCode[situationCode] });

return handleSituation(situationCode, error);
};

let deleteToken;
try {
deleteToken = await config.connectTokenProvider(ConnectTokenType.PasskeyDelete);
} catch {
return handleSituation(PasskeyListSituationCode.CtApiNotAvailablePreDelete);
return onDeleteError(PasskeyListSituationCode.CtApiNotAvailablePreDelete);
}

const deletePasskeyRes = await getConnectService().manageDelete(deleteToken, credentialsId);
if (deletePasskeyRes.err) {
return handleSituation(PasskeyListSituationCode.CboApiNotAvailableDuringDelete, deletePasskeyRes.val);
return onDeleteError(PasskeyListSituationCode.CboApiNotAvailableDuringDelete, deletePasskeyRes.val);
}

const remainingPasskeys = passkeyList.filter(p => p.id !== credentialsId).length;

await getPasskeyList(config, true);
hide();

// the passkey is gone and the confirmation modal is closed => notify the host page
notifyPasskeyDelete({ type: 'success', passkey: detail, remainingPasskeys });
};

const onAppendClick = async () => {
Expand Down Expand Up @@ -248,6 +265,11 @@ const PasskeyListScreen = () => {
passkeys={passkeyList}
onDeleteClick={passkey => {
setErrorMessage(null);

// the user clicked the delete icon => let the host page know before the confirmation modal opens
const detail = toPasskeyDeleteDetail(passkey);
notifyPasskeyDelete({ type: 'start', passkey: detail });

show(
<DeleteModal
passkey={passkey}
Expand All @@ -256,6 +278,7 @@ const PasskeyListScreen = () => {
// closing the confirmation without deleting => track as aborted deletion
void getConnectService().recordEventManageDeleteExplicitAbort();
hide();
notifyPasskeyDelete({ type: 'cancel', passkey: detail });
}}
/>,
);
Expand Down
3 changes: 3 additions & 0 deletions packages/connect-react/src/contexts/ManageProcessContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CorbadoConnectPasskeyListConfig } from '@corbado/types';
import type { RefObject } from 'react';
import { createContext } from 'react';

import { ManageScreenType } from '../types/screenTypes';
Expand All @@ -11,6 +12,8 @@ export interface ManageProcessContextProps {
currentScreenType: ManageScreenType;
passkeyListToken: string;
config: CorbadoConnectPasskeyListConfig;
/** Element the component is mounted in; DOM events for the host page are dispatched on it. */
containerRef?: RefObject<HTMLElement>;
navigateToScreen: (s: ManageScreenType) => void;
setPasskeyListToken: (t: string) => void;
}
Expand Down
13 changes: 10 additions & 3 deletions packages/connect-react/src/contexts/ManageProcessProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CorbadoConnectPasskeyListConfig } from '@corbado/types';
import type { FC, PropsWithChildren } from 'react';
import type { FC, PropsWithChildren, RefObject } from 'react';
import React, { useCallback, useMemo, useState } from 'react';

import type { ManageScreenType } from '../types/screenTypes';
Expand All @@ -9,9 +9,15 @@ import ManageProcessContext from './ManageProcessContext';
type Props = {
config: CorbadoConnectPasskeyListConfig;
initialScreenType: ManageScreenType;
containerRef?: RefObject<HTMLElement>;
};

export const ManageProcessProvider: FC<PropsWithChildren<Props>> = ({ children, initialScreenType, config }) => {
export const ManageProcessProvider: FC<PropsWithChildren<Props>> = ({
children,
initialScreenType,
config,
containerRef,
}) => {
const [currentScreenType, setCurrentScreenType] = useState<ManageScreenType>(initialScreenType);
const [passkeyListToken, setPasskeyListToken] = useState<string>('');

Expand All @@ -26,8 +32,9 @@ export const ManageProcessProvider: FC<PropsWithChildren<Props>> = ({ children,
passkeyListToken,
setPasskeyListToken,
config,
containerRef,
}),
[currentScreenType, navigateToScreen, config, setPasskeyListToken, passkeyListToken],
[currentScreenType, navigateToScreen, config, containerRef, setPasskeyListToken, passkeyListToken],
);

return <ManageProcessContext.Provider value={contextValue}>{children}</ManageProcessContext.Provider>;
Expand Down
1 change: 1 addition & 0 deletions packages/connect-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export {
};

export type { CorbadoConnectProviderProps } from './components/CorbadoConnectProvider';
export { CONNECT_PASSKEY_DELETE_EVENTS } from './utils/connectEvents';
54 changes: 54 additions & 0 deletions packages/connect-react/src/utils/connectEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {
ConnectPasskeyDeleteDetail,
ConnectPasskeyDeleteEvent,
ConnectPasskeyDeleteEventType,
ConnectPasskeyListEventName,
} from '@corbado/types';
import type { Passkey } from '@corbado/web-core';
import log from 'loglevel';

// runtime counterparts of ConnectPasskeyListEventMap (@corbado/types ships declarations only)
export const CONNECT_PASSKEY_DELETE_EVENTS: Record<ConnectPasskeyDeleteEventType, ConnectPasskeyListEventName> = {
start: 'corbado:passkey-delete-start',
cancel: 'corbado:passkey-delete-cancel',
success: 'corbado:passkey-delete-success',
error: 'corbado:passkey-delete-error',
};

export const toPasskeyDeleteDetail = (passkey: Passkey): ConnectPasskeyDeleteDetail => ({
passkeyId: passkey.id,
name: passkey.aaguidDetails.name,
browser: passkey.sourceBrowser,
os: passkey.sourceOS,
isSynced: passkey.backupState,
isHybrid: passkey.transport.includes('hybrid'),
createdAt: passkey.created,
lastUsed: passkey.lastUsed,
});

/**
* Notifies the host page about a step of a passkey deletion, through the optional config callback and through a
* bubbling, composed DOM CustomEvent on the mount element (falls back to `document`).
*
* Errors thrown by host code are swallowed on purpose: a broken listener must never break the passkey flow.
*/
export const emitPasskeyDeleteEvent = (
target: EventTarget | null | undefined,
callback: ((event: ConnectPasskeyDeleteEvent) => void) | undefined,
event: ConnectPasskeyDeleteEvent,
): void => {
if (callback) {
try {
callback(event);
} catch (e) {
log.debug('onPasskeyDelete callback failed', e);
}
}

const name = CONNECT_PASSKEY_DELETE_EVENTS[event.type];
try {
(target ?? document).dispatchEvent(new CustomEvent(name, { detail: event, bubbles: true, composed: true }));
} catch (e) {
log.debug(`dispatching ${name} failed`, e);
}
};
50 changes: 50 additions & 0 deletions packages/types/src/connect/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,58 @@ export enum ConnectTokenType {
PasskeyDelete = 'passkey-delete',
}

/**
* Metadata about the passkey a user deletes from CorbadoConnectPasskeyList.
* It deliberately contains no credential material, so it is safe to forward to analytics or survey tools.
*/
export type ConnectPasskeyDeleteDetail = {
passkeyId: string;
name: string;
browser: string;
os: string;
isSynced: boolean;
isHybrid: boolean;
createdAt: string;
lastUsed: string;
};

/**
* Lifecycle of a passkey deletion in CorbadoConnectPasskeyList:
* - `start`: the user clicked the delete icon of a passkey; the confirmation is shown next
* - `cancel`: the user dismissed the confirmation without deleting
* - `success`: the passkey was deleted and the list has been refreshed
* - `error`: the user confirmed, but the deletion failed (`reason` names the failing step)
*/
export type ConnectPasskeyDeleteEvent =
| { type: 'start'; passkey: ConnectPasskeyDeleteDetail }
| { type: 'cancel'; passkey: ConnectPasskeyDeleteDetail }
| { type: 'success'; passkey: ConnectPasskeyDeleteDetail; remainingPasskeys: number }
| { type: 'error'; passkey: ConnectPasskeyDeleteDetail; reason: string };

export type ConnectPasskeyDeleteEventType = ConnectPasskeyDeleteEvent['type'];

/**
* DOM CustomEvents dispatched by CorbadoConnectPasskeyList, keyed by event name. The name suffix equals the `type`
* of the ConnectPasskeyDeleteEvent carried in `event.detail`, so one listener can reuse the same switch as the
* `onPasskeyDelete` callback.
*
* Events are dispatched on the element the component is mounted on. They bubble and are composed, so they can also
* be observed on `document` or `window`, e.g. by a tag manager. The runtime constants for the names are exported by
* `@corbado/connect-react` (this package contains type declarations only).
*/
export type ConnectPasskeyListEventMap = {
'corbado:passkey-delete-start': CustomEvent<Extract<ConnectPasskeyDeleteEvent, { type: 'start' }>>;
'corbado:passkey-delete-cancel': CustomEvent<Extract<ConnectPasskeyDeleteEvent, { type: 'cancel' }>>;
'corbado:passkey-delete-success': CustomEvent<Extract<ConnectPasskeyDeleteEvent, { type: 'success' }>>;
'corbado:passkey-delete-error': CustomEvent<Extract<ConnectPasskeyDeleteEvent, { type: 'error' }>>;
};

export type ConnectPasskeyListEventName = keyof ConnectPasskeyListEventMap;

export type CorbadoConnectPasskeyListConfig = {
connectTokenProvider: (type: ConnectTokenType) => Promise<string>;
/** Called on every step of a passkey deletion (start, cancel, success, error). */
onPasskeyDelete?: (event: ConnectPasskeyDeleteEvent) => void;
};

export type CorbadoConnectConfig = {
Expand Down
Loading