@siymo/otp-sdk-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/dist/buildConfig.d.ts +2 -0
- package/dist/buildConfig.js +8 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +16 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.js +1 -0
- package/dist/useInboundCallOtp.d.ts +2 -0
- package/dist/useInboundCallOtp.js +53 -0
- package/dist/useInboundOtp.d.ts +42 -0
- package/dist/useInboundOtp.js +646 -0
- package/dist/useInboundSmsOtp.d.ts +2 -0
- package/dist/useInboundSmsOtp.js +65 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# siymo-otp-react-sdk
|
|
2
|
+
|
|
3
|
+
React hooks SDK for inbound OTP client flows, powered by `@siymo/otp-sdk-core`.
|
|
4
|
+
|
|
5
|
+
## What It Provides
|
|
6
|
+
|
|
7
|
+
- `useInboundCallOtp()` for inbound call OTP sessions
|
|
8
|
+
- `useInboundSmsOtp()` for inbound SMS OTP sessions
|
|
9
|
+
- Build-time or browser default base URL resolution
|
|
10
|
+
- Live WebSocket events for attempts, lock, verification, and expiry
|
|
11
|
+
- Optional long-poll mode if a React client prefers HTTP waiting
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install siymo-otp-react-sdk @siymo/otp-sdk-core react
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Default Behavior
|
|
20
|
+
|
|
21
|
+
If you do not pass a `baseUrl`, the hooks resolve it like this:
|
|
22
|
+
|
|
23
|
+
1. Build-time env value from `SIYMO_OTP_REACT_BASE_URL`
|
|
24
|
+
2. Build-time env value from `SIYMO_OTP_BASE_URL`
|
|
25
|
+
3. Build-time env value from `OTP_BASE_URL`
|
|
26
|
+
4. Build-time env value from `baseURL`
|
|
27
|
+
5. Browser `location.origin`
|
|
28
|
+
6. Fallback to `http://localhost:3000`
|
|
29
|
+
|
|
30
|
+
That lets you bake a service URL into the built package, while still keeping same-origin React apps working without extra configuration.
|
|
31
|
+
|
|
32
|
+
To build the package with a default service URL:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
SIYMO_OTP_REACT_BASE_URL=http://localhost:3000 npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Basic Usage
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useInboundSmsOtp } from 'siymo-otp-react-sdk';
|
|
42
|
+
|
|
43
|
+
export function InboundSmsWidget() {
|
|
44
|
+
const { data, error, loading, create, eventLog } = useInboundSmsOtp({
|
|
45
|
+
onTried(event) {
|
|
46
|
+
console.log('Try number:', event.data.attempts);
|
|
47
|
+
},
|
|
48
|
+
onSuccess() {
|
|
49
|
+
console.log('OTP verified');
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function handleStart() {
|
|
54
|
+
await create({
|
|
55
|
+
phone: '+998990649000',
|
|
56
|
+
expiration: 60,
|
|
57
|
+
maxTries: 3,
|
|
58
|
+
qrCode: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<button onClick={handleStart}>Create session</button>
|
|
65
|
+
<p>Loading: {loading ? 'yes' : 'no'}</p>
|
|
66
|
+
<p>Status: {data.status}</p>
|
|
67
|
+
<p>Attempts: {data.attempts}</p>
|
|
68
|
+
<p>Tries left: {data.triesLeft ?? '-'}</p>
|
|
69
|
+
<p>Decoded SMS: {data.decodedText ?? '-'}</p>
|
|
70
|
+
{data.qrCodeImage ? <img src={data.qrCodeImage} alt="Inbound SMS QR code" width={180} /> : null}
|
|
71
|
+
<p>Latest event: {eventLog.at(-1)?.description ?? '-'}</p>
|
|
72
|
+
{error ? <p>{error.message}</p> : null}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Hook Return Shape
|
|
79
|
+
|
|
80
|
+
Both hooks expose:
|
|
81
|
+
|
|
82
|
+
- `data` as the primary, flattened payload for the hook
|
|
83
|
+
- `loading`, `error`, `message`, and `called` for easy UI state
|
|
84
|
+
- `create(request)`, `restart(request?)`, `wait(sessionId?)`, `stop()`, and `clear()`
|
|
85
|
+
- Legacy aliases are still available: `start`, `waitForSession`, `cancel`, `reset`
|
|
86
|
+
- `eventLog` with pre-described entries ready for rendering
|
|
87
|
+
- Raw escape hatches when needed: `session`, `events`, `lastEvent`, `lastAttemptEvent`, `verifiedEvent`, `lockedEvent`, `expiredEvent`
|
|
88
|
+
- `attempts`, `triesLeft`, `status`, `isWaiting`, `isVerified`, `isLocked`, `isExpired`
|
|
89
|
+
|
|
90
|
+
`useInboundCallOtp()` also exposes `otpCode`, `callHref`, and `qrCodeImage`.
|
|
91
|
+
|
|
92
|
+
`useInboundSmsOtp()` also exposes `decodedText` and `qrCodeImage`.
|
|
93
|
+
|
|
94
|
+
The `data` shape is channel-specific:
|
|
95
|
+
|
|
96
|
+
- Call hook data: `servicePhone`, `callerPhone`, `qrCodeImage`, `otpCode`, `callHref`
|
|
97
|
+
- SMS hook data: `servicePhone`, `senderPhone`, `qrCodeImage`, `rawText`, `decodedText`
|
|
98
|
+
|
|
99
|
+
## Options
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
type UseInboundCallOtpOptions = {
|
|
103
|
+
autoWait?: boolean;
|
|
104
|
+
waitMode?: 'websocket' | 'long-poll';
|
|
105
|
+
timeoutMs?: number;
|
|
106
|
+
baseUrl?: string;
|
|
107
|
+
websocketBaseUrl?: string;
|
|
108
|
+
client?: SiymoOtpClient;
|
|
109
|
+
clientOptions?: Omit<SiymoOtpClientOptions, 'baseUrl' | 'websocketBaseUrl'>;
|
|
110
|
+
onCreated?: (session, result) => void;
|
|
111
|
+
onSubscribed?: (event, result) => void;
|
|
112
|
+
onTried?: (event, result) => void;
|
|
113
|
+
onSuccess?: (event, result) => void;
|
|
114
|
+
onLocked?: (event, result) => void;
|
|
115
|
+
onExpired?: (event, result) => void;
|
|
116
|
+
onError?: (error, result) => void;
|
|
117
|
+
onStatusChange?: (status, result) => void;
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Notes:
|
|
122
|
+
|
|
123
|
+
- `waitMode` defaults to `websocket`
|
|
124
|
+
- `autoWait` defaults to `true`
|
|
125
|
+
- `baseUrl` still overrides any build-time default when you want a per-hook override
|
|
126
|
+
- Pass `qrCode: true` in `create()` when you want the API to return a scannable `qrCodeImage` data URL
|
|
127
|
+
- WebSocket mode is the only mode that can stream `otp.attempt` events live
|
|
128
|
+
- Long-poll mode still updates verified / locked / expired state, but it cannot stream intermediate attempts
|
|
129
|
+
- Callback handlers receive the latest hook result, including the derived `data` object
|
|
130
|
+
|
|
131
|
+
## Examples
|
|
132
|
+
|
|
133
|
+
The package includes two basic React examples:
|
|
134
|
+
|
|
135
|
+
- [`examples/inbound-call-client`](./examples/inbound-call-client)
|
|
136
|
+
- [`examples/inbound-sms-client`](./examples/inbound-sms-client)
|
|
137
|
+
|
|
138
|
+
Each example consumes this React package plus the core `@siymo/otp-sdk-core`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// This file is updated by `npm run build` to capture any build-time base URL.
|
|
2
|
+
export const BUILD_TIME_SIYMO_OTP_REACT_BASE_URL = "https://otp.inadullaev.com";
|
|
3
|
+
export const BUILD_TIME_SIYMO_OTP_REACT_BASE_URL_ENV_VARS = [
|
|
4
|
+
"SIYMO_OTP_REACT_BASE_URL",
|
|
5
|
+
"SIYMO_OTP_BASE_URL",
|
|
6
|
+
"OTP_BASE_URL",
|
|
7
|
+
"baseURL"
|
|
8
|
+
];
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BUILD_TIME_SIYMO_OTP_REACT_BASE_URL } from './buildConfig.js';
|
|
2
|
+
export const DEFAULT_SIYMO_OTP_REACT_BASE_URL = 'http://localhost:3000';
|
|
3
|
+
export function resolveDefaultSiymoOtpBaseUrl() {
|
|
4
|
+
const configuredBaseUrl = BUILD_TIME_SIYMO_OTP_REACT_BASE_URL?.trim();
|
|
5
|
+
if (configuredBaseUrl && configuredBaseUrl.length > 0) {
|
|
6
|
+
return configuredBaseUrl;
|
|
7
|
+
}
|
|
8
|
+
const origin = globalThis.location?.origin;
|
|
9
|
+
if (typeof origin === 'string') {
|
|
10
|
+
const trimmed = origin.trim();
|
|
11
|
+
if (trimmed.length > 0 && trimmed !== 'null') {
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return DEFAULT_SIYMO_OTP_REACT_BASE_URL;
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SiymoOtpApiError, SiymoOtpClient, SiymoOtpConfigurationError, SiymoOtpError, SiymoOtpWebSocketError, } from '@siymo/otp-sdk-core';
|
|
2
|
+
export type { InboundReceiptType, InitiateInboundCallResponse, InitiateInboundRequest, InitiateInboundSmsResponse, OtpAttemptEvent, OtpExpiredEvent, OtpLockedEvent, OtpLongPollFailureResponse, OtpLongPollSuccessResponse, OtpSessionEvent, OtpSubscribedEvent, OtpVerifiedEvent, SiymoOtpClientOptions, } from '@siymo/otp-sdk-core';
|
|
3
|
+
export { DEFAULT_SIYMO_OTP_REACT_BASE_URL, resolveDefaultSiymoOtpBaseUrl, } from './defaults.js';
|
|
4
|
+
export type { InboundCallOtpData, InboundOtpActivityItem, InboundOtpActivityPayload, InboundOtpData, InboundOtpExpiredPayload, InboundOtpHookResult, InboundOtpHookStatus, InboundOtpLockedPayload, InboundOtpSuccessPayload, InboundOtpWaitMode, InboundSmsOtpData, UseInboundCallOtpOptions, UseInboundCallOtpResult, UseInboundSmsOtpOptions, UseInboundSmsOtpResult, UseSiymoOtpHookOptions, } from './types.js';
|
|
5
|
+
export { useInboundCallOtp } from './useInboundCallOtp.js';
|
|
6
|
+
export { useInboundSmsOtp } from './useInboundSmsOtp.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { SiymoOtpApiError, SiymoOtpClient, SiymoOtpConfigurationError, SiymoOtpError, SiymoOtpWebSocketError, } from '@siymo/otp-sdk-core';
|
|
2
|
+
export { DEFAULT_SIYMO_OTP_REACT_BASE_URL, resolveDefaultSiymoOtpBaseUrl, } from './defaults.js';
|
|
3
|
+
export { useInboundCallOtp } from './useInboundCallOtp.js';
|
|
4
|
+
export { useInboundSmsOtp } from './useInboundSmsOtp.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { InitiateInboundCallResponse, InitiateInboundRequest, InitiateInboundSmsResponse, OtpAttemptEvent, OtpExpiredEvent, OtpLockedEvent, OtpLongPollFailureResponse, OtpLongPollSuccessResponse, OtpSessionEvent, OtpSubscribedEvent, OtpVerifiedEvent, SiymoOtpClient, SiymoOtpClientOptions } from '@siymo/otp-sdk-core';
|
|
2
|
+
type InboundOtpSession = InitiateInboundCallResponse | InitiateInboundSmsResponse;
|
|
3
|
+
export type InboundOtpHookStatus = 'idle' | 'starting' | 'pending' | 'verified' | 'locked' | 'expired' | 'error';
|
|
4
|
+
export type InboundOtpWaitMode = 'websocket' | 'long-poll';
|
|
5
|
+
export type InboundOtpSuccessPayload = OtpVerifiedEvent | OtpLongPollSuccessResponse;
|
|
6
|
+
export type InboundOtpLockedPayload = OtpLockedEvent | (OtpLongPollFailureResponse & {
|
|
7
|
+
status: 'LOCKED';
|
|
8
|
+
});
|
|
9
|
+
export type InboundOtpExpiredPayload = OtpExpiredEvent | (OtpLongPollFailureResponse & {
|
|
10
|
+
status: 'EXPIRED';
|
|
11
|
+
});
|
|
12
|
+
export type InboundOtpActivityPayload = OtpSessionEvent | OtpLongPollSuccessResponse | OtpLongPollFailureResponse;
|
|
13
|
+
export interface InboundOtpActivityItem<TPayload extends InboundOtpActivityPayload = InboundOtpActivityPayload> {
|
|
14
|
+
type: 'otp.subscribed' | 'otp.attempt' | 'otp.locked' | 'otp.verified' | 'otp.expired';
|
|
15
|
+
description: string;
|
|
16
|
+
occurredAt: number | string | null;
|
|
17
|
+
payload: TPayload;
|
|
18
|
+
}
|
|
19
|
+
export interface InboundOtpData<TSession extends InboundOtpSession = InboundOtpSession> {
|
|
20
|
+
session: TSession | null;
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
channel: TSession['channel'] | null;
|
|
23
|
+
direction: TSession['direction'] | null;
|
|
24
|
+
status: InboundOtpHookStatus;
|
|
25
|
+
attempts: number;
|
|
26
|
+
triesLeft: number | null;
|
|
27
|
+
length: number | null;
|
|
28
|
+
expiresIn: number | null;
|
|
29
|
+
maxTries: number | null;
|
|
30
|
+
subscribedExpiresAt: number | null;
|
|
31
|
+
lastEvent: OtpSessionEvent | null;
|
|
32
|
+
lastEventText: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface InboundCallOtpData extends InboundOtpData<InitiateInboundCallResponse> {
|
|
35
|
+
channel: 'call' | null;
|
|
36
|
+
servicePhone: string | null;
|
|
37
|
+
callerPhone: string | null;
|
|
38
|
+
qrCodeImage: string | null;
|
|
39
|
+
otpCode: string | null;
|
|
40
|
+
callHref: string | null;
|
|
41
|
+
}
|
|
42
|
+
export interface InboundSmsOtpData extends InboundOtpData<InitiateInboundSmsResponse> {
|
|
43
|
+
channel: 'sms' | null;
|
|
44
|
+
servicePhone: string | null;
|
|
45
|
+
senderPhone: string | null;
|
|
46
|
+
qrCodeImage: string | null;
|
|
47
|
+
rawText: string | null;
|
|
48
|
+
decodedText: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface InboundOtpHookResult<TSession extends InboundOtpSession = InboundOtpSession, TData extends InboundOtpData<TSession> = InboundOtpData<TSession>> {
|
|
51
|
+
data: TData;
|
|
52
|
+
loading: boolean;
|
|
53
|
+
called: boolean;
|
|
54
|
+
hasSession: boolean;
|
|
55
|
+
message: string | null;
|
|
56
|
+
latestEventText: string | null;
|
|
57
|
+
eventLog: InboundOtpActivityItem[];
|
|
58
|
+
lastRequest: InitiateInboundRequest | null;
|
|
59
|
+
waitMode: InboundOtpWaitMode;
|
|
60
|
+
status: InboundOtpHookStatus;
|
|
61
|
+
sessionId: string | null;
|
|
62
|
+
session: TSession | null;
|
|
63
|
+
events: OtpSessionEvent[];
|
|
64
|
+
lastEvent: OtpSessionEvent | null;
|
|
65
|
+
subscribedEvent: OtpSubscribedEvent | null;
|
|
66
|
+
subscribedExpiresAt: number | null;
|
|
67
|
+
lastAttemptEvent: OtpAttemptEvent | null;
|
|
68
|
+
lockedEvent: OtpLockedEvent | null;
|
|
69
|
+
verifiedEvent: OtpVerifiedEvent | null;
|
|
70
|
+
expiredEvent: OtpExpiredEvent | null;
|
|
71
|
+
longPollResult: OtpLongPollSuccessResponse | null;
|
|
72
|
+
longPollFailure: OtpLongPollFailureResponse | null;
|
|
73
|
+
attempts: number;
|
|
74
|
+
triesLeft: number | null;
|
|
75
|
+
error: Error | null;
|
|
76
|
+
isIdle: boolean;
|
|
77
|
+
isStarting: boolean;
|
|
78
|
+
isWaiting: boolean;
|
|
79
|
+
isVerified: boolean;
|
|
80
|
+
isLocked: boolean;
|
|
81
|
+
isExpired: boolean;
|
|
82
|
+
create(request: InitiateInboundRequest): Promise<TSession>;
|
|
83
|
+
restart(request?: InitiateInboundRequest): Promise<TSession>;
|
|
84
|
+
wait(sessionId?: string): Promise<void>;
|
|
85
|
+
stop(): Promise<void>;
|
|
86
|
+
clear(): Promise<void>;
|
|
87
|
+
start(request: InitiateInboundRequest): Promise<TSession>;
|
|
88
|
+
waitForSession(sessionId?: string): Promise<void>;
|
|
89
|
+
cancel(): Promise<void>;
|
|
90
|
+
reset(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
export interface UseInboundCallOtpResult extends InboundOtpHookResult<InitiateInboundCallResponse, InboundCallOtpData> {
|
|
93
|
+
otpCode: string | null;
|
|
94
|
+
callHref: string | null;
|
|
95
|
+
qrCodeImage: string | null;
|
|
96
|
+
}
|
|
97
|
+
export interface UseInboundSmsOtpResult extends InboundOtpHookResult<InitiateInboundSmsResponse, InboundSmsOtpData> {
|
|
98
|
+
decodedText: string | null;
|
|
99
|
+
qrCodeImage: string | null;
|
|
100
|
+
}
|
|
101
|
+
export interface UseSiymoOtpHookOptions<TSession extends InboundOtpSession = InboundOtpSession, TData extends InboundOtpData<TSession> = InboundOtpData<TSession>> {
|
|
102
|
+
autoWait?: boolean;
|
|
103
|
+
waitMode?: InboundOtpWaitMode;
|
|
104
|
+
timeoutMs?: number;
|
|
105
|
+
baseUrl?: string;
|
|
106
|
+
websocketBaseUrl?: string;
|
|
107
|
+
client?: SiymoOtpClient;
|
|
108
|
+
clientOptions?: Omit<SiymoOtpClientOptions, 'baseUrl' | 'websocketBaseUrl'>;
|
|
109
|
+
onCreated?: (session: TSession, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
110
|
+
onEvent?: (event: OtpSessionEvent, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
111
|
+
onSubscribed?: (event: OtpSubscribedEvent, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
112
|
+
onTried?: (event: OtpAttemptEvent, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
113
|
+
onLocked?: (event: InboundOtpLockedPayload, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
114
|
+
onSuccess?: (event: InboundOtpSuccessPayload, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
115
|
+
onExpired?: (event: InboundOtpExpiredPayload, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
116
|
+
onError?: (error: Error, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
117
|
+
onStatusChange?: (status: InboundOtpHookStatus, result: InboundOtpHookResult<TSession, TData>) => void;
|
|
118
|
+
}
|
|
119
|
+
export type UseInboundCallOtpOptions = UseSiymoOtpHookOptions<InitiateInboundCallResponse, InboundCallOtpData>;
|
|
120
|
+
export type UseInboundSmsOtpOptions = UseSiymoOtpHookOptions<InitiateInboundSmsResponse, InboundSmsOtpData>;
|
|
121
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useInboundOtpSession } from './useInboundOtp.js';
|
|
2
|
+
export function useInboundCallOtp(options = {}) {
|
|
3
|
+
const result = useInboundOtpSession({
|
|
4
|
+
options,
|
|
5
|
+
initiate(client, request) {
|
|
6
|
+
return client.initiateInboundCall(request);
|
|
7
|
+
},
|
|
8
|
+
selectData(input) {
|
|
9
|
+
const servicePhone = input.session?.phone ??
|
|
10
|
+
input.verifiedEvent?.data.serverPhone ??
|
|
11
|
+
input.lockedEvent?.data.serverPhone ??
|
|
12
|
+
input.longPollResult?.serverPhone ??
|
|
13
|
+
input.longPollFailure?.serverPhone ??
|
|
14
|
+
null;
|
|
15
|
+
const callerPhone = input.session?.from ??
|
|
16
|
+
input.verifiedEvent?.data.phone ??
|
|
17
|
+
input.lockedEvent?.data.phone ??
|
|
18
|
+
input.longPollResult?.phone ??
|
|
19
|
+
input.longPollFailure?.phone ??
|
|
20
|
+
null;
|
|
21
|
+
const otpCode = input.session?.code ?? null;
|
|
22
|
+
return {
|
|
23
|
+
session: input.session,
|
|
24
|
+
sessionId: input.sessionId,
|
|
25
|
+
channel: input.session?.channel ?? null,
|
|
26
|
+
direction: input.session?.direction ?? null,
|
|
27
|
+
status: input.status,
|
|
28
|
+
attempts: input.attempts,
|
|
29
|
+
triesLeft: input.triesLeft,
|
|
30
|
+
length: input.session?.length ?? null,
|
|
31
|
+
expiresIn: input.session?.expiresIn ?? null,
|
|
32
|
+
maxTries: input.session?.maxTries ??
|
|
33
|
+
input.lockedEvent?.data.maxTries ??
|
|
34
|
+
input.longPollFailure?.maxTries ??
|
|
35
|
+
null,
|
|
36
|
+
subscribedExpiresAt: input.subscribedExpiresAt,
|
|
37
|
+
lastEvent: input.lastEvent,
|
|
38
|
+
lastEventText: input.latestEventText,
|
|
39
|
+
servicePhone,
|
|
40
|
+
callerPhone,
|
|
41
|
+
qrCodeImage: input.session?.qrCodeImage ?? null,
|
|
42
|
+
otpCode,
|
|
43
|
+
callHref: servicePhone && otpCode ? `tel:${servicePhone},${otpCode}` : null,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
otpCode: result.data.otpCode,
|
|
50
|
+
callHref: result.data.callHref,
|
|
51
|
+
qrCodeImage: result.data.qrCodeImage,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { SiymoOtpClient, type InitiateInboundCallResponse, type InitiateInboundRequest, type InitiateInboundSmsResponse, type OtpAttemptEvent, type OtpExpiredEvent, type OtpLockedEvent, type OtpLongPollFailureResponse, type OtpLongPollSuccessResponse, type OtpSessionEvent, type OtpSubscribedEvent, type OtpVerifiedEvent } from '@siymo/otp-sdk-core';
|
|
2
|
+
import type { InboundOtpActivityItem, InboundOtpData, InboundOtpHookResult, InboundOtpHookStatus, UseSiymoOtpHookOptions } from './types.js';
|
|
3
|
+
type InboundOtpSession = InitiateInboundCallResponse | InitiateInboundSmsResponse;
|
|
4
|
+
interface InboundOtpResultInput<TSession extends InboundOtpSession> {
|
|
5
|
+
loading: boolean;
|
|
6
|
+
called: boolean;
|
|
7
|
+
hasSession: boolean;
|
|
8
|
+
message: string | null;
|
|
9
|
+
latestEventText: string | null;
|
|
10
|
+
eventLog: InboundOtpActivityItem[];
|
|
11
|
+
lastRequest: InitiateInboundRequest | null;
|
|
12
|
+
waitMode: 'websocket' | 'long-poll';
|
|
13
|
+
status: InboundOtpHookStatus;
|
|
14
|
+
sessionId: string | null;
|
|
15
|
+
session: TSession | null;
|
|
16
|
+
events: OtpSessionEvent[];
|
|
17
|
+
lastEvent: OtpSessionEvent | null;
|
|
18
|
+
subscribedEvent: OtpSubscribedEvent | null;
|
|
19
|
+
subscribedExpiresAt: number | null;
|
|
20
|
+
lastAttemptEvent: OtpAttemptEvent | null;
|
|
21
|
+
lockedEvent: OtpLockedEvent | null;
|
|
22
|
+
verifiedEvent: OtpVerifiedEvent | null;
|
|
23
|
+
expiredEvent: OtpExpiredEvent | null;
|
|
24
|
+
longPollResult: OtpLongPollSuccessResponse | null;
|
|
25
|
+
longPollFailure: OtpLongPollFailureResponse | null;
|
|
26
|
+
attempts: number;
|
|
27
|
+
triesLeft: number | null;
|
|
28
|
+
error: Error | null;
|
|
29
|
+
isIdle: boolean;
|
|
30
|
+
isStarting: boolean;
|
|
31
|
+
isWaiting: boolean;
|
|
32
|
+
isVerified: boolean;
|
|
33
|
+
isLocked: boolean;
|
|
34
|
+
isExpired: boolean;
|
|
35
|
+
}
|
|
36
|
+
interface UseInboundOtpSessionConfig<TSession extends InboundOtpSession, TData extends InboundOtpData<TSession>> {
|
|
37
|
+
initiate(client: SiymoOtpClient, request: InitiateInboundRequest): Promise<TSession>;
|
|
38
|
+
options: UseSiymoOtpHookOptions<TSession, TData>;
|
|
39
|
+
selectData(input: InboundOtpResultInput<TSession>): TData;
|
|
40
|
+
}
|
|
41
|
+
export declare function useInboundOtpSession<TSession extends InboundOtpSession, TData extends InboundOtpData<TSession>>(config: UseInboundOtpSessionConfig<TSession, TData>): InboundOtpHookResult<TSession, TData>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, } from 'react';
|
|
2
|
+
import { SiymoOtpApiError, SiymoOtpClient, } from '@siymo/otp-sdk-core';
|
|
3
|
+
import { resolveDefaultSiymoOtpBaseUrl } from './defaults.js';
|
|
4
|
+
export function useInboundOtpSession(config) {
|
|
5
|
+
const waitMode = config.options.waitMode ?? 'websocket';
|
|
6
|
+
const [state, setState] = useState(() => createInitialState());
|
|
7
|
+
const stateRef = useRef(state);
|
|
8
|
+
const previousStateRef = useRef(state);
|
|
9
|
+
const callbacksRef = useRef(config.options);
|
|
10
|
+
const activeWaitRef = useRef(null);
|
|
11
|
+
const lastRequestRef = useRef(null);
|
|
12
|
+
const nextActiveWaitIdRef = useRef(0);
|
|
13
|
+
callbacksRef.current = config.options;
|
|
14
|
+
const updateState = (updater) => {
|
|
15
|
+
setState((current) => {
|
|
16
|
+
const next = typeof updater === 'function' ? updater(current) : updater;
|
|
17
|
+
stateRef.current = next;
|
|
18
|
+
return next;
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
return () => {
|
|
23
|
+
void stopActiveWait(activeWaitRef);
|
|
24
|
+
};
|
|
25
|
+
}, []);
|
|
26
|
+
async function start(request) {
|
|
27
|
+
lastRequestRef.current = { ...request };
|
|
28
|
+
await stopActiveWait(activeWaitRef);
|
|
29
|
+
updateState({
|
|
30
|
+
...createInitialState(),
|
|
31
|
+
status: 'starting',
|
|
32
|
+
});
|
|
33
|
+
const client = resolveClient(config.options);
|
|
34
|
+
const session = await config.initiate(client, request).catch((error) => {
|
|
35
|
+
const normalized = normalizeError(error);
|
|
36
|
+
updateState({
|
|
37
|
+
...createInitialState(),
|
|
38
|
+
status: 'error',
|
|
39
|
+
error: normalized,
|
|
40
|
+
});
|
|
41
|
+
throw normalized;
|
|
42
|
+
});
|
|
43
|
+
updateState({
|
|
44
|
+
...createInitialState(),
|
|
45
|
+
status: 'pending',
|
|
46
|
+
session,
|
|
47
|
+
triesLeft: session.maxTries,
|
|
48
|
+
});
|
|
49
|
+
if (config.options.autoWait ?? true) {
|
|
50
|
+
try {
|
|
51
|
+
await waitForSession(session.sessionId);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const normalized = normalizeError(error);
|
|
55
|
+
updateState((current) => ({
|
|
56
|
+
...current,
|
|
57
|
+
status: 'error',
|
|
58
|
+
error: normalized,
|
|
59
|
+
isWaiting: false,
|
|
60
|
+
}));
|
|
61
|
+
throw normalized;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
async function restart(request) {
|
|
67
|
+
const nextRequest = request ?? lastRequestRef.current;
|
|
68
|
+
if (!nextRequest) {
|
|
69
|
+
throw new Error('No previous inbound OTP request is available to restart.');
|
|
70
|
+
}
|
|
71
|
+
return start(nextRequest);
|
|
72
|
+
}
|
|
73
|
+
async function waitForSession(sessionId) {
|
|
74
|
+
const targetSessionId = sessionId ?? stateRef.current.session?.sessionId;
|
|
75
|
+
if (!targetSessionId) {
|
|
76
|
+
throw new Error('No inbound OTP session is available to wait for.');
|
|
77
|
+
}
|
|
78
|
+
await stopActiveWait(activeWaitRef);
|
|
79
|
+
const activeWait = {
|
|
80
|
+
id: nextActiveWaitIdRef.current + 1,
|
|
81
|
+
controller: new AbortController(),
|
|
82
|
+
};
|
|
83
|
+
nextActiveWaitIdRef.current = activeWait.id;
|
|
84
|
+
activeWaitRef.current = activeWait;
|
|
85
|
+
updateState((current) => ({
|
|
86
|
+
...current,
|
|
87
|
+
status: 'pending',
|
|
88
|
+
isWaiting: true,
|
|
89
|
+
error: null,
|
|
90
|
+
longPollResult: null,
|
|
91
|
+
longPollFailure: null,
|
|
92
|
+
}));
|
|
93
|
+
const client = resolveClient(config.options);
|
|
94
|
+
scheduleWaitTimeout(activeWaitRef, activeWait, config.options.timeoutMs, targetSessionId, updateState);
|
|
95
|
+
if (waitMode === 'long-poll') {
|
|
96
|
+
void client
|
|
97
|
+
.waitForConfirmationLongPoll(targetSessionId, {
|
|
98
|
+
signal: activeWait.controller.signal,
|
|
99
|
+
})
|
|
100
|
+
.then((result) => {
|
|
101
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
105
|
+
updateState((current) => ({
|
|
106
|
+
...current,
|
|
107
|
+
status: 'verified',
|
|
108
|
+
longPollResult: result,
|
|
109
|
+
error: null,
|
|
110
|
+
isWaiting: false,
|
|
111
|
+
}));
|
|
112
|
+
})
|
|
113
|
+
.catch((error) => {
|
|
114
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
118
|
+
if (isAbortLikeError(error)) {
|
|
119
|
+
updateState((current) => ({
|
|
120
|
+
...current,
|
|
121
|
+
isWaiting: false,
|
|
122
|
+
}));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const failure = extractLongPollFailure(error);
|
|
126
|
+
if (failure?.status === 'LOCKED') {
|
|
127
|
+
updateState((current) => ({
|
|
128
|
+
...current,
|
|
129
|
+
status: 'locked',
|
|
130
|
+
longPollFailure: failure,
|
|
131
|
+
attempts: failure.attempts ?? current.attempts,
|
|
132
|
+
triesLeft: failure.triesLeft ?? current.triesLeft,
|
|
133
|
+
error: null,
|
|
134
|
+
isWaiting: false,
|
|
135
|
+
}));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (failure?.status === 'EXPIRED') {
|
|
139
|
+
updateState((current) => ({
|
|
140
|
+
...current,
|
|
141
|
+
status: 'expired',
|
|
142
|
+
longPollFailure: failure,
|
|
143
|
+
error: null,
|
|
144
|
+
isWaiting: false,
|
|
145
|
+
}));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
updateState((current) => ({
|
|
149
|
+
...current,
|
|
150
|
+
status: 'error',
|
|
151
|
+
error: normalizeError(error),
|
|
152
|
+
isWaiting: false,
|
|
153
|
+
}));
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
activeWait.subscription = client.subscribeToSession(targetSessionId, {
|
|
159
|
+
signal: activeWait.controller.signal,
|
|
160
|
+
onSubscribed: (event) => {
|
|
161
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
updateState((current) => recordEvent(current, event, {
|
|
165
|
+
subscribedEvent: event,
|
|
166
|
+
subscribedExpiresAt: event.data.expiresAt,
|
|
167
|
+
}));
|
|
168
|
+
},
|
|
169
|
+
onAttempt: (event) => {
|
|
170
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
updateState((current) => recordEvent(current, event, {
|
|
174
|
+
attempts: event.data.attempts,
|
|
175
|
+
triesLeft: event.data.triesLeft,
|
|
176
|
+
lastAttemptEvent: event,
|
|
177
|
+
}));
|
|
178
|
+
},
|
|
179
|
+
onLocked: (event) => {
|
|
180
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
184
|
+
updateState((current) => recordEvent(current, event, {
|
|
185
|
+
status: 'locked',
|
|
186
|
+
attempts: event.data.attempts,
|
|
187
|
+
triesLeft: event.data.triesLeft,
|
|
188
|
+
lockedEvent: event,
|
|
189
|
+
isWaiting: false,
|
|
190
|
+
}));
|
|
191
|
+
},
|
|
192
|
+
onVerified: (event) => {
|
|
193
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
197
|
+
updateState((current) => recordEvent(current, event, {
|
|
198
|
+
status: 'verified',
|
|
199
|
+
verifiedEvent: event,
|
|
200
|
+
isWaiting: false,
|
|
201
|
+
}));
|
|
202
|
+
},
|
|
203
|
+
onExpired: (event) => {
|
|
204
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
208
|
+
updateState((current) => recordEvent(current, event, {
|
|
209
|
+
status: 'expired',
|
|
210
|
+
expiredEvent: event,
|
|
211
|
+
isWaiting: false,
|
|
212
|
+
}));
|
|
213
|
+
},
|
|
214
|
+
onError: (error) => {
|
|
215
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
219
|
+
updateState((current) => ({
|
|
220
|
+
...current,
|
|
221
|
+
status: 'error',
|
|
222
|
+
error: normalizeError(error),
|
|
223
|
+
isWaiting: false,
|
|
224
|
+
}));
|
|
225
|
+
},
|
|
226
|
+
onClose: () => {
|
|
227
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
231
|
+
if (activeWait.controller.signal.aborted) {
|
|
232
|
+
updateState((current) => ({
|
|
233
|
+
...current,
|
|
234
|
+
isWaiting: false,
|
|
235
|
+
}));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
updateState((current) => ({
|
|
239
|
+
...current,
|
|
240
|
+
status: current.status === 'pending' || current.status === 'starting'
|
|
241
|
+
? 'error'
|
|
242
|
+
: current.status,
|
|
243
|
+
error: current.status === 'pending' || current.status === 'starting'
|
|
244
|
+
? new Error(`WebSocket closed before session ${targetSessionId} completed.`)
|
|
245
|
+
: current.error,
|
|
246
|
+
isWaiting: false,
|
|
247
|
+
}));
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
253
|
+
const normalized = normalizeError(error);
|
|
254
|
+
updateState((current) => ({
|
|
255
|
+
...current,
|
|
256
|
+
status: 'error',
|
|
257
|
+
error: normalized,
|
|
258
|
+
isWaiting: false,
|
|
259
|
+
}));
|
|
260
|
+
throw normalized;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function cancel() {
|
|
264
|
+
await stopActiveWait(activeWaitRef);
|
|
265
|
+
updateState((current) => ({
|
|
266
|
+
...current,
|
|
267
|
+
isWaiting: false,
|
|
268
|
+
error: null,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
async function reset() {
|
|
272
|
+
await stopActiveWait(activeWaitRef);
|
|
273
|
+
updateState(createInitialState());
|
|
274
|
+
}
|
|
275
|
+
const resultInput = createResultInput(state, waitMode, lastRequestRef.current);
|
|
276
|
+
const result = createHookResult(resultInput, config.selectData(resultInput), {
|
|
277
|
+
start,
|
|
278
|
+
restart,
|
|
279
|
+
waitForSession,
|
|
280
|
+
cancel,
|
|
281
|
+
reset,
|
|
282
|
+
});
|
|
283
|
+
const resultRef = useRef(result);
|
|
284
|
+
resultRef.current = result;
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
const previousState = previousStateRef.current;
|
|
287
|
+
const callbacks = callbacksRef.current;
|
|
288
|
+
const currentResult = resultRef.current;
|
|
289
|
+
if (state.session !== previousState.session && state.session) {
|
|
290
|
+
callbacks.onCreated?.(state.session, currentResult);
|
|
291
|
+
}
|
|
292
|
+
if (state.lastEvent !== previousState.lastEvent && state.lastEvent) {
|
|
293
|
+
callbacks.onEvent?.(state.lastEvent, currentResult);
|
|
294
|
+
switch (state.lastEvent.event) {
|
|
295
|
+
case 'otp.subscribed':
|
|
296
|
+
callbacks.onSubscribed?.(state.lastEvent, currentResult);
|
|
297
|
+
break;
|
|
298
|
+
case 'otp.attempt':
|
|
299
|
+
callbacks.onTried?.(state.lastEvent, currentResult);
|
|
300
|
+
break;
|
|
301
|
+
case 'otp.locked':
|
|
302
|
+
callbacks.onLocked?.(state.lastEvent, currentResult);
|
|
303
|
+
break;
|
|
304
|
+
case 'otp.verified':
|
|
305
|
+
callbacks.onSuccess?.(state.lastEvent, currentResult);
|
|
306
|
+
break;
|
|
307
|
+
case 'otp.expired':
|
|
308
|
+
callbacks.onExpired?.(state.lastEvent, currentResult);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (state.longPollResult !== previousState.longPollResult && state.longPollResult) {
|
|
313
|
+
callbacks.onSuccess?.(state.longPollResult, currentResult);
|
|
314
|
+
}
|
|
315
|
+
if (state.longPollFailure !== previousState.longPollFailure && state.longPollFailure) {
|
|
316
|
+
if (state.longPollFailure.status === 'LOCKED') {
|
|
317
|
+
callbacks.onLocked?.(state.longPollFailure, currentResult);
|
|
318
|
+
}
|
|
319
|
+
if (state.longPollFailure.status === 'EXPIRED') {
|
|
320
|
+
callbacks.onExpired?.(state.longPollFailure, currentResult);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (state.error !== previousState.error && state.error) {
|
|
324
|
+
callbacks.onError?.(state.error, currentResult);
|
|
325
|
+
}
|
|
326
|
+
if (state.status !== previousState.status) {
|
|
327
|
+
callbacks.onStatusChange?.(state.status, currentResult);
|
|
328
|
+
}
|
|
329
|
+
previousStateRef.current = state;
|
|
330
|
+
}, [state]);
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
function createInitialState() {
|
|
334
|
+
return {
|
|
335
|
+
status: 'idle',
|
|
336
|
+
session: null,
|
|
337
|
+
events: [],
|
|
338
|
+
lastEvent: null,
|
|
339
|
+
subscribedEvent: null,
|
|
340
|
+
subscribedExpiresAt: null,
|
|
341
|
+
lastAttemptEvent: null,
|
|
342
|
+
lockedEvent: null,
|
|
343
|
+
verifiedEvent: null,
|
|
344
|
+
expiredEvent: null,
|
|
345
|
+
longPollResult: null,
|
|
346
|
+
longPollFailure: null,
|
|
347
|
+
attempts: 0,
|
|
348
|
+
triesLeft: null,
|
|
349
|
+
error: null,
|
|
350
|
+
isWaiting: false,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function createResultInput(state, waitMode, lastRequest) {
|
|
354
|
+
const sessionId = resolveSessionId(state);
|
|
355
|
+
const eventLog = createEventLog(state);
|
|
356
|
+
const latestEventText = eventLog.at(-1)?.description ?? null;
|
|
357
|
+
const message = createMessage(state, sessionId, latestEventText);
|
|
358
|
+
const called = state.status !== 'idle' ||
|
|
359
|
+
sessionId !== null ||
|
|
360
|
+
state.events.length > 0 ||
|
|
361
|
+
state.error !== null ||
|
|
362
|
+
state.longPollResult !== null ||
|
|
363
|
+
state.longPollFailure !== null;
|
|
364
|
+
return {
|
|
365
|
+
loading: state.status === 'starting' || state.isWaiting,
|
|
366
|
+
called,
|
|
367
|
+
hasSession: state.session !== null,
|
|
368
|
+
message,
|
|
369
|
+
latestEventText,
|
|
370
|
+
eventLog,
|
|
371
|
+
lastRequest,
|
|
372
|
+
waitMode,
|
|
373
|
+
status: state.status,
|
|
374
|
+
sessionId,
|
|
375
|
+
session: state.session,
|
|
376
|
+
events: state.events,
|
|
377
|
+
lastEvent: state.lastEvent,
|
|
378
|
+
subscribedEvent: state.subscribedEvent,
|
|
379
|
+
subscribedExpiresAt: state.subscribedExpiresAt,
|
|
380
|
+
lastAttemptEvent: state.lastAttemptEvent,
|
|
381
|
+
lockedEvent: state.lockedEvent,
|
|
382
|
+
verifiedEvent: state.verifiedEvent,
|
|
383
|
+
expiredEvent: state.expiredEvent,
|
|
384
|
+
longPollResult: state.longPollResult,
|
|
385
|
+
longPollFailure: state.longPollFailure,
|
|
386
|
+
attempts: state.attempts,
|
|
387
|
+
triesLeft: state.triesLeft,
|
|
388
|
+
error: state.error,
|
|
389
|
+
isIdle: state.status === 'idle',
|
|
390
|
+
isStarting: state.status === 'starting',
|
|
391
|
+
isWaiting: state.isWaiting,
|
|
392
|
+
isVerified: state.status === 'verified',
|
|
393
|
+
isLocked: state.status === 'locked',
|
|
394
|
+
isExpired: state.status === 'expired',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function createHookResult(input, data, actions) {
|
|
398
|
+
return {
|
|
399
|
+
data,
|
|
400
|
+
loading: input.loading,
|
|
401
|
+
called: input.called,
|
|
402
|
+
hasSession: input.hasSession,
|
|
403
|
+
message: input.message,
|
|
404
|
+
latestEventText: input.latestEventText,
|
|
405
|
+
eventLog: input.eventLog,
|
|
406
|
+
lastRequest: input.lastRequest,
|
|
407
|
+
waitMode: input.waitMode,
|
|
408
|
+
status: input.status,
|
|
409
|
+
sessionId: input.sessionId,
|
|
410
|
+
session: input.session,
|
|
411
|
+
events: input.events,
|
|
412
|
+
lastEvent: input.lastEvent,
|
|
413
|
+
subscribedEvent: input.subscribedEvent,
|
|
414
|
+
subscribedExpiresAt: input.subscribedExpiresAt,
|
|
415
|
+
lastAttemptEvent: input.lastAttemptEvent,
|
|
416
|
+
lockedEvent: input.lockedEvent,
|
|
417
|
+
verifiedEvent: input.verifiedEvent,
|
|
418
|
+
expiredEvent: input.expiredEvent,
|
|
419
|
+
longPollResult: input.longPollResult,
|
|
420
|
+
longPollFailure: input.longPollFailure,
|
|
421
|
+
attempts: input.attempts,
|
|
422
|
+
triesLeft: input.triesLeft,
|
|
423
|
+
error: input.error,
|
|
424
|
+
isIdle: input.isIdle,
|
|
425
|
+
isStarting: input.isStarting,
|
|
426
|
+
isWaiting: input.isWaiting,
|
|
427
|
+
isVerified: input.isVerified,
|
|
428
|
+
isLocked: input.isLocked,
|
|
429
|
+
isExpired: input.isExpired,
|
|
430
|
+
create: actions.start,
|
|
431
|
+
restart: actions.restart,
|
|
432
|
+
wait: actions.waitForSession,
|
|
433
|
+
stop: actions.cancel,
|
|
434
|
+
clear: actions.reset,
|
|
435
|
+
start: actions.start,
|
|
436
|
+
waitForSession: actions.waitForSession,
|
|
437
|
+
cancel: actions.cancel,
|
|
438
|
+
reset: actions.reset,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function createMessage(state, sessionId, latestEventText) {
|
|
442
|
+
if (state.error) {
|
|
443
|
+
return state.error.message;
|
|
444
|
+
}
|
|
445
|
+
if (latestEventText) {
|
|
446
|
+
return latestEventText;
|
|
447
|
+
}
|
|
448
|
+
if (state.isWaiting && sessionId) {
|
|
449
|
+
return `Waiting for session ${sessionId} to finish.`;
|
|
450
|
+
}
|
|
451
|
+
if (state.session && sessionId) {
|
|
452
|
+
return `Session ${sessionId} is ready.`;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
function createEventLog(state) {
|
|
457
|
+
const log = state.events.map((event) => createEventLogItem(event));
|
|
458
|
+
if (state.longPollResult) {
|
|
459
|
+
log.push({
|
|
460
|
+
type: 'otp.verified',
|
|
461
|
+
description: describeLongPollSuccess(state.longPollResult),
|
|
462
|
+
occurredAt: state.longPollResult.verifiedAt,
|
|
463
|
+
payload: state.longPollResult,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (state.longPollFailure?.status === 'LOCKED') {
|
|
467
|
+
log.push({
|
|
468
|
+
type: 'otp.locked',
|
|
469
|
+
description: describeLongPollFailure(state.longPollFailure),
|
|
470
|
+
occurredAt: state.longPollFailure.lockedAt ?? null,
|
|
471
|
+
payload: state.longPollFailure,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (state.longPollFailure?.status === 'EXPIRED') {
|
|
475
|
+
log.push({
|
|
476
|
+
type: 'otp.expired',
|
|
477
|
+
description: describeLongPollFailure(state.longPollFailure),
|
|
478
|
+
occurredAt: state.longPollFailure.expiredAt ?? null,
|
|
479
|
+
payload: state.longPollFailure,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return log;
|
|
483
|
+
}
|
|
484
|
+
function createEventLogItem(event) {
|
|
485
|
+
return {
|
|
486
|
+
type: event.event,
|
|
487
|
+
description: describeSessionEvent(event),
|
|
488
|
+
occurredAt: getSessionEventOccurredAt(event),
|
|
489
|
+
payload: event,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function describeSessionEvent(event) {
|
|
493
|
+
if (event.event === 'otp.subscribed') {
|
|
494
|
+
return `session ${event.data.sessionId} subscribed`;
|
|
495
|
+
}
|
|
496
|
+
if (event.event === 'otp.attempt') {
|
|
497
|
+
return `attempt ${event.data.attempts} of ${event.data.maxTries}, ${event.data.triesLeft} left`;
|
|
498
|
+
}
|
|
499
|
+
if (event.event === 'otp.locked') {
|
|
500
|
+
return `locked after ${event.data.attempts} attempt(s)`;
|
|
501
|
+
}
|
|
502
|
+
if (event.event === 'otp.verified') {
|
|
503
|
+
return `verified at ${event.data.verifiedAt}`;
|
|
504
|
+
}
|
|
505
|
+
return `expired session ${event.data.sessionId}`;
|
|
506
|
+
}
|
|
507
|
+
function describeLongPollSuccess(result) {
|
|
508
|
+
return `verified at ${result.verifiedAt}`;
|
|
509
|
+
}
|
|
510
|
+
function describeLongPollFailure(result) {
|
|
511
|
+
if (result.status === 'LOCKED') {
|
|
512
|
+
const attempts = typeof result.attempts === 'number' ? result.attempts : '?';
|
|
513
|
+
return `locked after ${attempts} attempt(s)`;
|
|
514
|
+
}
|
|
515
|
+
return `expired session ${result.sessionId}`;
|
|
516
|
+
}
|
|
517
|
+
function getSessionEventOccurredAt(event) {
|
|
518
|
+
if (event.event === 'otp.subscribed') {
|
|
519
|
+
return event.data.expiresAt;
|
|
520
|
+
}
|
|
521
|
+
if (event.event === 'otp.attempt') {
|
|
522
|
+
return event.data.attemptedAt;
|
|
523
|
+
}
|
|
524
|
+
if (event.event === 'otp.locked') {
|
|
525
|
+
return event.data.lockedAt;
|
|
526
|
+
}
|
|
527
|
+
if (event.event === 'otp.verified') {
|
|
528
|
+
return event.data.verifiedAt;
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
function resolveSessionId(state) {
|
|
533
|
+
return (state.session?.sessionId ??
|
|
534
|
+
state.verifiedEvent?.data.sessionId ??
|
|
535
|
+
state.lockedEvent?.data.sessionId ??
|
|
536
|
+
state.expiredEvent?.data.sessionId ??
|
|
537
|
+
state.longPollResult?.sessionId ??
|
|
538
|
+
state.longPollFailure?.sessionId ??
|
|
539
|
+
null);
|
|
540
|
+
}
|
|
541
|
+
function recordEvent(current, event, updates = {}) {
|
|
542
|
+
return {
|
|
543
|
+
...current,
|
|
544
|
+
...updates,
|
|
545
|
+
events: [...current.events, event],
|
|
546
|
+
lastEvent: event,
|
|
547
|
+
error: updates.error ?? current.error,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function resolveClient(options) {
|
|
551
|
+
if (options.client) {
|
|
552
|
+
return options.client;
|
|
553
|
+
}
|
|
554
|
+
return new SiymoOtpClient({
|
|
555
|
+
...options.clientOptions,
|
|
556
|
+
baseUrl: options.baseUrl ?? resolveDefaultSiymoOtpBaseUrl(),
|
|
557
|
+
websocketBaseUrl: options.websocketBaseUrl,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function normalizeError(error) {
|
|
561
|
+
if (error instanceof Error) {
|
|
562
|
+
return error;
|
|
563
|
+
}
|
|
564
|
+
return new Error(typeof error === 'string' ? error : 'Unknown OTP hook error');
|
|
565
|
+
}
|
|
566
|
+
function isAbortLikeError(error) {
|
|
567
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (error instanceof SiymoOtpApiError && error.status === 0) {
|
|
571
|
+
const cause = getErrorCause(error);
|
|
572
|
+
return cause instanceof Error && cause.name === 'AbortError';
|
|
573
|
+
}
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
function getErrorCause(error) {
|
|
577
|
+
const value = error;
|
|
578
|
+
return value.cause;
|
|
579
|
+
}
|
|
580
|
+
function extractLongPollFailure(error) {
|
|
581
|
+
if (!(error instanceof SiymoOtpApiError)) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
if (isLongPollFailureResponse(error.body)) {
|
|
585
|
+
return error.body;
|
|
586
|
+
}
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
function isLongPollFailureResponse(value) {
|
|
590
|
+
if (!isRecord(value)) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
return (typeof value.sessionId === 'string' &&
|
|
594
|
+
typeof value.message === 'string' &&
|
|
595
|
+
(value.status === 'LOCKED' || value.status === 'EXPIRED'));
|
|
596
|
+
}
|
|
597
|
+
function isRecord(value) {
|
|
598
|
+
return typeof value === 'object' && value !== null;
|
|
599
|
+
}
|
|
600
|
+
function isCurrentActiveWait(activeWaitRef, activeWait) {
|
|
601
|
+
return activeWaitRef.current?.id === activeWait.id;
|
|
602
|
+
}
|
|
603
|
+
async function stopActiveWait(activeWaitRef) {
|
|
604
|
+
const activeWait = activeWaitRef.current;
|
|
605
|
+
if (!activeWait) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
609
|
+
activeWait.controller.abort();
|
|
610
|
+
activeWait.subscription?.close(1000, 'Cancelled');
|
|
611
|
+
if (activeWait.subscription) {
|
|
612
|
+
try {
|
|
613
|
+
await activeWait.subscription.closed;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// No extra handling needed during local hook cleanup.
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function finalizeActiveWait(activeWaitRef, activeWait) {
|
|
621
|
+
if (activeWait.timeoutId) {
|
|
622
|
+
clearTimeout(activeWait.timeoutId);
|
|
623
|
+
}
|
|
624
|
+
if (activeWaitRef.current?.id === activeWait.id) {
|
|
625
|
+
activeWaitRef.current = null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function scheduleWaitTimeout(activeWaitRef, activeWait, timeoutMs, sessionId, updateState) {
|
|
629
|
+
if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
activeWait.timeoutId = setTimeout(() => {
|
|
633
|
+
if (!isCurrentActiveWait(activeWaitRef, activeWait)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
finalizeActiveWait(activeWaitRef, activeWait);
|
|
637
|
+
activeWait.controller.abort();
|
|
638
|
+
activeWait.subscription?.close(1000, 'Timed out');
|
|
639
|
+
updateState((current) => ({
|
|
640
|
+
...current,
|
|
641
|
+
status: 'error',
|
|
642
|
+
error: new Error(`Timed out after ${timeoutMs} ms while waiting for session ${sessionId}.`),
|
|
643
|
+
isWaiting: false,
|
|
644
|
+
}));
|
|
645
|
+
}, timeoutMs);
|
|
646
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useInboundOtpSession } from './useInboundOtp.js';
|
|
2
|
+
export function useInboundSmsOtp(options = {}) {
|
|
3
|
+
const result = useInboundOtpSession({
|
|
4
|
+
options,
|
|
5
|
+
initiate(client, request) {
|
|
6
|
+
return client.initiateInboundSms(request);
|
|
7
|
+
},
|
|
8
|
+
selectData(input) {
|
|
9
|
+
return {
|
|
10
|
+
session: input.session,
|
|
11
|
+
sessionId: input.sessionId,
|
|
12
|
+
channel: input.session?.channel ?? null,
|
|
13
|
+
direction: input.session?.direction ?? null,
|
|
14
|
+
status: input.status,
|
|
15
|
+
attempts: input.attempts,
|
|
16
|
+
triesLeft: input.triesLeft,
|
|
17
|
+
length: input.session?.length ?? null,
|
|
18
|
+
expiresIn: input.session?.expiresIn ?? null,
|
|
19
|
+
maxTries: input.session?.maxTries ??
|
|
20
|
+
input.lockedEvent?.data.maxTries ??
|
|
21
|
+
input.longPollFailure?.maxTries ??
|
|
22
|
+
null,
|
|
23
|
+
subscribedExpiresAt: input.subscribedExpiresAt,
|
|
24
|
+
lastEvent: input.lastEvent,
|
|
25
|
+
lastEventText: input.latestEventText,
|
|
26
|
+
servicePhone: input.session?.phone ??
|
|
27
|
+
input.verifiedEvent?.data.serverPhone ??
|
|
28
|
+
input.lockedEvent?.data.serverPhone ??
|
|
29
|
+
input.longPollResult?.serverPhone ??
|
|
30
|
+
input.longPollFailure?.serverPhone ??
|
|
31
|
+
null,
|
|
32
|
+
senderPhone: input.session?.from ??
|
|
33
|
+
input.verifiedEvent?.data.phone ??
|
|
34
|
+
input.lockedEvent?.data.phone ??
|
|
35
|
+
input.longPollResult?.phone ??
|
|
36
|
+
input.longPollFailure?.phone ??
|
|
37
|
+
null,
|
|
38
|
+
qrCodeImage: input.session?.qrCodeImage ?? null,
|
|
39
|
+
rawText: input.session?.text ?? null,
|
|
40
|
+
decodedText: decodeBase64Text(input.session?.text),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
...result,
|
|
46
|
+
decodedText: result.data.decodedText,
|
|
47
|
+
qrCodeImage: result.data.qrCodeImage,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function decodeBase64Text(value) {
|
|
51
|
+
if (!value) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (typeof globalThis.atob !== 'function') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const binary = globalThis.atob(value);
|
|
59
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
60
|
+
return new TextDecoder().decode(bytes);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siymo/otp-sdk-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks SDK for inbound call and inbound SMS OTP flows built on top of @siymo/otp-sdk-core",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"prebuild": "node ./scripts/write-build-config.mjs",
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"prepare": "npm run build",
|
|
26
|
+
"prepack": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"otp",
|
|
30
|
+
"react",
|
|
31
|
+
"hooks",
|
|
32
|
+
"sdk",
|
|
33
|
+
"websocket"
|
|
34
|
+
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=18",
|
|
37
|
+
"@siymo/otp-sdk-core": "^1.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@siymo/otp-sdk-core": "^1.0.0",
|
|
41
|
+
"@types/react": "^18.3.3",
|
|
42
|
+
"react": "^18.3.1",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|