@switchlabs/verify-ai-react-native 0.1.5 → 1.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 +73 -0
- package/lib/client/index.d.ts +26 -1
- package/lib/client/index.js +53 -3
- package/lib/components/VerifyAIScanner.d.ts +3 -1
- package/lib/components/VerifyAIScanner.js +48 -6
- package/lib/hooks/useVerifyAI.js +16 -3
- package/lib/index.d.ts +1 -1
- package/lib/storage/offlineQueue.js +5 -2
- package/lib/types/index.d.ts +35 -1
- package/package.json +1 -1
- package/src/client/index.ts +59 -3
- package/src/components/VerifyAIScanner.tsx +72 -17
- package/src/hooks/useVerifyAI.ts +22 -4
- package/src/index.ts +1 -0
- package/src/storage/offlineQueue.ts +6 -3
- package/src/types/index.ts +37 -1
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @switchlabs/verify-ai-react-native
|
|
2
|
+
|
|
3
|
+
React Native SDK for Verify AI photo verification.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Expo-managed apps:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
React Native CLI:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { useVerifyAI } from '@switchlabs/verify-ai-react-native';
|
|
23
|
+
|
|
24
|
+
function ParkingScreen() {
|
|
25
|
+
const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
|
|
26
|
+
|
|
27
|
+
const onPhoto = async (base64: string) => {
|
|
28
|
+
const result = await verify({
|
|
29
|
+
image: base64,
|
|
30
|
+
policy: 'scooter_parking',
|
|
31
|
+
});
|
|
32
|
+
if (result?.is_compliant) {
|
|
33
|
+
console.log('PASS');
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Scanner Component
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { useVerifyAI, VerifyAIScanner } from '@switchlabs/verify-ai-react-native';
|
|
45
|
+
|
|
46
|
+
function ScannerScreen() {
|
|
47
|
+
const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
|
|
48
|
+
return (
|
|
49
|
+
<VerifyAIScanner
|
|
50
|
+
onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
|
|
51
|
+
onResult={(result) => console.log(result.is_compliant ? 'PASS' : 'FAIL')}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Offline Mode
|
|
58
|
+
|
|
59
|
+
Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const { verify, queueSize, processQueue } = useVerifyAI({
|
|
63
|
+
apiKey: 'vai_your_api_key',
|
|
64
|
+
offlineMode: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await verify({ image: base64, policy: 'scooter_parking' });
|
|
68
|
+
await processQueue();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API Docs
|
|
72
|
+
|
|
73
|
+
Full API docs: https://verify.switchlabs.dev/docs
|
package/lib/client/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError } from '../types';
|
|
1
|
+
import type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError, PolicyConfigResponse } from '../types';
|
|
2
2
|
export declare class VerifyAIClient {
|
|
3
3
|
private apiKey;
|
|
4
4
|
private baseUrl;
|
|
@@ -51,6 +51,29 @@ export declare class VerifyAIClient {
|
|
|
51
51
|
* @returns The full verification result with a fresh signed image URL
|
|
52
52
|
*/
|
|
53
53
|
getVerification(id: string): Promise<VerificationResult>;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch the policy configuration (categories, attempt limits, UI copy).
|
|
56
|
+
*
|
|
57
|
+
* Use this to configure the scanner overlay with server-driven settings
|
|
58
|
+
* so you can update behavior without shipping a new app version.
|
|
59
|
+
*
|
|
60
|
+
* @param policyId - The policy ID (e.g., "pol_abc123")
|
|
61
|
+
* @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* const config = await client.fetchPolicyConfig('pol_abc123');
|
|
66
|
+
* // Use config to set scanner overlay props
|
|
67
|
+
* <VerifyAIScanner
|
|
68
|
+
* overlay={{
|
|
69
|
+
* maxAttempts: config.maxAttempts,
|
|
70
|
+
* autoApproveOnExhaust: config.autoApproveOnExhaust,
|
|
71
|
+
* processingMessage: config.uiCopy.processingMessage,
|
|
72
|
+
* }}
|
|
73
|
+
* />
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
fetchPolicyConfig(policyId: string): Promise<PolicyConfigResponse>;
|
|
54
77
|
}
|
|
55
78
|
export declare class VerifyAIRequestError extends Error {
|
|
56
79
|
status: number;
|
|
@@ -58,5 +81,7 @@ export declare class VerifyAIRequestError extends Error {
|
|
|
58
81
|
constructor(message: string, status: number, body: VerifyAIError);
|
|
59
82
|
get isRateLimited(): boolean;
|
|
60
83
|
get isUnauthorized(): boolean;
|
|
84
|
+
get isServerError(): boolean;
|
|
85
|
+
get isRetryable(): boolean;
|
|
61
86
|
get upgradeUrl(): string | undefined;
|
|
62
87
|
}
|
package/lib/client/index.js
CHANGED
|
@@ -21,10 +21,29 @@ export class VerifyAIClient {
|
|
|
21
21
|
...options.headers,
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
-
const
|
|
24
|
+
const rawBody = await response.text();
|
|
25
|
+
let body = null;
|
|
26
|
+
if (rawBody) {
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(rawBody);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
body = { error: rawBody };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
if (!response.ok) {
|
|
26
|
-
const
|
|
27
|
-
|
|
35
|
+
const errorBody = (body && typeof body === 'object' ? body : null);
|
|
36
|
+
const error = {
|
|
37
|
+
error: errorBody?.error || `Request failed with status ${response.status}`,
|
|
38
|
+
status: response.status,
|
|
39
|
+
current_usage: errorBody?.current_usage,
|
|
40
|
+
limit: errorBody?.limit,
|
|
41
|
+
upgrade_url: errorBody?.upgrade_url,
|
|
42
|
+
};
|
|
43
|
+
throw new VerifyAIRequestError(error.error, response.status, error);
|
|
44
|
+
}
|
|
45
|
+
if (!body || typeof body !== 'object') {
|
|
46
|
+
throw new Error('VerifyAI: Invalid response payload');
|
|
28
47
|
}
|
|
29
48
|
return body;
|
|
30
49
|
}
|
|
@@ -104,6 +123,31 @@ export class VerifyAIClient {
|
|
|
104
123
|
async getVerification(id) {
|
|
105
124
|
return this.request(`/verifications/${id}`);
|
|
106
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Fetch the policy configuration (categories, attempt limits, UI copy).
|
|
128
|
+
*
|
|
129
|
+
* Use this to configure the scanner overlay with server-driven settings
|
|
130
|
+
* so you can update behavior without shipping a new app version.
|
|
131
|
+
*
|
|
132
|
+
* @param policyId - The policy ID (e.g., "pol_abc123")
|
|
133
|
+
* @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* const config = await client.fetchPolicyConfig('pol_abc123');
|
|
138
|
+
* // Use config to set scanner overlay props
|
|
139
|
+
* <VerifyAIScanner
|
|
140
|
+
* overlay={{
|
|
141
|
+
* maxAttempts: config.maxAttempts,
|
|
142
|
+
* autoApproveOnExhaust: config.autoApproveOnExhaust,
|
|
143
|
+
* processingMessage: config.uiCopy.processingMessage,
|
|
144
|
+
* }}
|
|
145
|
+
* />
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
async fetchPolicyConfig(policyId) {
|
|
149
|
+
return this.request(`/policies/${policyId}/config`);
|
|
150
|
+
}
|
|
107
151
|
}
|
|
108
152
|
export class VerifyAIRequestError extends Error {
|
|
109
153
|
constructor(message, status, body) {
|
|
@@ -118,6 +162,12 @@ export class VerifyAIRequestError extends Error {
|
|
|
118
162
|
get isUnauthorized() {
|
|
119
163
|
return this.status === 401;
|
|
120
164
|
}
|
|
165
|
+
get isServerError() {
|
|
166
|
+
return this.status >= 500;
|
|
167
|
+
}
|
|
168
|
+
get isRetryable() {
|
|
169
|
+
return this.status === 408 || this.status === 429 || this.status >= 500;
|
|
170
|
+
}
|
|
121
171
|
get upgradeUrl() {
|
|
122
172
|
return this.body.upgrade_url;
|
|
123
173
|
}
|
|
@@ -16,6 +16,8 @@ export interface VerifyAIScannerProps {
|
|
|
16
16
|
showCaptureButton?: boolean;
|
|
17
17
|
/** Ref to imperatively trigger capture from parent. */
|
|
18
18
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
19
|
+
/** Whether to enable the camera torch/flashlight. */
|
|
20
|
+
enableTorch?: boolean;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Camera scanner component for capturing verification photos.
|
|
@@ -38,4 +40,4 @@ export interface VerifyAIScannerProps {
|
|
|
38
40
|
* />
|
|
39
41
|
* ```
|
|
40
42
|
*/
|
|
41
|
-
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
43
|
+
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -23,13 +23,15 @@ import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
|
23
23
|
* />
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
|
-
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, }) {
|
|
26
|
+
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, }) {
|
|
27
27
|
const cameraRef = useRef(null);
|
|
28
28
|
const [status, setStatus] = useState('idle');
|
|
29
29
|
const [result, setResult] = useState(null);
|
|
30
30
|
const [permission, requestPermission] = useCameraPermissions();
|
|
31
|
+
const attemptCountRef = useRef(0);
|
|
32
|
+
const [exhausted, setExhausted] = useState(false);
|
|
31
33
|
const handleCapture = useCallback(async () => {
|
|
32
|
-
if (!cameraRef.current || status === 'capturing' || status === 'processing')
|
|
34
|
+
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
33
35
|
return;
|
|
34
36
|
setStatus('capturing');
|
|
35
37
|
setResult(null);
|
|
@@ -44,7 +46,27 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
44
46
|
}
|
|
45
47
|
setStatus('processing');
|
|
46
48
|
const verificationResult = await onCapture(photo.base64);
|
|
49
|
+
attemptCountRef.current++;
|
|
47
50
|
if (verificationResult) {
|
|
51
|
+
const maxAttempts = overlay?.maxAttempts;
|
|
52
|
+
const autoApprove = overlay?.autoApproveOnExhaust ?? false;
|
|
53
|
+
// Check attempt exhaustion for non-compliant results
|
|
54
|
+
if (!verificationResult.is_compliant &&
|
|
55
|
+
maxAttempts != null &&
|
|
56
|
+
attemptCountRef.current >= maxAttempts) {
|
|
57
|
+
setExhausted(true);
|
|
58
|
+
if (autoApprove) {
|
|
59
|
+
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
60
|
+
setResult(approvedResult);
|
|
61
|
+
setStatus('success');
|
|
62
|
+
onResult?.(approvedResult);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
setResult(verificationResult);
|
|
66
|
+
setStatus('error');
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
48
70
|
setResult(verificationResult);
|
|
49
71
|
setStatus('success');
|
|
50
72
|
onResult?.(verificationResult);
|
|
@@ -62,7 +84,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
62
84
|
// Reset after a brief pause
|
|
63
85
|
setTimeout(() => setStatus('idle'), 2000);
|
|
64
86
|
}
|
|
65
|
-
}, [status, onCapture, onResult, onError]);
|
|
87
|
+
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
|
|
66
88
|
// Expose capture to parent via ref
|
|
67
89
|
if (captureRef) {
|
|
68
90
|
captureRef.current = handleCapture;
|
|
@@ -74,18 +96,38 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
74
96
|
return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
|
|
75
97
|
}
|
|
76
98
|
const showBottomCard = status === 'success' || status === 'error';
|
|
77
|
-
return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
|
|
99
|
+
return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: enableTorch, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
|
|
78
100
|
styles.guideFrame,
|
|
79
101
|
overlay.guideFrameAspectRatio
|
|
80
102
|
? { aspectRatio: overlay.guideFrameAspectRatio }
|
|
81
103
|
: undefined,
|
|
82
|
-
], children: [_jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }) })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children:
|
|
104
|
+
], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }) })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: styles.bottomArea, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
|
|
83
105
|
styles.resultIconCircle,
|
|
84
106
|
result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
|
|
85
107
|
], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
|
|
86
108
|
styles.resultLabel,
|
|
87
109
|
result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
|
|
88
|
-
], children: result.is_compliant
|
|
110
|
+
], children: result.is_compliant
|
|
111
|
+
? (overlay?.successMessage || 'Verified')
|
|
112
|
+
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
|
|
113
|
+
let errorTitle;
|
|
114
|
+
let errorMessage;
|
|
115
|
+
if (exhausted) {
|
|
116
|
+
errorTitle = 'Attempts Exhausted';
|
|
117
|
+
errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
|
|
118
|
+
}
|
|
119
|
+
else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
|
|
120
|
+
const remaining = overlay.maxAttempts - attemptCountRef.current;
|
|
121
|
+
errorTitle = 'Not Verified';
|
|
122
|
+
const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
|
|
123
|
+
errorMessage = template.replace('{remaining}', String(remaining));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
errorTitle = 'Something went wrong';
|
|
127
|
+
errorMessage = "We couldn't process your photo. Please try again.";
|
|
128
|
+
}
|
|
129
|
+
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
|
|
130
|
+
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: styles.instructionsText, children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
|
|
89
131
|
styles.captureButton,
|
|
90
132
|
(status === 'capturing' || status === 'processing') &&
|
|
91
133
|
styles.captureButtonDisabled,
|
package/lib/hooks/useVerifyAI.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { useMemo, useCallback, useState, useEffect } from 'react';
|
|
2
2
|
import { AppState } from 'react-native';
|
|
3
|
-
import { VerifyAIClient } from '../client';
|
|
3
|
+
import { VerifyAIClient, VerifyAIRequestError } from '../client';
|
|
4
4
|
import { OfflineQueue } from '../storage/offlineQueue';
|
|
5
|
+
function isQueueableError(error) {
|
|
6
|
+
if (error instanceof VerifyAIRequestError) {
|
|
7
|
+
return error.isRetryable;
|
|
8
|
+
}
|
|
9
|
+
if (error.name === 'AbortError') {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
const message = error.message.toLowerCase();
|
|
13
|
+
return (message.includes('network') ||
|
|
14
|
+
message.includes('timeout') ||
|
|
15
|
+
message.includes('timed out') ||
|
|
16
|
+
message.includes('failed to fetch'));
|
|
17
|
+
}
|
|
5
18
|
/**
|
|
6
19
|
* React hook for Verify AI. Provides verification methods,
|
|
7
20
|
* loading/error state, and optional offline queue management.
|
|
@@ -62,8 +75,8 @@ export function useVerifyAI(config) {
|
|
|
62
75
|
catch (err) {
|
|
63
76
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
64
77
|
setError(error);
|
|
65
|
-
//
|
|
66
|
-
if (offlineQueue) {
|
|
78
|
+
// Queue only transient failures so invalid requests are surfaced immediately.
|
|
79
|
+
if (offlineQueue && isQueueableError(error)) {
|
|
67
80
|
await offlineQueue.enqueue(request);
|
|
68
81
|
await refreshQueueSize();
|
|
69
82
|
return null;
|
package/lib/index.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export type { UseVerifyAIReturn } from './hooks/useVerifyAI';
|
|
|
4
4
|
export { VerifyAIScanner } from './components/VerifyAIScanner';
|
|
5
5
|
export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
|
|
6
6
|
export { OfflineQueue } from './storage/offlineQueue';
|
|
7
|
-
export type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, } from './types';
|
|
7
|
+
export type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, PolicyConfigResponse, } from './types';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import { VerifyAIRequestError } from '../client';
|
|
2
3
|
const MANIFEST_KEY = '@verifyai/queue_manifest';
|
|
3
4
|
const ITEM_PREFIX = '@verifyai/queue_item_';
|
|
4
5
|
const LEGACY_KEY = '@verifyai/offline_queue';
|
|
@@ -157,9 +158,11 @@ export class OfflineQueue {
|
|
|
157
158
|
await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
|
|
158
159
|
onResult?.(item.id, result);
|
|
159
160
|
}
|
|
160
|
-
catch {
|
|
161
|
+
catch (err) {
|
|
162
|
+
const requestError = err instanceof VerifyAIRequestError ? err : null;
|
|
163
|
+
const shouldRetry = !requestError || requestError.isRetryable;
|
|
161
164
|
item.retryCount++;
|
|
162
|
-
if (item.retryCount < maxRetries) {
|
|
165
|
+
if (shouldRetry && item.retryCount < maxRetries) {
|
|
163
166
|
await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
|
|
164
167
|
remainingIds.push(id);
|
|
165
168
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type React from 'react';
|
|
1
2
|
export interface VerifyAIConfig {
|
|
2
3
|
apiKey: string;
|
|
3
4
|
baseUrl?: string;
|
|
@@ -44,7 +45,7 @@ export interface QueueItem {
|
|
|
44
45
|
}
|
|
45
46
|
export interface VerifyAIError {
|
|
46
47
|
error: string;
|
|
47
|
-
status
|
|
48
|
+
status?: number;
|
|
48
49
|
current_usage?: number;
|
|
49
50
|
limit?: number;
|
|
50
51
|
upgrade_url?: string;
|
|
@@ -55,4 +56,37 @@ export interface ScannerOverlayConfig {
|
|
|
55
56
|
instructions?: string;
|
|
56
57
|
showGuideFrame?: boolean;
|
|
57
58
|
guideFrameAspectRatio?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Optional React element rendered inside the guide frame as a semi-transparent
|
|
61
|
+
* overlay (e.g. a silhouette image to guide photo composition).
|
|
62
|
+
*
|
|
63
|
+
* The element is absolutely positioned to fill the guide frame.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* guideOverlayContent: <Image source={require('./bike_silhouette.png')} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
guideOverlayContent?: React.ReactNode;
|
|
71
|
+
/** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
|
|
72
|
+
guideOverlayOpacity?: number;
|
|
73
|
+
processingMessage?: string;
|
|
74
|
+
successMessage?: string;
|
|
75
|
+
failureMessage?: string;
|
|
76
|
+
retryMessage?: string;
|
|
77
|
+
exhaustedMessage?: string;
|
|
78
|
+
maxAttempts?: number;
|
|
79
|
+
autoApproveOnExhaust?: boolean;
|
|
80
|
+
}
|
|
81
|
+
export interface PolicyConfigResponse {
|
|
82
|
+
maxAttempts: number;
|
|
83
|
+
autoApproveOnExhaust: boolean;
|
|
84
|
+
uiCopy: Record<string, string | undefined>;
|
|
85
|
+
categories: Array<{
|
|
86
|
+
id: string;
|
|
87
|
+
label: string;
|
|
88
|
+
color: string;
|
|
89
|
+
isCompliant: boolean;
|
|
90
|
+
description?: string;
|
|
91
|
+
}>;
|
|
58
92
|
}
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
VerificationListResponse,
|
|
6
6
|
VerificationListParams,
|
|
7
7
|
VerifyAIError,
|
|
8
|
+
PolicyConfigResponse,
|
|
8
9
|
} from '../types';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
|
|
@@ -41,17 +42,38 @@ export class VerifyAIClient {
|
|
|
41
42
|
},
|
|
42
43
|
});
|
|
43
44
|
|
|
44
|
-
const
|
|
45
|
+
const rawBody = await response.text();
|
|
46
|
+
let body: unknown = null;
|
|
47
|
+
|
|
48
|
+
if (rawBody) {
|
|
49
|
+
try {
|
|
50
|
+
body = JSON.parse(rawBody);
|
|
51
|
+
} catch {
|
|
52
|
+
body = { error: rawBody };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
45
55
|
|
|
46
56
|
if (!response.ok) {
|
|
47
|
-
const
|
|
57
|
+
const errorBody = (body && typeof body === 'object' ? body : null) as VerifyAIError | null;
|
|
58
|
+
const error: VerifyAIError = {
|
|
59
|
+
error: errorBody?.error || `Request failed with status ${response.status}`,
|
|
60
|
+
status: response.status,
|
|
61
|
+
current_usage: errorBody?.current_usage,
|
|
62
|
+
limit: errorBody?.limit,
|
|
63
|
+
upgrade_url: errorBody?.upgrade_url,
|
|
64
|
+
};
|
|
65
|
+
|
|
48
66
|
throw new VerifyAIRequestError(
|
|
49
|
-
error.error
|
|
67
|
+
error.error,
|
|
50
68
|
response.status,
|
|
51
69
|
error
|
|
52
70
|
);
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
if (!body || typeof body !== 'object') {
|
|
74
|
+
throw new Error('VerifyAI: Invalid response payload');
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
return body as T;
|
|
56
78
|
} finally {
|
|
57
79
|
clearTimeout(timer);
|
|
@@ -131,6 +153,32 @@ export class VerifyAIClient {
|
|
|
131
153
|
async getVerification(id: string): Promise<VerificationResult> {
|
|
132
154
|
return this.request<VerificationResult>(`/verifications/${id}`);
|
|
133
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fetch the policy configuration (categories, attempt limits, UI copy).
|
|
159
|
+
*
|
|
160
|
+
* Use this to configure the scanner overlay with server-driven settings
|
|
161
|
+
* so you can update behavior without shipping a new app version.
|
|
162
|
+
*
|
|
163
|
+
* @param policyId - The policy ID (e.g., "pol_abc123")
|
|
164
|
+
* @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* const config = await client.fetchPolicyConfig('pol_abc123');
|
|
169
|
+
* // Use config to set scanner overlay props
|
|
170
|
+
* <VerifyAIScanner
|
|
171
|
+
* overlay={{
|
|
172
|
+
* maxAttempts: config.maxAttempts,
|
|
173
|
+
* autoApproveOnExhaust: config.autoApproveOnExhaust,
|
|
174
|
+
* processingMessage: config.uiCopy.processingMessage,
|
|
175
|
+
* }}
|
|
176
|
+
* />
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
async fetchPolicyConfig(policyId: string): Promise<PolicyConfigResponse> {
|
|
180
|
+
return this.request<PolicyConfigResponse>(`/policies/${policyId}/config`);
|
|
181
|
+
}
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
export class VerifyAIRequestError extends Error {
|
|
@@ -152,6 +200,14 @@ export class VerifyAIRequestError extends Error {
|
|
|
152
200
|
return this.status === 401;
|
|
153
201
|
}
|
|
154
202
|
|
|
203
|
+
get isServerError(): boolean {
|
|
204
|
+
return this.status >= 500;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get isRetryable(): boolean {
|
|
208
|
+
return this.status === 408 || this.status === 429 || this.status >= 500;
|
|
209
|
+
}
|
|
210
|
+
|
|
155
211
|
get upgradeUrl(): string | undefined {
|
|
156
212
|
return this.body.upgrade_url;
|
|
157
213
|
}
|
|
@@ -33,6 +33,8 @@ export interface VerifyAIScannerProps {
|
|
|
33
33
|
showCaptureButton?: boolean;
|
|
34
34
|
/** Ref to imperatively trigger capture from parent. */
|
|
35
35
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
36
|
+
/** Whether to enable the camera torch/flashlight. */
|
|
37
|
+
enableTorch?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -64,14 +66,17 @@ export function VerifyAIScanner({
|
|
|
64
66
|
style,
|
|
65
67
|
showCaptureButton = true,
|
|
66
68
|
captureRef,
|
|
69
|
+
enableTorch,
|
|
67
70
|
}: VerifyAIScannerProps) {
|
|
68
71
|
const cameraRef = useRef<CameraView>(null);
|
|
69
72
|
const [status, setStatus] = useState<ScannerStatus>('idle');
|
|
70
73
|
const [result, setResult] = useState<VerificationResult | null>(null);
|
|
71
74
|
const [permission, requestPermission] = useCameraPermissions();
|
|
75
|
+
const attemptCountRef = useRef(0);
|
|
76
|
+
const [exhausted, setExhausted] = useState(false);
|
|
72
77
|
|
|
73
78
|
const handleCapture = useCallback(async () => {
|
|
74
|
-
if (!cameraRef.current || status === 'capturing' || status === 'processing') return;
|
|
79
|
+
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
75
80
|
|
|
76
81
|
setStatus('capturing');
|
|
77
82
|
setResult(null);
|
|
@@ -90,7 +95,29 @@ export function VerifyAIScanner({
|
|
|
90
95
|
setStatus('processing');
|
|
91
96
|
const verificationResult = await onCapture(photo.base64);
|
|
92
97
|
|
|
98
|
+
attemptCountRef.current++;
|
|
99
|
+
|
|
93
100
|
if (verificationResult) {
|
|
101
|
+
const maxAttempts = overlay?.maxAttempts;
|
|
102
|
+
const autoApprove = overlay?.autoApproveOnExhaust ?? false;
|
|
103
|
+
|
|
104
|
+
// Check attempt exhaustion for non-compliant results
|
|
105
|
+
if (!verificationResult.is_compliant &&
|
|
106
|
+
maxAttempts != null &&
|
|
107
|
+
attemptCountRef.current >= maxAttempts) {
|
|
108
|
+
setExhausted(true);
|
|
109
|
+
if (autoApprove) {
|
|
110
|
+
const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
|
|
111
|
+
setResult(approvedResult);
|
|
112
|
+
setStatus('success');
|
|
113
|
+
onResult?.(approvedResult);
|
|
114
|
+
} else {
|
|
115
|
+
setResult(verificationResult);
|
|
116
|
+
setStatus('error');
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
setResult(verificationResult);
|
|
95
122
|
setStatus('success');
|
|
96
123
|
onResult?.(verificationResult);
|
|
@@ -106,7 +133,7 @@ export function VerifyAIScanner({
|
|
|
106
133
|
// Reset after a brief pause
|
|
107
134
|
setTimeout(() => setStatus('idle'), 2000);
|
|
108
135
|
}
|
|
109
|
-
}, [status, onCapture, onResult, onError]);
|
|
136
|
+
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
|
|
110
137
|
|
|
111
138
|
// Expose capture to parent via ref
|
|
112
139
|
if (captureRef) {
|
|
@@ -132,7 +159,7 @@ export function VerifyAIScanner({
|
|
|
132
159
|
|
|
133
160
|
return (
|
|
134
161
|
<View style={[styles.container, style]}>
|
|
135
|
-
<CameraView ref={cameraRef} style={styles.camera} facing="back">
|
|
162
|
+
<CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={enableTorch}>
|
|
136
163
|
{/* Overlay */}
|
|
137
164
|
<View style={styles.overlay}>
|
|
138
165
|
{overlay?.title && (
|
|
@@ -151,6 +178,12 @@ export function VerifyAIScanner({
|
|
|
151
178
|
: undefined,
|
|
152
179
|
]}
|
|
153
180
|
>
|
|
181
|
+
{/* Guide overlay (e.g. bike silhouette) — rendered behind corners */}
|
|
182
|
+
{overlay.guideOverlayContent && (
|
|
183
|
+
<View style={[StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }]}>
|
|
184
|
+
{overlay.guideOverlayContent}
|
|
185
|
+
</View>
|
|
186
|
+
)}
|
|
154
187
|
{/* Corner brackets */}
|
|
155
188
|
<View style={[styles.corner, styles.cornerTopLeft]} />
|
|
156
189
|
<View style={[styles.corner, styles.cornerTopRight]} />
|
|
@@ -164,7 +197,9 @@ export function VerifyAIScanner({
|
|
|
164
197
|
{status === 'processing' && (
|
|
165
198
|
<View style={styles.processingOverlay}>
|
|
166
199
|
<ActivityIndicator size="large" color="#fff" />
|
|
167
|
-
<Text style={styles.statusText}>
|
|
200
|
+
<Text style={styles.statusText}>
|
|
201
|
+
{overlay?.processingMessage || 'Analyzing photo...'}
|
|
202
|
+
</Text>
|
|
168
203
|
</View>
|
|
169
204
|
)}
|
|
170
205
|
|
|
@@ -192,28 +227,48 @@ export function VerifyAIScanner({
|
|
|
192
227
|
result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
|
|
193
228
|
]}
|
|
194
229
|
>
|
|
195
|
-
{result.is_compliant
|
|
230
|
+
{result.is_compliant
|
|
231
|
+
? (overlay?.successMessage || 'Verified')
|
|
232
|
+
: (overlay?.failureMessage || 'Not Verified')}
|
|
196
233
|
</Text>
|
|
197
234
|
</View>
|
|
198
235
|
<Text style={styles.feedbackText}>{result.feedback}</Text>
|
|
199
236
|
</View>
|
|
200
237
|
)}
|
|
201
238
|
|
|
202
|
-
{status === 'error' && (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
239
|
+
{status === 'error' && (() => {
|
|
240
|
+
let errorTitle: string;
|
|
241
|
+
let errorMessage: string;
|
|
242
|
+
|
|
243
|
+
if (exhausted) {
|
|
244
|
+
errorTitle = 'Attempts Exhausted';
|
|
245
|
+
errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
|
|
246
|
+
} else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
|
|
247
|
+
const remaining = overlay.maxAttempts - attemptCountRef.current;
|
|
248
|
+
errorTitle = 'Not Verified';
|
|
249
|
+
const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
|
|
250
|
+
errorMessage = template.replace('{remaining}', String(remaining));
|
|
251
|
+
} else {
|
|
252
|
+
errorTitle = 'Something went wrong';
|
|
253
|
+
errorMessage = "We couldn't process your photo. Please try again.";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<View style={styles.resultCard}>
|
|
258
|
+
<View style={styles.resultCardHeader}>
|
|
259
|
+
<View style={[styles.resultIconCircle, styles.resultIconError]}>
|
|
260
|
+
<Text style={styles.resultIcon}>!</Text>
|
|
261
|
+
</View>
|
|
262
|
+
<Text style={[styles.resultLabel, styles.resultLabelError]}>
|
|
263
|
+
{errorTitle}
|
|
264
|
+
</Text>
|
|
207
265
|
</View>
|
|
208
|
-
<Text style={
|
|
209
|
-
|
|
266
|
+
<Text style={styles.feedbackText}>
|
|
267
|
+
{errorMessage}
|
|
210
268
|
</Text>
|
|
211
269
|
</View>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
</Text>
|
|
215
|
-
</View>
|
|
216
|
-
)}
|
|
270
|
+
);
|
|
271
|
+
})()}
|
|
217
272
|
|
|
218
273
|
{!showBottomCard && (
|
|
219
274
|
<>
|
package/src/hooks/useVerifyAI.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, useCallback, useState, useEffect } from 'react';
|
|
2
2
|
import { AppState, type AppStateStatus } from 'react-native';
|
|
3
|
-
import { VerifyAIClient } from '../client';
|
|
3
|
+
import { VerifyAIClient, VerifyAIRequestError } from '../client';
|
|
4
4
|
import { OfflineQueue } from '../storage/offlineQueue';
|
|
5
5
|
import type {
|
|
6
6
|
VerifyAIConfig,
|
|
@@ -10,6 +10,24 @@ import type {
|
|
|
10
10
|
VerificationListResponse,
|
|
11
11
|
} from '../types';
|
|
12
12
|
|
|
13
|
+
function isQueueableError(error: Error): boolean {
|
|
14
|
+
if (error instanceof VerifyAIRequestError) {
|
|
15
|
+
return error.isRetryable;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (error.name === 'AbortError') {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const message = error.message.toLowerCase();
|
|
23
|
+
return (
|
|
24
|
+
message.includes('network') ||
|
|
25
|
+
message.includes('timeout') ||
|
|
26
|
+
message.includes('timed out') ||
|
|
27
|
+
message.includes('failed to fetch')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
export interface UseVerifyAIReturn {
|
|
14
32
|
/** Submit a verification. Uses offline queue if offlineMode is enabled and request fails. */
|
|
15
33
|
verify: (request: VerificationRequest) => Promise<VerificationResult | null>;
|
|
@@ -104,8 +122,8 @@ export function useVerifyAI(config: VerifyAIConfig): UseVerifyAIReturn {
|
|
|
104
122
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
105
123
|
setError(error);
|
|
106
124
|
|
|
107
|
-
//
|
|
108
|
-
if (offlineQueue) {
|
|
125
|
+
// Queue only transient failures so invalid requests are surfaced immediately.
|
|
126
|
+
if (offlineQueue && isQueueableError(error)) {
|
|
109
127
|
await offlineQueue.enqueue(request);
|
|
110
128
|
await refreshQueueSize();
|
|
111
129
|
return null;
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
2
|
import type { VerificationRequest, VerificationResult, QueueItem } from '../types';
|
|
3
|
-
import { VerifyAIClient } from '../client';
|
|
3
|
+
import { VerifyAIClient, VerifyAIRequestError } from '../client';
|
|
4
4
|
|
|
5
5
|
const MANIFEST_KEY = '@verifyai/queue_manifest';
|
|
6
6
|
const ITEM_PREFIX = '@verifyai/queue_item_';
|
|
@@ -179,9 +179,12 @@ export class OfflineQueue {
|
|
|
179
179
|
processed++;
|
|
180
180
|
await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
|
|
181
181
|
onResult?.(item.id, result);
|
|
182
|
-
} catch {
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const requestError = err instanceof VerifyAIRequestError ? err : null;
|
|
184
|
+
const shouldRetry = !requestError || requestError.isRetryable;
|
|
185
|
+
|
|
183
186
|
item.retryCount++;
|
|
184
|
-
if (item.retryCount < maxRetries) {
|
|
187
|
+
if (shouldRetry && item.retryCount < maxRetries) {
|
|
185
188
|
await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
|
|
186
189
|
remainingIds.push(id);
|
|
187
190
|
} else {
|
package/src/types/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
1
3
|
export interface VerifyAIConfig {
|
|
2
4
|
apiKey: string;
|
|
3
5
|
baseUrl?: string;
|
|
@@ -50,7 +52,7 @@ export interface QueueItem {
|
|
|
50
52
|
|
|
51
53
|
export interface VerifyAIError {
|
|
52
54
|
error: string;
|
|
53
|
-
status
|
|
55
|
+
status?: number;
|
|
54
56
|
current_usage?: number;
|
|
55
57
|
limit?: number;
|
|
56
58
|
upgrade_url?: string;
|
|
@@ -63,4 +65,38 @@ export interface ScannerOverlayConfig {
|
|
|
63
65
|
instructions?: string;
|
|
64
66
|
showGuideFrame?: boolean;
|
|
65
67
|
guideFrameAspectRatio?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Optional React element rendered inside the guide frame as a semi-transparent
|
|
70
|
+
* overlay (e.g. a silhouette image to guide photo composition).
|
|
71
|
+
*
|
|
72
|
+
* The element is absolutely positioned to fill the guide frame.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```tsx
|
|
76
|
+
* guideOverlayContent: <Image source={require('./bike_silhouette.png')} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
guideOverlayContent?: React.ReactNode;
|
|
80
|
+
/** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
|
|
81
|
+
guideOverlayOpacity?: number;
|
|
82
|
+
processingMessage?: string;
|
|
83
|
+
successMessage?: string;
|
|
84
|
+
failureMessage?: string;
|
|
85
|
+
retryMessage?: string; // supports {remaining} placeholder
|
|
86
|
+
exhaustedMessage?: string;
|
|
87
|
+
maxAttempts?: number;
|
|
88
|
+
autoApproveOnExhaust?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface PolicyConfigResponse {
|
|
92
|
+
maxAttempts: number;
|
|
93
|
+
autoApproveOnExhaust: boolean;
|
|
94
|
+
uiCopy: Record<string, string | undefined>;
|
|
95
|
+
categories: Array<{
|
|
96
|
+
id: string;
|
|
97
|
+
label: string;
|
|
98
|
+
color: string;
|
|
99
|
+
isCompliant: boolean;
|
|
100
|
+
description?: string;
|
|
101
|
+
}>;
|
|
66
102
|
}
|