feat(storage): enhance useStorageAsync with cross-instance sync and event handling
This commit is contained in:
@@ -17,6 +17,7 @@ export * from './useImage';
|
||||
export * from './useLocalFonts';
|
||||
export * from './useMediaQuery';
|
||||
export * from './useObjectUrl';
|
||||
export * from './useOtpCredentials';
|
||||
export * from './usePermission';
|
||||
export * from './usePreferredColorScheme';
|
||||
export * from './usePreferredContrast';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useOtpCredentials } from './index';
|
||||
|
||||
const { isSupported, code, isReceiving, receive, abort, onReceive, onError } = useOtpCredentials();
|
||||
|
||||
// A local, dismissible presentation error: the composable only clears its own
|
||||
// `error` when a new request starts, so we mirror it here and clear it whenever
|
||||
// the user takes over manually — otherwise a single failed read would pin the
|
||||
// status indicator permanently.
|
||||
const hint = ref<string | null>(null);
|
||||
onError(() => { hint.value = 'Couldn’t read the code automatically — enter it manually.'; });
|
||||
onReceive(() => { hint.value = null; });
|
||||
|
||||
const status = computed(() => {
|
||||
if (hint.value)
|
||||
return { label: hint.value, tone: 'error' as const };
|
||||
if (isReceiving.value)
|
||||
return { label: 'Waiting for the SMS code…', tone: 'pending' as const };
|
||||
if (code.value)
|
||||
return { label: 'Code received', tone: 'ok' as const };
|
||||
return { label: 'Idle', tone: 'idle' as const };
|
||||
});
|
||||
|
||||
function listen() {
|
||||
hint.value = null;
|
||||
receive();
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
hint.value = null;
|
||||
code.value = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
// WebOTP only works on a device that can receive the SMS (Chrome Android), so
|
||||
// on desktop we let you simulate the auto-fill the API would normally perform.
|
||||
function simulate() {
|
||||
hint.value = null;
|
||||
code.value = Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">WebOTP</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="isSupported
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||
>
|
||||
{{ isSupported ? 'supported' : 'unsupported' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:value="code ?? ''"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
placeholder="••••••"
|
||||
class="w-full rounded-xl border border-(--border) bg-(--bg-inset) px-4 py-3 text-center font-mono text-2xl tracking-[0.4em] tabular-nums text-(--fg) outline-none transition focus:border-(--accent)"
|
||||
@input="onInput"
|
||||
>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="!isReceiving"
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
|
||||
@click="listen"
|
||||
>
|
||||
Listen for code
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) px-3 py-1.5 text-sm font-medium text-(--fg-muted) transition hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||
@click="abort()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-lg border border-(--border) px-3 py-1.5 text-sm font-medium text-(--fg-muted) transition hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||
@click="simulate"
|
||||
>
|
||||
Simulate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
:class="{
|
||||
'bg-(--fg-subtle)': status.tone === 'idle',
|
||||
'animate-pulse bg-amber-500': status.tone === 'pending',
|
||||
'bg-emerald-500': status.tone === 'ok',
|
||||
'bg-red-500': status.tone === 'error',
|
||||
}"
|
||||
/>
|
||||
<span class="text-(--fg-muted)">{{ status.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,308 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useOtpCredentials } from '.';
|
||||
|
||||
/**
|
||||
* Build a fake `window`/`navigator` pair exposing the WebOTP surface. `get`
|
||||
* resolves with an `{ code }` credential by default; tests can override it to
|
||||
* reject (e.g. an AbortError) or hang.
|
||||
*/
|
||||
function createOtpEnv(get = vi.fn(async (_options?: CredentialRequestOptions) => ({ code: '123456' }))) {
|
||||
const window = { OTPCredential: class {} } as unknown as Window & typeof globalThis;
|
||||
const navigator = { credentials: { get } } as unknown as Navigator;
|
||||
return { window, navigator, get };
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake `get` that never resolves on its own — it rejects with an AbortError
|
||||
* the moment its signal aborts (or immediately if already aborted), mirroring
|
||||
* how a real `navigator.credentials.get` reacts to abortion.
|
||||
*/
|
||||
function abortableGet() {
|
||||
return vi.fn((options?: CredentialRequestOptions) =>
|
||||
new Promise<{ code: string }>((_resolve, reject) => {
|
||||
const signal = options!.signal!;
|
||||
const fail = (): void => reject(new DOMException('aborted', 'AbortError'));
|
||||
if (signal.aborted)
|
||||
fail();
|
||||
else
|
||||
signal.addEventListener('abort', fail);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
|
||||
const scope = effectScope();
|
||||
let result!: T;
|
||||
scope.run(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
describe(useOtpCredentials, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports supported when OTPCredential and navigator.credentials exist', () => {
|
||||
const { window, navigator } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
expect(result.isSupported.value).toBeTruthy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when OTPCredential is absent', () => {
|
||||
const window = {} as unknown as Window & typeof globalThis;
|
||||
const navigator = { credentials: { get: vi.fn() } } as unknown as Navigator;
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
expect(result.isSupported.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe and a no-op when window/navigator are undefined', async () => {
|
||||
const { result, stop } = withScope(() =>
|
||||
useOtpCredentials({
|
||||
window: undefined as unknown as Window,
|
||||
navigator: undefined as unknown as Navigator,
|
||||
}),
|
||||
);
|
||||
expect(result.isSupported.value).toBeFalsy();
|
||||
await expect(result.receive()).resolves.toBeUndefined();
|
||||
expect(result.code.value).toBeNull();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('defaults code to null and isReceiving to false', () => {
|
||||
const { window, navigator } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
expect(result.code.value).toBeNull();
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
expect(result.error.value).toBeNull();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('resolves with the code, updates code, and clears isReceiving', async () => {
|
||||
const { window, navigator, get } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const received = await result.receive();
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(received).toBe('123456');
|
||||
expect(result.code.value).toBe('123456');
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('requests the sms transport by default and forwards an abort signal', async () => {
|
||||
const { window, navigator, get } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
await result.receive();
|
||||
|
||||
const passed = get.mock.calls[0]![0]!;
|
||||
expect(passed.otp).toEqual({ transport: ['sms'] });
|
||||
expect(passed.signal).toBeInstanceOf(AbortSignal);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('honors a per-call transport override', async () => {
|
||||
const { window, navigator, get } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, transport: ['sms'] }));
|
||||
|
||||
await result.receive({ transport: [] as unknown as OTPTransportType[] });
|
||||
|
||||
expect(get.mock.calls[0]![0]!.otp).toEqual({ transport: [] });
|
||||
stop();
|
||||
});
|
||||
|
||||
it('fires onReceive listeners and the option callback with the code', async () => {
|
||||
const callback = vi.fn();
|
||||
const listener = vi.fn();
|
||||
const { window, navigator } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, onReceive: callback }));
|
||||
result.onReceive(listener);
|
||||
|
||||
await result.receive();
|
||||
|
||||
expect(callback).toHaveBeenCalledWith('123456');
|
||||
expect(listener).toHaveBeenCalledWith('123456');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('swallows AbortError without setting error or code', async () => {
|
||||
const get = vi.fn(async () => {
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
});
|
||||
const { window, navigator } = createOtpEnv(get);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
await expect(result.receive()).resolves.toBeUndefined();
|
||||
expect(result.error.value).toBeNull();
|
||||
expect(result.code.value).toBeNull();
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('surfaces non-abort errors via error ref, onError hook, and the callback', async () => {
|
||||
const failure = new Error('boom');
|
||||
const get = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
const callback = vi.fn();
|
||||
const listener = vi.fn();
|
||||
const { window, navigator } = createOtpEnv(get);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, onError: callback }));
|
||||
result.onError(listener);
|
||||
|
||||
await expect(result.receive()).resolves.toBeUndefined();
|
||||
|
||||
expect(result.error.value).toBe(failure);
|
||||
expect(callback).toHaveBeenCalledWith(failure);
|
||||
expect(listener).toHaveBeenCalledWith(failure);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('aborts the previous in-flight request when receive() is called again', async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
const get = vi.fn((options?: CredentialRequestOptions) =>
|
||||
new Promise<{ code: string }>((resolve, reject) => {
|
||||
const signal = options!.signal!;
|
||||
signals.push(signal);
|
||||
signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
// The second call resolves so the test can await a settled value.
|
||||
if (signals.length === 2)
|
||||
resolve({ code: '654321' });
|
||||
}),
|
||||
);
|
||||
const { window, navigator } = createOtpEnv(get as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const first = result.receive();
|
||||
const second = result.receive();
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBe('654321');
|
||||
|
||||
expect(signals[0]!.aborted).toBeTruthy();
|
||||
expect(result.code.value).toBe('654321');
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('does not let a superseded request that resolves late clobber the newer result', async () => {
|
||||
// A signal-unaware `get` (e.g. a polyfill, or an SMS landing as the abort
|
||||
// propagates): it resolves only when the test says so, ignoring abort.
|
||||
const resolvers: Array<(value: { code: string }) => void> = [];
|
||||
const get = vi.fn(() => new Promise<{ code: string }>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}));
|
||||
const onReceiveSpy = vi.fn();
|
||||
const { window, navigator } = createOtpEnv(get as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
result.onReceive(onReceiveSpy);
|
||||
|
||||
const first = result.receive(); // #1 -> resolvers[0]
|
||||
const second = result.receive(); // #2 supersedes #1 -> resolvers[1]
|
||||
|
||||
// The current request (#2) resolves first with the fresh code.
|
||||
resolvers[1]!({ code: 'FRESH2' });
|
||||
await expect(second).resolves.toBe('FRESH2');
|
||||
|
||||
// The superseded request (#1) resolves LATE with a stale code — it must be
|
||||
// ignored: no clobber of `code`, no onReceive with the stale value.
|
||||
resolvers[0]!({ code: 'STALE1' });
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result.code.value).toBe('FRESH2');
|
||||
expect(onReceiveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onReceiveSpy).toHaveBeenCalledWith('FRESH2');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('keeps isReceiving true while a superseded request settles and a newer one is still in flight', async () => {
|
||||
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const first = result.receive(); // A: pending
|
||||
void result.receive(); // B: supersedes A (aborts it), still pending
|
||||
|
||||
await expect(first).resolves.toBeUndefined(); // A's AbortError swallowed
|
||||
await Promise.resolve(); // let A's finally microtask run
|
||||
|
||||
// A is superseded, so its teardown must NOT clear B's in-flight flag.
|
||||
expect(result.isReceiving.value).toBeTruthy();
|
||||
|
||||
result.abort();
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('clears a previous error after a successful receive', async () => {
|
||||
const failure = new Error('boom');
|
||||
const get = vi.fn()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({ code: '123456' });
|
||||
const { window, navigator } = createOtpEnv(get as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
await result.receive();
|
||||
expect(result.error.value).toBe(failure);
|
||||
|
||||
const received = await result.receive();
|
||||
expect(received).toBe('123456');
|
||||
expect(result.error.value).toBeNull();
|
||||
expect(result.code.value).toBe('123456');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('abort() cancels the in-flight request and clears isReceiving', async () => {
|
||||
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const pending = result.receive();
|
||||
expect(result.isReceiving.value).toBeTruthy();
|
||||
|
||||
result.abort();
|
||||
expect(result.isReceiving.value).toBeFalsy();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('aborts when an already-aborted external signal is supplied', async () => {
|
||||
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(result.receive({ signal: controller.signal })).resolves.toBeUndefined();
|
||||
expect(result.error.value).toBeNull();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('aborts the request when the surrounding scope is disposed', async () => {
|
||||
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||
|
||||
const pending = result.receive();
|
||||
stop();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('starts listening immediately when immediate is true', async () => {
|
||||
const { window, navigator, get } = createOtpEnv();
|
||||
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, immediate: true }));
|
||||
|
||||
await nextTick();
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
// Allow the microtask chain from the immediate receive() to settle.
|
||||
await Promise.resolve();
|
||||
expect(result.code.value).toBe('123456');
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { shallowReadonly, shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultNavigator, defaultWindow } from '@/types';
|
||||
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { createEventHook } from '@/composables/utilities/createEventHook';
|
||||
import type { EventHookOn } from '@/composables/utilities/createEventHook';
|
||||
|
||||
/**
|
||||
* Per-call overrides for {@link UseOtpCredentialsReturn.receive}.
|
||||
*/
|
||||
export interface OtpCredentialsRequestOptions {
|
||||
/**
|
||||
* Transports the OTP may be delivered over. Currently only `'sms'` is
|
||||
* specified by the WebOTP API.
|
||||
*
|
||||
* @default the composable-level `transport` option (`['sms']`)
|
||||
*/
|
||||
transport?: OTPTransportType[];
|
||||
|
||||
/**
|
||||
* An external `AbortSignal` merged with the composable's own controller, so
|
||||
* the request is aborted when either fires (e.g. pair with
|
||||
* `AbortSignal.timeout(30_000)` to give up after 30s).
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface UseOtpCredentialsOptions extends ConfigurableWindow, ConfigurableNavigator {
|
||||
/**
|
||||
* Transports the OTP may be delivered over. Currently only `'sms'` is
|
||||
* specified by the WebOTP API.
|
||||
*
|
||||
* @default ['sms']
|
||||
*/
|
||||
transport?: OTPTransportType[];
|
||||
|
||||
/**
|
||||
* Begin listening for an OTP immediately (on the client). The request is a
|
||||
* no-op during SSR or when the API is unsupported.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* An external `AbortSignal` merged with the composable's own controller for
|
||||
* every `receive()` call.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
|
||||
/**
|
||||
* Called with the OTP code whenever one is received. Equivalent to
|
||||
* registering a listener via the returned `onReceive`.
|
||||
*
|
||||
* @default () => {}
|
||||
*/
|
||||
onReceive?: (code: string) => void;
|
||||
|
||||
/**
|
||||
* Called when a request fails for a reason other than abort. Equivalent to
|
||||
* registering a listener via the returned `onError`.
|
||||
*
|
||||
* @default () => {}
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseOtpCredentialsReturn {
|
||||
/**
|
||||
* Whether the [WebOTP API](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API) is supported.
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The most recently received OTP code, or `null` before the first one
|
||||
* arrives. Writable so consumers can clear it.
|
||||
*/
|
||||
code: ShallowRef<string | null>;
|
||||
|
||||
/**
|
||||
* Whether a request is currently in flight (waiting for the user to deliver
|
||||
* the OTP).
|
||||
*/
|
||||
isReceiving: Readonly<ShallowRef<boolean>>;
|
||||
|
||||
/**
|
||||
* The last non-abort error, or `null`. Aborts are part of normal lifecycle
|
||||
* and are never surfaced here.
|
||||
*/
|
||||
error: Readonly<ShallowRef<unknown>>;
|
||||
|
||||
/**
|
||||
* Start listening for an OTP. Resolves with the received code, or
|
||||
* `undefined` when the request is aborted, errors, or the API is
|
||||
* unsupported. Only one request can be active at a time — calling `receive`
|
||||
* again aborts the previous one. Never rejects; failures surface via
|
||||
* `error` / `onError`.
|
||||
*/
|
||||
receive: (overrideOptions?: OtpCredentialsRequestOptions) => Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Abort the in-flight request, if any.
|
||||
*/
|
||||
abort: () => void;
|
||||
|
||||
/**
|
||||
* Register a listener fired with the code each time an OTP is received.
|
||||
*/
|
||||
onReceive: EventHookOn<string>;
|
||||
|
||||
/**
|
||||
* Register a listener fired with the error when a request fails (non-abort).
|
||||
*/
|
||||
onError: EventHookOn<unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSPORT: OTPTransportType[] = ['sms'];
|
||||
|
||||
/**
|
||||
* @name useOtpCredentials
|
||||
* @category Browser
|
||||
* @description Reactive, SSR-safe wrapper around the [WebOTP API](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API)
|
||||
* (`navigator.credentials.get({ otp })`) for auto-reading one-time passwords
|
||||
* delivered by SMS. Exposes the received `code`, in-flight/error state,
|
||||
* `receive()`/`abort()` controls, and `onReceive`/`onError` hooks. Pairs with
|
||||
* an `<input autocomplete="one-time-code">`.
|
||||
*
|
||||
* @param {UseOtpCredentialsOptions} [options={}] Options (`transport`, `immediate`, `signal`, `onReceive`, `onError`, custom `window`/`navigator`)
|
||||
* @returns {UseOtpCredentialsReturn} `{ isSupported, code, isReceiving, error, receive, abort, onReceive, onError }`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, code, receive } = useOtpCredentials();
|
||||
* if (isSupported.value) {
|
||||
* const otp = await receive();
|
||||
* if (otp)
|
||||
* form.code = otp;
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Start listening on mount and react via the hook
|
||||
* const { onReceive } = useOtpCredentials({ immediate: true });
|
||||
* onReceive((code) => { form.code = code; });
|
||||
*
|
||||
* @example
|
||||
* // Give up after 30 seconds via an external signal
|
||||
* const { receive } = useOtpCredentials();
|
||||
* receive({ signal: AbortSignal.timeout(30_000) });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useOtpCredentials(options: UseOtpCredentialsOptions = {}): UseOtpCredentialsReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
navigator = defaultNavigator,
|
||||
transport = DEFAULT_TRANSPORT,
|
||||
immediate = false,
|
||||
signal: defaultSignal,
|
||||
onReceive: onReceiveCallback = noop,
|
||||
onError: onErrorCallback = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() =>
|
||||
!!window
|
||||
&& 'OTPCredential' in window
|
||||
&& !!navigator
|
||||
&& 'credentials' in navigator,
|
||||
);
|
||||
|
||||
const code = shallowRef<string | null>(null);
|
||||
const isReceiving = shallowRef(false);
|
||||
const error = shallowRef<unknown>(null);
|
||||
|
||||
const { on: onReceive, trigger: receiveTrigger } = createEventHook<string>();
|
||||
const { on: onError, trigger: errorTrigger } = createEventHook<unknown>();
|
||||
|
||||
if (onReceiveCallback !== noop)
|
||||
onReceive(onReceiveCallback);
|
||||
if (onErrorCallback !== noop)
|
||||
onError(onErrorCallback);
|
||||
|
||||
// Only one WebOTP request may be in flight at a time. We keep the active
|
||||
// controller so a new receive() (or abort()) can cancel the previous one.
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
function abort(): void {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
isReceiving.value = false;
|
||||
}
|
||||
|
||||
async function receive(overrideOptions: OtpCredentialsRequestOptions = {}): Promise<string | undefined> {
|
||||
if (!isSupported.value || !navigator)
|
||||
return undefined;
|
||||
|
||||
// Cancel any previous request before starting a new one.
|
||||
controller?.abort();
|
||||
|
||||
const ownController = new AbortController();
|
||||
controller = ownController;
|
||||
|
||||
// Merge an external signal: abort our controller when it fires.
|
||||
const externalSignal = overrideOptions.signal ?? defaultSignal;
|
||||
let unlinkExternal = noop;
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
ownController.abort();
|
||||
}
|
||||
else {
|
||||
const onExternalAbort = (): void => ownController.abort();
|
||||
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||
unlinkExternal = () => externalSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
isReceiving.value = true;
|
||||
|
||||
// A request is "current" only while it still owns the shared controller. A
|
||||
// newer receive() or an abort() replaces/clears it, after which this
|
||||
// request must neither commit its result nor tear down shared state — so a
|
||||
// superseded or late-resolving request can never clobber a fresher one.
|
||||
const isCurrent = (): boolean => controller === ownController;
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
otp: { transport: overrideOptions.transport ?? transport },
|
||||
signal: ownController.signal,
|
||||
}) as OTPCredential | null;
|
||||
|
||||
const received = credential?.code;
|
||||
if (!received || !isCurrent())
|
||||
return undefined;
|
||||
|
||||
code.value = received;
|
||||
receiveTrigger(received);
|
||||
|
||||
return received;
|
||||
}
|
||||
catch (err) {
|
||||
// Aborts are part of normal lifecycle (unmount, re-request, timeout) —
|
||||
// swallow them rather than surfacing as errors. A superseded request's
|
||||
// error is likewise stale and must not overwrite the live one's state.
|
||||
if (!isCurrent() || (err instanceof DOMException && err.name === 'AbortError'))
|
||||
return undefined;
|
||||
|
||||
error.value = err;
|
||||
errorTrigger(err);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
finally {
|
||||
unlinkExternal();
|
||||
// Only the latest request owns the shared state.
|
||||
if (isCurrent()) {
|
||||
controller = null;
|
||||
isReceiving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
receive();
|
||||
|
||||
tryOnScopeDispose(abort);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
code,
|
||||
isReceiving: shallowReadonly(isReceiving),
|
||||
error: shallowReadonly(error),
|
||||
receive,
|
||||
abort,
|
||||
onReceive,
|
||||
onError,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user