From e7fe55e21e3f16cd45ca4c1f28f69d25082b8fca Mon Sep 17 00:00:00 2001 From: Martin Kellner Date: Tue, 15 Sep 2026 12:10:53 +0200 Subject: [PATCH] Passkey-list delete events Expose the lifecycle of a passkey deletion in CorbadoConnectPasskeyList to the host page: - optional config callback onPasskeyDelete(event) with a discriminated union: start | cancel | success | error - DOM CustomEvents corbado:passkey-delete-{start,cancel,success,error} dispatched on the mount element (bubbling, composed) so tag managers can observe them on document without host code changes Co-Authored-By: Claude Fable 5.1 --- .../components/CorbadoConnectPasskeyList.tsx | 10 +++- .../components/passkeyList/DeleteModal.tsx | 4 +- .../passkeyList/PasskeyListScreen.tsx | 33 ++++++++++-- .../src/contexts/ManageProcessContext.ts | 3 ++ .../src/contexts/ManageProcessProvider.tsx | 13 +++-- packages/connect-react/src/index.ts | 1 + .../connect-react/src/utils/connectEvents.ts | 54 +++++++++++++++++++ packages/types/src/connect/config.ts | 50 +++++++++++++++++ 8 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 packages/connect-react/src/utils/connectEvents.ts diff --git a/packages/connect-react/src/components/CorbadoConnectPasskeyList.tsx b/packages/connect-react/src/components/CorbadoConnectPasskeyList.tsx index d3c53b4d7..5e26ddf1f 100644 --- a/packages/connect-react/src/components/CorbadoConnectPasskeyList.tsx +++ b/packages/connect-react/src/components/CorbadoConnectPasskeyList.tsx @@ -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 = config => { + const containerRef = useRef(null); + return ( -
+
diff --git a/packages/connect-react/src/components/passkeyList/DeleteModal.tsx b/packages/connect-react/src/components/passkeyList/DeleteModal.tsx index d46b3b181..04703c837 100644 --- a/packages/connect-react/src/components/passkeyList/DeleteModal.tsx +++ b/packages/connect-react/src/components/passkeyList/DeleteModal.tsx @@ -5,14 +5,14 @@ import { BaseModal } from '../shared/BaseModal'; import { PasskeyListItem } from '../shared/PasskeyListItem'; type Props = { - onDeleteClick: (id: string) => Promise; + onDeleteClick: (passkey: Passkey) => Promise; hide: () => void; passkey: Passkey; }; const DeleteModal = ({ onDeleteClick, hide, passkey }: Props) => ( onDeleteClick(passkey.id)} + onPrimaryButton={() => onDeleteClick(passkey)} onCloseButton={() => hide()} onSecondaryButton={() => hide()} headerText='Delete passkey' diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx index f380c34a0..aaa63c08e 100644 --- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx +++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx @@ -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'; @@ -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'; @@ -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(); @@ -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 () => { @@ -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( { // closing the confirmation without deleting => track as aborted deletion void getConnectService().recordEventManageDeleteExplicitAbort(); hide(); + notifyPasskeyDelete({ type: 'cancel', passkey: detail }); }} />, ); diff --git a/packages/connect-react/src/contexts/ManageProcessContext.ts b/packages/connect-react/src/contexts/ManageProcessContext.ts index 6eb1ba76c..6369e25bf 100644 --- a/packages/connect-react/src/contexts/ManageProcessContext.ts +++ b/packages/connect-react/src/contexts/ManageProcessContext.ts @@ -1,4 +1,5 @@ import type { CorbadoConnectPasskeyListConfig } from '@corbado/types'; +import type { RefObject } from 'react'; import { createContext } from 'react'; import { ManageScreenType } from '../types/screenTypes'; @@ -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; navigateToScreen: (s: ManageScreenType) => void; setPasskeyListToken: (t: string) => void; } diff --git a/packages/connect-react/src/contexts/ManageProcessProvider.tsx b/packages/connect-react/src/contexts/ManageProcessProvider.tsx index c122cb914..fa7bbc499 100644 --- a/packages/connect-react/src/contexts/ManageProcessProvider.tsx +++ b/packages/connect-react/src/contexts/ManageProcessProvider.tsx @@ -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'; @@ -9,9 +9,15 @@ import ManageProcessContext from './ManageProcessContext'; type Props = { config: CorbadoConnectPasskeyListConfig; initialScreenType: ManageScreenType; + containerRef?: RefObject; }; -export const ManageProcessProvider: FC> = ({ children, initialScreenType, config }) => { +export const ManageProcessProvider: FC> = ({ + children, + initialScreenType, + config, + containerRef, +}) => { const [currentScreenType, setCurrentScreenType] = useState(initialScreenType); const [passkeyListToken, setPasskeyListToken] = useState(''); @@ -26,8 +32,9 @@ export const ManageProcessProvider: FC> = ({ children, passkeyListToken, setPasskeyListToken, config, + containerRef, }), - [currentScreenType, navigateToScreen, config, setPasskeyListToken, passkeyListToken], + [currentScreenType, navigateToScreen, config, containerRef, setPasskeyListToken, passkeyListToken], ); return {children}; diff --git a/packages/connect-react/src/index.ts b/packages/connect-react/src/index.ts index b8f35f190..c4aea4965 100644 --- a/packages/connect-react/src/index.ts +++ b/packages/connect-react/src/index.ts @@ -19,3 +19,4 @@ export { }; export type { CorbadoConnectProviderProps } from './components/CorbadoConnectProvider'; +export { CONNECT_PASSKEY_DELETE_EVENTS } from './utils/connectEvents'; diff --git a/packages/connect-react/src/utils/connectEvents.ts b/packages/connect-react/src/utils/connectEvents.ts new file mode 100644 index 000000000..59c0aec6b --- /dev/null +++ b/packages/connect-react/src/utils/connectEvents.ts @@ -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 = { + 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); + } +}; diff --git a/packages/types/src/connect/config.ts b/packages/types/src/connect/config.ts index c45ce6c04..3b0a5f761 100644 --- a/packages/types/src/connect/config.ts +++ b/packages/types/src/connect/config.ts @@ -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>; + 'corbado:passkey-delete-cancel': CustomEvent>; + 'corbado:passkey-delete-success': CustomEvent>; + 'corbado:passkey-delete-error': CustomEvent>; +}; + +export type ConnectPasskeyListEventName = keyof ConnectPasskeyListEventMap; + export type CorbadoConnectPasskeyListConfig = { connectTokenProvider: (type: ConnectTokenType) => Promise; + /** Called on every step of a passkey deletion (start, cancel, success, error). */ + onPasskeyDelete?: (event: ConnectPasskeyDeleteEvent) => void; }; export type CorbadoConnectConfig = {