typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
+232
View File
@@ -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;