231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useStatusToast } from '../lib/context/StatusToastContext';
|
|
/* global PublicKeyCredentialCreationOptions, CredentialCreationOptions */
|
|
|
|
import {
|
|
isWebAuthnAvailable,
|
|
preparePublicKeyCreationOptions,
|
|
serializeRegistrationCredential,
|
|
} from '../utils/webauthn';
|
|
import {
|
|
deletePasskey,
|
|
finishPasskeyRegistration,
|
|
listPasskeys,
|
|
startPasskeyRegistration,
|
|
} from '../lib/api/apiClient';
|
|
import type { PasskeyId } from '../types/identifiers';
|
|
|
|
type ApiError = {
|
|
response?: {
|
|
status?: number;
|
|
data?: {
|
|
error?: string;
|
|
};
|
|
};
|
|
name?: string;
|
|
};
|
|
|
|
export interface PasskeyRecord {
|
|
id?: PasskeyId;
|
|
nickname?: string;
|
|
created_at?: string;
|
|
createdAt?: string;
|
|
last_used_at?: string;
|
|
lastUsedAt?: string;
|
|
revoked_at?: string;
|
|
revokedAt?: string;
|
|
revoked_reason?: string;
|
|
revokedReason?: string;
|
|
transports?: string[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface PasskeyChallengeResponse {
|
|
challengeId?: string;
|
|
challenge_id?: string;
|
|
publicKey?: PublicKeyCredentialCreationOptions;
|
|
public_key?: PublicKeyCredentialCreationOptions;
|
|
challenge?: {
|
|
publicKey?: PublicKeyCredentialCreationOptions;
|
|
};
|
|
publicKeyCredentialCreationOptions?: PublicKeyCredentialCreationOptions;
|
|
}
|
|
|
|
interface PasskeyRegisterPayload {
|
|
challengeId: string;
|
|
credential: unknown;
|
|
nickname?: string;
|
|
}
|
|
|
|
type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error';
|
|
export type RegisterPasskeyResult =
|
|
| { ok: true }
|
|
| { ok: false; reason: RegisterPasskeyFailureReason; message?: string };
|
|
|
|
type RevokePasskeyFailureReason = 'missing-id' | 'error';
|
|
type RevokePasskeyResult =
|
|
| { ok: true }
|
|
| { ok: false; reason: RevokePasskeyFailureReason; message?: string };
|
|
|
|
import useNotifyApiError from '../hooks/useNotifyApiError';
|
|
|
|
interface UsePasskeysArgs {
|
|
token?: string | null;
|
|
}
|
|
|
|
interface UsePasskeysResult {
|
|
passkeys: PasskeyRecord[];
|
|
passkeysSupported: boolean | null;
|
|
passkeysLoading: boolean;
|
|
registeringPasskey: boolean;
|
|
revokingPasskeyId: PasskeyId | null;
|
|
refreshPasskeys: () => Promise<void>;
|
|
registerPasskey: (options?: { nickname?: string }) => Promise<RegisterPasskeyResult>;
|
|
revokePasskey: (
|
|
passkeyId: PasskeyId,
|
|
reason?: string,
|
|
) => Promise<RevokePasskeyResult>;
|
|
}
|
|
|
|
const usePasskeys = ({ token }: UsePasskeysArgs): UsePasskeysResult => {
|
|
const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
|
|
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
|
|
const [passkeysLoading, setPasskeysLoading] = useState(false);
|
|
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
|
const [revokingPasskeyId, setRevokingPasskeyId] = useState<PasskeyId | null>(null);
|
|
const { showToast } = useStatusToast();
|
|
const notifyApiError = useNotifyApiError();
|
|
|
|
const refreshPasskeys = useCallback(async (): Promise<void> => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
setPasskeysLoading(true);
|
|
try {
|
|
const passkeyData = await listPasskeys();
|
|
setPasskeys(passkeyData);
|
|
setPasskeysSupported(true);
|
|
} catch (error) {
|
|
const status = (error as ApiError)?.response?.status;
|
|
if (status === 400 || status === 404) {
|
|
setPasskeysSupported(false);
|
|
setPasskeys([]);
|
|
} else {
|
|
notifyApiError(error, 'Failed to load passkeys.');
|
|
}
|
|
} finally {
|
|
setPasskeysLoading(false);
|
|
}
|
|
}, [notifyApiError, token]);
|
|
|
|
const registerPasskey = useCallback(
|
|
async ({ nickname }: { nickname?: string } = {}): Promise<RegisterPasskeyResult> => {
|
|
if (!isWebAuthnAvailable()) {
|
|
setPasskeysSupported(false);
|
|
showToast('Passkeys are not supported in this browser.', 'error');
|
|
return { ok: false, reason: 'unsupported' };
|
|
}
|
|
if (registeringPasskey) {
|
|
return { ok: false, reason: 'busy' };
|
|
}
|
|
|
|
setRegisteringPasskey(true);
|
|
try {
|
|
const data = await startPasskeyRegistration();
|
|
const challengeData = data as PasskeyChallengeResponse | undefined;
|
|
const challengeId = challengeData?.challengeId || challengeData?.challenge_id;
|
|
const publicKeyOptions =
|
|
challengeData?.publicKey
|
|
|| challengeData?.public_key
|
|
|| challengeData?.challenge?.publicKey
|
|
|| challengeData?.publicKeyCredentialCreationOptions;
|
|
|
|
if (!challengeId || !publicKeyOptions) {
|
|
throw new Error('Invalid passkey challenge response.');
|
|
}
|
|
|
|
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
|
const credential = (await navigator.credentials.create({
|
|
publicKey,
|
|
} as CredentialCreationOptions)) as PublicKeyCredential | null;
|
|
|
|
if (!credential) {
|
|
return { ok: false, reason: 'cancelled' };
|
|
}
|
|
|
|
const serialized = serializeRegistrationCredential(credential);
|
|
const payload: PasskeyRegisterPayload = {
|
|
challengeId,
|
|
credential: serialized,
|
|
};
|
|
const trimmedNickname = nickname?.trim?.();
|
|
if (trimmedNickname) {
|
|
payload.nickname = trimmedNickname;
|
|
}
|
|
|
|
await finishPasskeyRegistration(payload);
|
|
await refreshPasskeys();
|
|
setPasskeysSupported(true);
|
|
showToast('Passkey registered.', 'success');
|
|
return { ok: true };
|
|
} catch (error) {
|
|
const typedError = error as ApiError;
|
|
if (typedError?.name === 'NotAllowedError') {
|
|
showToast('Passkey registration cancelled.', 'info');
|
|
return { ok: false, reason: 'cancelled' };
|
|
}
|
|
|
|
const status = typedError?.response?.status;
|
|
if (status === 400 || status === 404) {
|
|
setPasskeysSupported(false);
|
|
}
|
|
|
|
const message = typedError?.response?.data?.error || 'Failed to register passkey.';
|
|
notifyApiError(error, message);
|
|
return { ok: false, reason: 'error', message };
|
|
} finally {
|
|
setRegisteringPasskey(false);
|
|
}
|
|
},
|
|
[notifyApiError, refreshPasskeys, registeringPasskey, showToast],
|
|
);
|
|
|
|
const revokePasskey = useCallback(
|
|
async (
|
|
passkeyId: PasskeyId,
|
|
reason?: string,
|
|
): Promise<RevokePasskeyResult> => {
|
|
if (passkeyId == null) {
|
|
return { ok: false, reason: 'missing-id' };
|
|
}
|
|
setRevokingPasskeyId(passkeyId);
|
|
try {
|
|
await deletePasskey(passkeyId, { reason });
|
|
await refreshPasskeys();
|
|
showToast('Passkey revoked.', 'success');
|
|
return { ok: true };
|
|
} catch (error) {
|
|
const message = (error as ApiError)?.response?.data?.error || 'Failed to revoke passkey.';
|
|
notifyApiError(error, message);
|
|
return { ok: false, reason: 'error', message };
|
|
} finally {
|
|
setRevokingPasskeyId(null);
|
|
}
|
|
},
|
|
[notifyApiError, refreshPasskeys, showToast],
|
|
);
|
|
|
|
return {
|
|
passkeys,
|
|
passkeysSupported,
|
|
passkeysLoading,
|
|
registeringPasskey,
|
|
revokingPasskeyId,
|
|
refreshPasskeys,
|
|
registerPasskey,
|
|
revokePasskey,
|
|
};
|
|
};
|
|
|
|
export default usePasskeys;
|