typescript
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
/* global PublicKeyCredentialCreationOptions, CredentialCreationOptions */
|
||||
|
||||
import {
|
||||
isWebAuthnAvailable,
|
||||
preparePublicKeyCreationOptions,
|
||||
serializeRegistrationCredential,
|
||||
} from '../utils/webauthn';
|
||||
|
||||
type ApiClient = {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
|
||||
delete: (path: string) => Promise<{ data: unknown }>;
|
||||
};
|
||||
|
||||
type StatusMessageFn = (message: string, variant?: string) => void;
|
||||
type NotifyApiErrorFn = (error: unknown, message: string) => void;
|
||||
|
||||
type ApiError = {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export interface PasskeyRecord {
|
||||
id?: string | number;
|
||||
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';
|
||||
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 };
|
||||
|
||||
interface UsePasskeysArgs {
|
||||
api: ApiClient;
|
||||
notifyApiError: NotifyApiErrorFn;
|
||||
setStatusMessage: StatusMessageFn;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface UsePasskeysResult {
|
||||
passkeys: PasskeyRecord[];
|
||||
passkeysSupported: boolean | null;
|
||||
passkeysLoading: boolean;
|
||||
registeringPasskey: boolean;
|
||||
revokingPasskeyId: string | number | null;
|
||||
refreshPasskeys: () => Promise<void>;
|
||||
registerPasskey: (options?: { nickname?: string }) => Promise<RegisterPasskeyResult>;
|
||||
revokePasskey: (
|
||||
passkeyId: string | number | null | undefined,
|
||||
reason?: string,
|
||||
) => Promise<RevokePasskeyResult>;
|
||||
}
|
||||
|
||||
const usePasskeys = ({ api, notifyApiError, setStatusMessage, 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<string | number | null>(null);
|
||||
|
||||
const refreshPasskeys = useCallback(async (): Promise<void> => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setPasskeysLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/profile/passkeys');
|
||||
const passkeyData = Array.isArray(data) ? (data as PasskeyRecord[]) : [];
|
||||
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);
|
||||
}
|
||||
}, [api, notifyApiError, token]);
|
||||
|
||||
const registerPasskey = useCallback(
|
||||
async ({ nickname }: { nickname?: string } = {}): Promise<RegisterPasskeyResult> => {
|
||||
if (!isWebAuthnAvailable()) {
|
||||
setPasskeysSupported(false);
|
||||
setStatusMessage('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 api.post('/auth/passkeys/register/start', {});
|
||||
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 api.post('/auth/passkeys/register/finish', payload);
|
||||
await refreshPasskeys();
|
||||
setPasskeysSupported(true);
|
||||
setStatusMessage('Passkey registered.', 'success');
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const typedError = error as ApiError;
|
||||
if (typedError?.name === 'NotAllowedError') {
|
||||
setStatusMessage('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);
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
|
||||
);
|
||||
|
||||
const revokePasskey = useCallback(
|
||||
async (
|
||||
passkeyId: string | number | null | undefined,
|
||||
reason?: string,
|
||||
): Promise<RevokePasskeyResult> => {
|
||||
if (!passkeyId) {
|
||||
return { ok: false, reason: 'missing-id' };
|
||||
}
|
||||
setRevokingPasskeyId(passkeyId);
|
||||
try {
|
||||
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
|
||||
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
|
||||
await refreshPasskeys();
|
||||
setStatusMessage('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);
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, refreshPasskeys, setStatusMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePasskeys;
|
||||
Reference in New Issue
Block a user