Merge pull request #1889 from selfxyz/release/staging-2026-03-30
Release to Staging v2.9.16 - 2026-03-30
@@ -1,16 +1,19 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
|
||||
language: "en-US"
|
||||
tone_instructions: |
|
||||
You are an expert reviewer for a React Native and TypeScript mobile app with smart contract integration.
|
||||
Focus on security, performance, and best practices.
|
||||
Only highlight issues of medium or higher priority.
|
||||
tone_instructions: "Only report bugs, security vulnerabilities, data loss, or production incidents. No nitpicks, style suggestions, missing comments, optional improvements, or test mock wiring. When unsure, do not post."
|
||||
|
||||
reviews:
|
||||
profile: "chill"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: true
|
||||
poem: false
|
||||
sequence_diagrams: false
|
||||
related_issues: false
|
||||
related_prs: false
|
||||
suggested_labels: false
|
||||
suggested_reviewers: false
|
||||
enable_prompt_for_ai_agents: false
|
||||
review_status: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
@@ -20,77 +23,22 @@ reviews:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
path_instructions:
|
||||
- path: "app/src/**/*.{ts,tsx,js,jsx}"
|
||||
instructions: |
|
||||
Review React Native TypeScript code for:
|
||||
- Component architecture and reusability
|
||||
- State management patterns
|
||||
- Performance optimizations
|
||||
- TypeScript type safety
|
||||
- React hooks usage and dependencies
|
||||
- Navigation patterns
|
||||
- path: "contracts/**/*.sol"
|
||||
instructions: |
|
||||
Review Solidity smart contracts for:
|
||||
- Security vulnerabilities (reentrancy, overflow, etc.)
|
||||
- Gas optimization opportunities
|
||||
- Access control patterns
|
||||
- Event emission for important state changes
|
||||
- Code documentation and NatSpec comments
|
||||
Focus exclusively on security vulnerabilities (reentrancy, overflow, access control)
|
||||
and correctness bugs. Ignore style, gas optimization suggestions, and missing NatSpec.
|
||||
- path: "circuits/**/*.circom"
|
||||
instructions: |
|
||||
Review ZK circuit code for:
|
||||
- Circuit correctness and completeness
|
||||
- Constraint efficiency
|
||||
- Input validation
|
||||
- Security considerations for zero-knowledge proofs
|
||||
Focus on circuit correctness, constraint soundness, and input validation bugs only.
|
||||
- path: "noir/**/*.nr"
|
||||
instructions: |
|
||||
Review Noir circuits for:
|
||||
- Constraint and witness correctness
|
||||
- Efficient proof generation and verification
|
||||
- Soundness and security assumptions
|
||||
- Clear separation of public and private inputs
|
||||
- path: "**/*.{test,spec}.{ts,js,tsx,jsx}"
|
||||
instructions: |
|
||||
Review test files for:
|
||||
- Test coverage completeness
|
||||
- Test case quality and edge cases
|
||||
- Mock usage appropriateness
|
||||
- Test readability and maintainability
|
||||
- path: "common/src/**/*.{ts,tsx,js,jsx}"
|
||||
instructions: |
|
||||
Review shared utilities for:
|
||||
- Reusability and modular design
|
||||
- Type safety and error handling
|
||||
- Side-effect management
|
||||
- Documentation and naming clarity
|
||||
- path: "sdk/**/*.{ts,tsx,js,jsx}"
|
||||
instructions: |
|
||||
Review SDK packages for:
|
||||
- Public API design and stability
|
||||
- Asynchronous flows and error handling
|
||||
- Security and input validation
|
||||
- Compatibility across environments
|
||||
- path: "packages/mobile-sdk-alpha/**/*.{ts,tsx,js,jsx}"
|
||||
instructions: |
|
||||
Review alpha mobile SDK code for:
|
||||
- API consistency with core SDK
|
||||
- Platform-neutral abstractions
|
||||
- Performance considerations
|
||||
- Clear experimental notes or TODOs
|
||||
- path: "app/android/**/*"
|
||||
instructions: |
|
||||
Review Android-specific code for:
|
||||
- Platform-specific implementations
|
||||
- Performance considerations
|
||||
- Security best practices for mobile
|
||||
Focus on constraint correctness, soundness bugs, and public/private input misuse only.
|
||||
- path: "app/ios/**/*"
|
||||
instructions: |
|
||||
Review iOS-specific code for:
|
||||
- Platform-specific implementations
|
||||
- Performance considerations
|
||||
- Security best practices for mobile
|
||||
Focus on security issues (keychain misuse, insecure storage) and crash-causing bugs only.
|
||||
- path: "app/android/**/*"
|
||||
instructions: |
|
||||
Focus on security issues and crash-causing bugs only.
|
||||
|
||||
chat:
|
||||
auto_reply: true
|
||||
|
||||
2
.gitignore
vendored
@@ -55,3 +55,5 @@ contracts/broadcast/
|
||||
# Keep RN test app config files tracked (global gitignore may ignore *.config.*)
|
||||
!packages/rn-sdk-test-app/metro.config.cjs
|
||||
!packages/rn-sdk-test-app/react-native.config.cjs
|
||||
packages/native-shell-android/.gradle/
|
||||
packages/native-shell-android/build/
|
||||
|
||||
@@ -8,6 +8,18 @@ import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
|
||||
type RecoveryVerification = {
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
status: 'pending' | 'processing' | 'failed';
|
||||
timeoutAt: number;
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
function getRecoveryIdentifier(verification: RecoveryVerification) {
|
||||
return verification.sessionId ?? verification.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to recover pending KYC verifications on app restart.
|
||||
*
|
||||
@@ -65,39 +77,46 @@ export function usePendingKycRecovery() {
|
||||
v.status === 'processing' &&
|
||||
v.documentId &&
|
||||
v.timeoutAt > Date.now() &&
|
||||
!hasAttemptedRecoveryRef.current.has(v.sessionId),
|
||||
!!getRecoveryIdentifier(v) &&
|
||||
!hasAttemptedRecoveryRef.current.has(getRecoveryIdentifier(v)!),
|
||||
);
|
||||
|
||||
if (processingWithDocument) {
|
||||
const recoveryId = getRecoveryIdentifier(processingWithDocument);
|
||||
|
||||
if (!recoveryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
|
||||
processingWithDocument.sessionId,
|
||||
recoveryId,
|
||||
);
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
// Only mark as attempted after successful navigation
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
|
||||
hasAttemptedRecoveryRef.current.add(recoveryId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation not ready yet - poll until ready
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation not ready, polling for readiness:',
|
||||
processingWithDocument.sessionId,
|
||||
recoveryId,
|
||||
);
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
if (navigationRef.isReady()) {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation ready, navigating for:',
|
||||
processingWithDocument.sessionId,
|
||||
recoveryId,
|
||||
);
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.sessionId);
|
||||
hasAttemptedRecoveryRef.current.add(recoveryId);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 100); // Poll every 100ms
|
||||
@@ -112,16 +131,23 @@ export function usePendingKycRecovery() {
|
||||
v =>
|
||||
v.status === 'pending' &&
|
||||
v.timeoutAt > Date.now() &&
|
||||
!hasAttemptedRecoveryRef.current.has(v.sessionId),
|
||||
!!getRecoveryIdentifier(v) &&
|
||||
!hasAttemptedRecoveryRef.current.has(getRecoveryIdentifier(v)!),
|
||||
);
|
||||
|
||||
if (firstPending) {
|
||||
hasAttemptedRecoveryRef.current.add(firstPending.sessionId);
|
||||
const recoveryId = getRecoveryIdentifier(firstPending);
|
||||
|
||||
if (!recoveryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAttemptedRecoveryRef.current.add(recoveryId);
|
||||
console.log(
|
||||
'[PendingKycRecovery] Recovering pending verification:',
|
||||
firstPending.sessionId,
|
||||
recoveryId,
|
||||
);
|
||||
subscribe(firstPending.sessionId);
|
||||
subscribe(recoveryId);
|
||||
}
|
||||
}, [pendingVerifications, subscribe, unsubscribeAll]);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,24 @@ describe('usePendingKycRecovery', () => {
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('session-789');
|
||||
});
|
||||
|
||||
it('should recover legacy pending verification entries that only store userId', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'legacy-session-123',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('legacy-session-123');
|
||||
});
|
||||
|
||||
it('should skip expired verifications', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
|
||||
@@ -251,16 +251,19 @@ export const generateKycRegisterInput = async (
|
||||
pubkeyStr: [string, string],
|
||||
secret: string
|
||||
) => {
|
||||
const applicantInfo = deserializeApplicantInfo(applicantInfoBase64);
|
||||
const signature = deserializeSignature(signatureBase64);
|
||||
const pubkey = [BigInt(pubkeyStr[0]), BigInt(pubkeyStr[1])] as [bigint, bigint];
|
||||
|
||||
const serializedData = serializeKycData(applicantInfo).padEnd(KYC_MAX_LENGTH, '\0');
|
||||
|
||||
const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
|
||||
// Use raw bytes directly — deserialize→reserialize strips the namespace prefix
|
||||
// from id_type, producing different bytes than the TEE signed.
|
||||
const raw = Buffer.from(applicantInfoBase64, 'base64');
|
||||
const dataPadded = [
|
||||
...Array.from(raw, (b) => Number(b)),
|
||||
...new Array(Math.max(0, KYC_MAX_LENGTH - raw.length)).fill(0),
|
||||
];
|
||||
|
||||
const kycRegisterInput: KycRegisterInput = {
|
||||
data_padded: msgPadded,
|
||||
data_padded: dataPadded,
|
||||
s: signature.s,
|
||||
R: signature.R,
|
||||
pubKey: pubkey,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Base8, inCurve, mulPointEscalar, subOrder } from '@zk-kit/baby-jubjub';
|
||||
|
||||
import { deserializeApplicantInfo, deserializeSignature } from '../../documents/kyc/api.js';
|
||||
import { deserializeSignature } from '../../documents/kyc/api.js';
|
||||
import { KYC_MAX_LENGTH } from '../../documents/kyc/constants.js';
|
||||
import type { KycRegisterInput } from '../../documents/kyc/types.js';
|
||||
import { serializeKycData } from '../../documents/kyc/types.js';
|
||||
@@ -13,15 +13,19 @@ export function generateKycRegisterInputs(
|
||||
pubkeyStr: [string, string],
|
||||
secret: string,
|
||||
): KycRegisterInput {
|
||||
const applicantInfo = deserializeApplicantInfo(applicantInfoBase64);
|
||||
const signature = deserializeSignature(signatureBase64);
|
||||
const pubkey = [BigInt(pubkeyStr[0]), BigInt(pubkeyStr[1])] as [bigint, bigint];
|
||||
|
||||
const serializedData = serializeKycData(applicantInfo).padEnd(KYC_MAX_LENGTH, '\0');
|
||||
const msgPadded = Array.from(serializedData, x => x.charCodeAt(0));
|
||||
// Use raw bytes directly — deserialize→reserialize strips the namespace prefix
|
||||
// from id_type, producing different bytes than the TEE signed.
|
||||
const raw = Buffer.from(applicantInfoBase64, 'base64');
|
||||
const dataPadded = [
|
||||
...Array.from(raw, b => Number(b)),
|
||||
...new Array(Math.max(0, KYC_MAX_LENGTH - raw.length)).fill(0),
|
||||
];
|
||||
|
||||
return {
|
||||
data_padded: msgPadded,
|
||||
data_padded: dataPadded,
|
||||
s: signature.s,
|
||||
R: signature.R,
|
||||
pubKey: pubkey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "self-workspace-root",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"app",
|
||||
|
||||
|
After Width: | Height: | Size: 538 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 220 KiB |
@@ -0,0 +1 @@
|
||||
@font-face{font-family:Advercase-Regular;src:url(../fonts/Advercase-Regular.otf) format("opentype");font-display:swap}@font-face{font-family:DINOT-Bold;src:url(../fonts/DINOT-Bold.otf) format("opentype");font-weight:700;font-display:swap}@font-face{font-family:DINOT-Medium;src:url(../fonts/DINOT-Medium.otf) format("opentype");font-weight:500;font-display:swap}@font-face{font-family:IBMPlexMono-Regular;src:url(../fonts/IBMPlexMono-Regular.otf) format("opentype");font-display:swap}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;width:100%;overflow:hidden}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 381 KiB |
BIN
packages/native-shell-android/src/main/assets/self-wallet/fonts/DINOT-Medium.otf
Executable file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Self</title>
|
||||
<script type="module" crossorigin src="./assets/index-JxbVYeGE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-VdzGwUkN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,10 +9,16 @@ import xyz.self.sdk.webview.SelfVerificationActivity
|
||||
object SelfSdk {
|
||||
fun launch(activity: Activity, config: SelfSdkConfig, requestCode: Int = REQUEST_CODE_VERIFICATION) {
|
||||
val intent = Intent(activity, SelfVerificationActivity::class.java).apply {
|
||||
putExtra(SelfVerificationActivity.EXTRA_TEE_URL, config.teeUrl)
|
||||
putExtra(SelfVerificationActivity.EXTRA_ENVIRONMENT, config.environment)
|
||||
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_ID, config.verificationId)
|
||||
putExtra(SelfVerificationActivity.EXTRA_USER_ID, config.userId)
|
||||
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.isDebugMode)
|
||||
putExtra(SelfVerificationActivity.EXTRA_VERSION, config.version)
|
||||
config.scope?.let { putExtra(SelfVerificationActivity.EXTRA_SCOPE, it) }
|
||||
config.disclosures?.let { putStringArrayListExtra(SelfVerificationActivity.EXTRA_DISCLOSURES, ArrayList(it)) }
|
||||
config.appName?.let { putExtra(SelfVerificationActivity.EXTRA_APP_NAME, it) }
|
||||
config.appEndpoint?.let { putExtra(SelfVerificationActivity.EXTRA_APP_ENDPOINT, it) }
|
||||
config.resultType?.let { putExtra(SelfVerificationActivity.EXTRA_RESULT_TYPE, it) }
|
||||
}
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
package xyz.self.sdk.api
|
||||
|
||||
data class SelfSdkConfig(
|
||||
val teeUrl: String,
|
||||
val verificationId: String,
|
||||
val userId: String,
|
||||
val environment: String = "prod",
|
||||
val isDebugMode: Boolean = false,
|
||||
val version: Int = 1,
|
||||
val scope: String? = null,
|
||||
val disclosures: List<String>? = null,
|
||||
val appName: String? = null,
|
||||
val appEndpoint: String? = null,
|
||||
val resultType: String? = null,
|
||||
)
|
||||
|
||||
class SelfSdkException(message: String) : Exception(message)
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
|
||||
@@ -20,6 +30,8 @@ class AndroidWebViewHost(
|
||||
private val isDebugMode: Boolean = false,
|
||||
) {
|
||||
private lateinit var webView: WebView
|
||||
var fileUploadCallback: ValueCallback<Array<Uri>>? = null
|
||||
var pendingPermissionRequest: PermissionRequest? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun createWebView(queryParams: String): WebView {
|
||||
@@ -91,6 +103,71 @@ class AndroidWebViewHost(
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onPermissionRequest(request: PermissionRequest?) {
|
||||
request ?: return
|
||||
|
||||
// Only allow permissions from trusted origins
|
||||
val origin = request.origin?.toString() ?: ""
|
||||
val isTrusted = origin.startsWith("https://appassets.androidplatform.net") ||
|
||||
origin.startsWith("https://verify.didit.me") ||
|
||||
(isDebugMode && origin.startsWith("http://127.0.0.1"))
|
||||
if (!isTrusted) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
|
||||
val activity = context as? Activity ?: run {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
|
||||
// Collect required Android permissions
|
||||
val neededPermissions = mutableListOf<String>()
|
||||
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
|
||||
neededPermissions.add(Manifest.permission.CAMERA)
|
||||
}
|
||||
if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
|
||||
neededPermissions.add(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Check if any runtime permissions are missing
|
||||
val missingPermissions = neededPermissions.filter {
|
||||
ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
pendingPermissionRequest = request
|
||||
ActivityCompat.requestPermissions(activity, missingPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST_CODE)
|
||||
return
|
||||
}
|
||||
|
||||
request.grant(request.resources)
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
webView: WebView?,
|
||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean {
|
||||
fileUploadCallback?.onReceiveValue(null)
|
||||
fileUploadCallback = filePathCallback
|
||||
|
||||
val intent = fileChooserParams?.createIntent() ?: return false
|
||||
val activity = context as? Activity ?: run {
|
||||
fileUploadCallback = null
|
||||
return false
|
||||
}
|
||||
try {
|
||||
activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
|
||||
} catch (e: Exception) {
|
||||
fileUploadCallback = null
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
|
||||
|
||||
if (isDebugMode) {
|
||||
@@ -121,4 +198,9 @@ class AndroidWebViewHost(
|
||||
router.onMessageReceived(json)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE_CHOOSER_REQUEST_CODE = 1001
|
||||
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebChromeClient
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
import xyz.self.sdk.handlers.CryptoHandler
|
||||
@@ -17,9 +20,15 @@ class SelfVerificationActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false)
|
||||
val teeUrl = intent.getStringExtra(EXTRA_TEE_URL) ?: ""
|
||||
val environment = intent.getStringExtra(EXTRA_ENVIRONMENT) ?: "prod"
|
||||
val verificationId = intent.getStringExtra(EXTRA_VERIFICATION_ID) ?: ""
|
||||
val userId = intent.getStringExtra(EXTRA_USER_ID) ?: ""
|
||||
val version = intent.getIntExtra(EXTRA_VERSION, 1)
|
||||
val scope = intent.getStringExtra(EXTRA_SCOPE)
|
||||
val disclosures = intent.getStringArrayListExtra(EXTRA_DISCLOSURES)
|
||||
val appName = intent.getStringExtra(EXTRA_APP_NAME)
|
||||
val appEndpoint = intent.getStringExtra(EXTRA_APP_ENDPOINT)
|
||||
val resultType = intent.getStringExtra(EXTRA_RESULT_TYPE)
|
||||
|
||||
router = MessageRouter(
|
||||
sendToWebView = { js ->
|
||||
@@ -34,15 +43,52 @@ class SelfVerificationActivity : AppCompatActivity() {
|
||||
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
|
||||
|
||||
val queryParams = buildString {
|
||||
append("teeUrl=").append(android.net.Uri.encode(teeUrl))
|
||||
append("&verificationId=").append(android.net.Uri.encode(verificationId))
|
||||
append("&userId=").append(android.net.Uri.encode(userId))
|
||||
append("environment=").append(Uri.encode(environment))
|
||||
append("&verificationId=").append(Uri.encode(verificationId))
|
||||
append("&userId=").append(Uri.encode(userId))
|
||||
append("&version=").append(version)
|
||||
scope?.let { append("&scope=").append(Uri.encode(it)) }
|
||||
disclosures?.takeIf { it.isNotEmpty() }?.let {
|
||||
append("&disclosures=").append(Uri.encode(it.joinToString(",")))
|
||||
}
|
||||
appName?.let { append("&appName=").append(Uri.encode(it)) }
|
||||
appEndpoint?.let { append("&appEndpoint=").append(Uri.encode(it)) }
|
||||
resultType?.let { append("&resultType=").append(Uri.encode(it)) }
|
||||
}
|
||||
|
||||
val webView = webViewHost.createWebView(queryParams)
|
||||
setContentView(webView)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == AndroidWebViewHost.CAMERA_PERMISSION_REQUEST_CODE) {
|
||||
val pending = webViewHost.pendingPermissionRequest
|
||||
if (pending != null) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
pending.grant(pending.resources)
|
||||
} else {
|
||||
pending.deny()
|
||||
}
|
||||
webViewHost.pendingPermissionRequest = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use Activity Result API")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == AndroidWebViewHost.FILE_CHOOSER_REQUEST_CODE) {
|
||||
val results = if (resultCode == RESULT_OK && data != null) {
|
||||
WebChromeClient.FileChooserParams.parseResult(resultCode, data)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
webViewHost.fileUploadCallback?.onReceiveValue(results)
|
||||
webViewHost.fileUploadCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (::webViewHost.isInitialized) {
|
||||
webViewHost.destroy()
|
||||
@@ -52,9 +98,15 @@ class SelfVerificationActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
|
||||
const val EXTRA_TEE_URL = "xyz.self.sdk.TEE_URL"
|
||||
const val EXTRA_ENVIRONMENT = "xyz.self.sdk.ENVIRONMENT"
|
||||
const val EXTRA_VERIFICATION_ID = "xyz.self.sdk.VERIFICATION_ID"
|
||||
const val EXTRA_USER_ID = "xyz.self.sdk.USER_ID"
|
||||
const val EXTRA_VERSION = "xyz.self.sdk.VERSION"
|
||||
const val EXTRA_SCOPE = "xyz.self.sdk.SCOPE"
|
||||
const val EXTRA_DISCLOSURES = "xyz.self.sdk.DISCLOSURES"
|
||||
const val EXTRA_APP_NAME = "xyz.self.sdk.APP_NAME"
|
||||
const val EXTRA_APP_ENDPOINT = "xyz.self.sdk.APP_ENDPOINT"
|
||||
const val EXTRA_RESULT_TYPE = "xyz.self.sdk.RESULT_TYPE"
|
||||
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,65 @@
|
||||
import Foundation
|
||||
|
||||
public struct SelfSdkConfig {
|
||||
public let teeUrl: String
|
||||
public let verificationId: String
|
||||
public let userId: String
|
||||
public let environment: String
|
||||
public let isDebugMode: Bool
|
||||
public let version: Int
|
||||
public let scope: String?
|
||||
public let disclosures: [String]?
|
||||
public let appName: String?
|
||||
public let appEndpoint: String?
|
||||
public let resultType: String?
|
||||
|
||||
public init(
|
||||
teeUrl: String,
|
||||
verificationId: String,
|
||||
userId: String,
|
||||
isDebugMode: Bool = false
|
||||
environment: String = "prod",
|
||||
isDebugMode: Bool = false,
|
||||
version: Int = 1,
|
||||
scope: String? = nil,
|
||||
disclosures: [String]? = nil,
|
||||
appName: String? = nil,
|
||||
appEndpoint: String? = nil,
|
||||
resultType: String? = nil
|
||||
) {
|
||||
self.teeUrl = teeUrl
|
||||
self.verificationId = verificationId
|
||||
self.userId = userId
|
||||
self.environment = environment
|
||||
self.isDebugMode = isDebugMode
|
||||
self.version = version
|
||||
self.scope = scope
|
||||
self.disclosures = disclosures
|
||||
self.appName = appName
|
||||
self.appEndpoint = appEndpoint
|
||||
self.resultType = resultType
|
||||
}
|
||||
|
||||
func toQueryParams() -> String {
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "teeUrl", value: teeUrl),
|
||||
var items = [
|
||||
URLQueryItem(name: "environment", value: environment),
|
||||
URLQueryItem(name: "verificationId", value: verificationId),
|
||||
URLQueryItem(name: "userId", value: userId)
|
||||
URLQueryItem(name: "userId", value: userId),
|
||||
URLQueryItem(name: "version", value: String(version)),
|
||||
]
|
||||
if let scope = scope {
|
||||
items.append(URLQueryItem(name: "scope", value: scope))
|
||||
}
|
||||
if let disclosures = disclosures, !disclosures.isEmpty {
|
||||
items.append(URLQueryItem(name: "disclosures", value: disclosures.joined(separator: ",")))
|
||||
}
|
||||
if let appName = appName {
|
||||
items.append(URLQueryItem(name: "appName", value: appName))
|
||||
}
|
||||
if let appEndpoint = appEndpoint {
|
||||
items.append(URLQueryItem(name: "appEndpoint", value: appEndpoint))
|
||||
}
|
||||
if let resultType = resultType {
|
||||
items.append(URLQueryItem(name: "resultType", value: resultType))
|
||||
}
|
||||
components.queryItems = items
|
||||
return components.percentEncodedQuery ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,18 @@ final class SecureStorageHandler: BridgeHandler {
|
||||
private let service = "xyz.self.sdk"
|
||||
|
||||
func handle(method: String, params: [String: Any]?) async throws -> Any? {
|
||||
let result: Any?
|
||||
|
||||
switch method {
|
||||
case "get":
|
||||
guard let key = params?["key"] as? String else {
|
||||
throw BridgeHandlerError.missingParam("key")
|
||||
}
|
||||
let value = get(key: key)
|
||||
return ["value": value as Any]
|
||||
if let value = get(key: key) {
|
||||
result = ["value": value]
|
||||
} else {
|
||||
result = ["value": NSNull()]
|
||||
}
|
||||
|
||||
case "set":
|
||||
guard let key = params?["key"] as? String else {
|
||||
@@ -26,18 +31,19 @@ final class SecureStorageHandler: BridgeHandler {
|
||||
throw BridgeHandlerError.missingParam("value")
|
||||
}
|
||||
try set(key: key, value: value)
|
||||
return nil
|
||||
result = nil
|
||||
|
||||
case "remove":
|
||||
guard let key = params?["key"] as? String else {
|
||||
throw BridgeHandlerError.missingParam("key")
|
||||
}
|
||||
remove(key: key)
|
||||
return nil
|
||||
result = nil
|
||||
|
||||
default:
|
||||
throw BridgeHandlerError.unknownMethod(method)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func get(key: String) -> String? {
|
||||
|
||||
@@ -21,6 +21,8 @@ final class SelfWebViewHost: NSObject {
|
||||
contentController.add(WeakScriptMessageProxy(handler: self), name: "SelfNativeIOS")
|
||||
config.userContentController = contentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.allowsInlineMediaPlayback = true
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.scrollView.bounces = false
|
||||
@@ -44,7 +46,7 @@ final class SelfWebViewHost: NSObject {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
} else {
|
||||
guard let bundlePath = Bundle.main.path(forResource: "self-sdk-web", ofType: nil) else {
|
||||
guard let bundlePath = Bundle.module.path(forResource: "self-sdk-web", ofType: nil) else {
|
||||
return
|
||||
}
|
||||
let fileURL = URL(fileURLWithPath: "\(bundlePath)/index.html")
|
||||
|
||||
@@ -5,10 +5,10 @@ Minimal test apps for exercising the Self SDK native shells (Android + iOS) end-
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host test app → Native shell (keychain/crypto/lifecycle) → WebView (webview-app bundle) → Sumsub KYC
|
||||
Host test app → Native shell (keychain/crypto/lifecycle) → WebView (webview-app bundle) → Didit KYC
|
||||
```
|
||||
|
||||
The test app launches the native shell, which hosts a WebView running the bundled `webview-app`. The WebView handles the full verification flow (Sumsub KYC → Self proof pipeline) and returns a terminal result to the test app via the bridge.
|
||||
The test app launches the native shell, which hosts a WebView running the bundled `webview-app`. The WebView handles the full verification flow (Didit KYC via JS SDK → Socket.IO attestation → Self proof pipeline) and returns a terminal result to the test app via the bridge.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -100,11 +100,11 @@ The test app has three config fields:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| TEE URL | `https://tee.staging.self.xyz` | Trusted execution environment endpoint |
|
||||
| TEE URL | `https://kyc.self.xyz` | Didit TEE backend endpoint for session creation and signed data delivery |
|
||||
| Verification ID | `test-verification-123` | Session correlation ID (use a real one for end-to-end testing) |
|
||||
| User ID | `test-user-456` | User correlation key |
|
||||
|
||||
For end-to-end testing with Sumsub, you need real `verificationId` and `teeUrl` values from the Self backend.
|
||||
For end-to-end testing, you need a real `teeUrl` pointing to a running didit-tee instance with valid Didit API credentials.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -124,6 +124,17 @@ For end-to-end testing with Sumsub, you need real `verificationId` and `teeUrl`
|
||||
4. On completion, the `SelfSdkCallback` protocol methods are invoked
|
||||
5. The view controller is dismissed
|
||||
|
||||
## KYC Flow
|
||||
|
||||
The WebView app uses the Didit JS SDK (`@didit-protocol/sdk-web`) for identity verification:
|
||||
|
||||
1. WebView calls `POST /session` on the TEE to create a Didit session
|
||||
2. Didit JS SDK launches in embedded mode (iframe) for document capture + liveness
|
||||
3. After SDK completes, WebView connects Socket.IO to the TEE
|
||||
4. TEE delivers signed KYC data (EdDSA signature + 295-byte applicant info)
|
||||
5. WebView emits `ack_success` to trigger session deletion
|
||||
6. Document is stored and proving machine generates the ZK proof
|
||||
|
||||
## Full Build Pipeline
|
||||
|
||||
To build everything from scratch:
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -68,10 +68,15 @@ fun TestAppScreen(
|
||||
resultText: String,
|
||||
onLaunch: (SelfSdkConfig) -> Unit
|
||||
) {
|
||||
var teeUrl by remember { mutableStateOf("https://tee.staging.self.xyz") }
|
||||
var environment by remember { mutableStateOf("staging") }
|
||||
var verificationId by remember { mutableStateOf("test-verification-123") }
|
||||
var userId by remember { mutableStateOf("test-user-456") }
|
||||
var debugMode by remember { mutableStateOf(false) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var disclosures by remember { mutableStateOf("full_name,dob") }
|
||||
var appName by remember { mutableStateOf("Self Test App") }
|
||||
var appEndpoint by remember { mutableStateOf("") }
|
||||
var resultType by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -83,9 +88,9 @@ fun TestAppScreen(
|
||||
Text("Self SDK Test", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = teeUrl,
|
||||
onValueChange = { teeUrl = it },
|
||||
label = { Text("TEE URL") },
|
||||
value = environment,
|
||||
onValueChange = { environment = it },
|
||||
label = { Text("Environment (prod / staging)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -114,14 +119,61 @@ fun TestAppScreen(
|
||||
Text("Debug mode (localhost:5173)")
|
||||
}
|
||||
|
||||
Text("Verification Config", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
OutlinedTextField(
|
||||
value = scope,
|
||||
onValueChange = { scope = it },
|
||||
label = { Text("Scope") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = disclosures,
|
||||
onValueChange = { disclosures = it },
|
||||
label = { Text("Disclosures (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = appName,
|
||||
onValueChange = { appName = it },
|
||||
label = { Text("App Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = appEndpoint,
|
||||
onValueChange = { appEndpoint = it },
|
||||
label = { Text("App Endpoint") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = resultType,
|
||||
onValueChange = { resultType = it },
|
||||
label = { Text("Result Type") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onLaunch(
|
||||
SelfSdkConfig(
|
||||
teeUrl = teeUrl,
|
||||
verificationId = verificationId,
|
||||
userId = userId,
|
||||
isDebugMode = debugMode
|
||||
environment = environment,
|
||||
isDebugMode = debugMode,
|
||||
scope = scope.ifBlank { null },
|
||||
disclosures = disclosures.ifBlank { null }?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() },
|
||||
appName = appName.ifBlank { null },
|
||||
appEndpoint = appEndpoint.ifBlank { null },
|
||||
resultType = resultType.ifBlank { null },
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=600000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,15 @@ class VerificationCallback: SelfSdkCallback {
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var teeUrl = "https://tee.staging.self.xyz"
|
||||
@State private var environment = "staging"
|
||||
@State private var verificationId = "test-verification-123"
|
||||
@State private var userId = "test-user-456"
|
||||
@State private var debugMode = false
|
||||
@State private var scope = ""
|
||||
@State private var disclosures = "full_name,dob"
|
||||
@State private var appName = "Self Test App"
|
||||
@State private var appEndpoint = ""
|
||||
@State private var resultType = ""
|
||||
@State private var resultText = "No result yet"
|
||||
@State private var showVerification = false
|
||||
|
||||
@@ -32,9 +37,9 @@ struct ContentView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Group {
|
||||
Text("TEE URL")
|
||||
Text("Environment (prod / staging)")
|
||||
.font(.caption)
|
||||
TextField("TEE URL", text: $teeUrl)
|
||||
TextField("Environment", text: $environment)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("Verification ID")
|
||||
@@ -50,6 +55,36 @@ struct ContentView: View {
|
||||
|
||||
Toggle("Debug mode (localhost:5173)", isOn: $debugMode)
|
||||
|
||||
Group {
|
||||
Text("Verification Config")
|
||||
.font(.headline)
|
||||
|
||||
Text("Scope")
|
||||
.font(.caption)
|
||||
TextField("Scope", text: $scope)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("Disclosures (comma-separated)")
|
||||
.font(.caption)
|
||||
TextField("Disclosures", text: $disclosures)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("App Name")
|
||||
.font(.caption)
|
||||
TextField("App Name", text: $appName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("App Endpoint")
|
||||
.font(.caption)
|
||||
TextField("App Endpoint", text: $appEndpoint)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Text("Result Type")
|
||||
.font(.caption)
|
||||
TextField("Result Type", text: $resultType)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Button(action: { showVerification = true }) {
|
||||
Text("Launch Verification")
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -70,10 +105,15 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $showVerification) {
|
||||
VerificationView(
|
||||
config: SelfSdkConfig(
|
||||
teeUrl: teeUrl,
|
||||
verificationId: verificationId,
|
||||
userId: userId,
|
||||
isDebugMode: debugMode
|
||||
environment: environment,
|
||||
isDebugMode: debugMode,
|
||||
scope: scope.isEmpty ? nil : scope,
|
||||
disclosures: disclosures.isEmpty ? nil : disclosures.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) },
|
||||
appName: appName.isEmpty ? nil : appName,
|
||||
appEndpoint: appEndpoint.isEmpty ? nil : appEndpoint,
|
||||
resultType: resultType.isEmpty ? nil : resultType
|
||||
),
|
||||
onResult: { result in
|
||||
resultText = result
|
||||
|
||||
@@ -19,6 +19,19 @@ targets:
|
||||
- SelfTestApp
|
||||
dependencies:
|
||||
- package: SelfNativeShell
|
||||
info:
|
||||
properties:
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
NSCameraUsageDescription: "Camera access is required for identity document capture and liveness verification."
|
||||
settings:
|
||||
SWIFT_VERSION: "5.9"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@selfxyz/euclid": "1.2.6",
|
||||
"@selfxyz/euclid-core": "1.2.6",
|
||||
"@didit-protocol/sdk-web": "^0.1.8",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@selfxyz/euclid": "1.3.0",
|
||||
"@selfxyz/euclid-core": "1.3.0",
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:^",
|
||||
"@selfxyz/webview-bridge": "workspace:^",
|
||||
"@sumsub/websdk": "^2.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"elliptic": "^6.5.4",
|
||||
"lottie-react": "^2.4.0",
|
||||
@@ -49,10 +50,14 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sort-exports": "^0.9.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0"
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"engines": {
|
||||
"node": ">=22 <23"
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/webview-app/public/logos/self.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="37" height="37" viewBox="0 0 37 37" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_16462_4504)">
|
||||
<path d="M18.5051 14.1988H18.5C16.1245 14.1988 14.1987 16.1245 14.1987 18.5V18.5051C14.1987 20.8807 16.1245 22.8064 18.5 22.8064H18.5051C20.8806 22.8064 22.8064 20.8807 22.8064 18.5051V18.5C22.8064 16.1245 20.8806 14.1988 18.5051 14.1988Z" fill="#00FFB6"/>
|
||||
<path d="M10.0619 14.5174C10.0619 11.9633 12.1329 9.89236 14.6869 9.89236H23.6183L33.5107 0H8.84917L0 8.84917V23.4076H10.0619V14.5122V14.5174Z" fill="url(#paint0_linear_16462_4504)"/>
|
||||
<path d="M26.9381 13.5564V22.1435C26.9381 24.6975 24.8671 26.7685 22.3131 26.7685H13.726L3.48932 37.0051H28.1508L37 28.156V13.5615H26.9381V13.5564Z" fill="url(#paint1_linear_16462_4504)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_16462_4504" x1="0" y1="11.7038" x2="33.5107" y2="11.7038" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E2EDF8"/>
|
||||
<stop offset="0.63" stop-color="white"/>
|
||||
<stop offset="1" stop-color="#EAF1F9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_16462_4504" x1="3.48932" y1="25.2808" x2="37" y2="25.2808" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E2EDF8"/>
|
||||
<stop offset="0.63" stop-color="white"/>
|
||||
<stop offset="1" stop-color="#EAF1F9"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_16462_4504">
|
||||
<rect width="37" height="37" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -5,6 +5,8 @@
|
||||
import type React from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { DevRouteMenu } from './components/DevRouteMenu';
|
||||
import { PasswordGate } from './components/PasswordGate';
|
||||
import { SelfClientProvider } from './providers/SelfClientProvider';
|
||||
import { VerificationRequestProvider } from './providers/VerificationRequestProvider';
|
||||
import { DevModeScreen } from './screens/account/DevModeScreen';
|
||||
@@ -14,17 +16,37 @@ import { SettingsScreen } from './screens/account/SettingsScreen';
|
||||
import { ComingSoonScreen } from './screens/ComingSoonScreen';
|
||||
import { KeychainDebugScreen } from './screens/debug/KeychainDebugScreen';
|
||||
import { HomeScreen } from './screens/home/HomeScreen';
|
||||
import { IDDataScreen } from './screens/home/IDDataScreen';
|
||||
import { ManageDocumentsScreen } from './screens/home/ManageDocumentsScreen';
|
||||
import { ConfirmIdentificationScreen } from './screens/onboarding/ConfirmIdentificationScreen';
|
||||
import { ConflictDetectedScreen } from './screens/onboarding/ConflictDetectedScreen';
|
||||
import { CountryPickerScreen } from './screens/onboarding/CountryPickerScreen';
|
||||
import { IDSelectionScreen } from './screens/onboarding/IDSelectionScreen';
|
||||
import { KycFailureScreen } from './screens/onboarding/KycFailureScreen';
|
||||
import { ProviderLaunchScreen } from './screens/onboarding/ProviderLaunchScreen';
|
||||
import { ProviderResultScreen } from './screens/onboarding/ProviderResultScreen';
|
||||
import { PushNotificationPromptScreen } from './screens/onboarding/PushNotificationPromptScreen';
|
||||
import { RegistrationFailureScreen } from './screens/onboarding/RegistrationFailureScreen';
|
||||
import { ScanSuccessScreen } from './screens/onboarding/ScanSuccessScreen';
|
||||
import { SocialSignOnMethodPickerScreen } from './screens/onboarding/SocialSignOnMethodPickerScreen';
|
||||
import { SocialSignOnPickerScreen } from './screens/onboarding/SocialSignOnPickerScreen';
|
||||
import { TourScreen } from './screens/onboarding/TourScreen';
|
||||
import { DialogueWithCtaScreen } from './screens/proving/DialogueWithCtaScreen';
|
||||
import { KycPendingScreen } from './screens/proving/KycPendingScreen';
|
||||
import { KycSuccessScreen } from './screens/proving/KycSuccessScreen';
|
||||
import { ProofGenerationDialogueScreen } from './screens/proving/ProofGenerationDialogueScreen';
|
||||
import { ProofGenerationSuccessScreen } from './screens/proving/ProofGenerationSuccessScreen';
|
||||
import { ProofHistoryScreen } from './screens/proving/ProofHistoryScreen';
|
||||
import { ProofRequestReceiptScreen } from './screens/proving/ProofRequestReceiptScreen';
|
||||
import { ProofSuccessBackupScreen } from './screens/proving/ProofSuccessBackupScreen';
|
||||
import { ProvingScreen } from './screens/proving/ProvingScreen';
|
||||
import { SimpleDialogueScreen } from './screens/proving/SimpleDialogueScreen';
|
||||
import { VerificationResultScreen } from './screens/proving/VerificationResultScreen';
|
||||
import { BackupMethodPickerScreen } from './screens/recovery/BackupMethodPickerScreen';
|
||||
import { LaunchRecoveryScreen } from './screens/recovery/LaunchRecoveryScreen';
|
||||
import { RecoveryPhraseScreen } from './screens/recovery/RecoveryPhraseScreen';
|
||||
import { RecoverySuccessScreen } from './screens/recovery/RecoverySuccessScreen';
|
||||
import { SecretPhraseInputScreen } from './screens/recovery/SecretPhraseInputScreen';
|
||||
import { KycMockScreen } from './screens/tunnel/KycMockScreen';
|
||||
import { TourScreen as TunnelTourScreen } from './screens/tunnel/TourScreen';
|
||||
import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerScreen';
|
||||
@@ -34,39 +56,62 @@ import { TunnelProvingScreen } from './screens/tunnel/TunnelProvingScreen';
|
||||
import { TunnelResultScreen } from './screens/tunnel/TunnelResultScreen';
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<BrowserRouter>
|
||||
<VerificationRequestProvider>
|
||||
<SelfClientProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeScreen />} />
|
||||
<Route path="/onboarding/tour/:step" element={<TourScreen />} />
|
||||
<Route path="/onboarding/country" element={<CountryPickerScreen />} />
|
||||
<Route path="/onboarding/id-type" element={<IDSelectionScreen />} />
|
||||
<Route path="/onboarding/provider" element={<ProviderLaunchScreen />} />
|
||||
<Route path="/onboarding/provider-result" element={<ProviderResultScreen />} />
|
||||
<Route path="/onboarding/confirm" element={<ConfirmIdentificationScreen />} />
|
||||
<Route path="/onboarding/success" element={<ScanSuccessScreen />} />
|
||||
<Route path="/onboarding/failure" element={<RegistrationFailureScreen />} />
|
||||
<Route path="/onboarding/kyc-failure" element={<KycFailureScreen />} />
|
||||
<Route path="/proving" element={<ProvingScreen />} />
|
||||
<Route path="/proving/result" element={<VerificationResultScreen />} />
|
||||
<Route path="/settings" element={<SettingsScreen />} />
|
||||
<Route path="/settings/security" element={<SecurityScreen />} />
|
||||
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
|
||||
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
|
||||
{import.meta.env.DEV && <Route path="/debug/keychain" element={<KeychainDebugScreen />} />}
|
||||
<Route path="/account/verified" element={<VerificationResultScreen />} />
|
||||
<Route path="/coming-soon" element={<ComingSoonScreen />} />
|
||||
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
|
||||
<Route path="/tunnel/kyc" element={<KycMockScreen />} />
|
||||
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />
|
||||
<Route path="/tunnel/registration/id-type" element={<TunnelIDTypeScreen />} />
|
||||
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
|
||||
<Route path="/tunnel/proof/generating" element={<TunnelProvingScreen />} />
|
||||
<Route path="/tunnel/proof/result" element={<TunnelResultScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SelfClientProvider>
|
||||
</VerificationRequestProvider>
|
||||
</BrowserRouter>
|
||||
<PasswordGate>
|
||||
<BrowserRouter>
|
||||
<VerificationRequestProvider>
|
||||
<SelfClientProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeScreen />} />
|
||||
<Route path="/onboarding/tour/:step" element={<TourScreen />} />
|
||||
<Route path="/onboarding/country" element={<CountryPickerScreen />} />
|
||||
<Route path="/onboarding/id-type" element={<IDSelectionScreen />} />
|
||||
<Route path="/onboarding/provider" element={<ProviderLaunchScreen />} />
|
||||
<Route path="/onboarding/provider-result" element={<ProviderResultScreen />} />
|
||||
<Route path="/onboarding/confirm" element={<ConfirmIdentificationScreen />} />
|
||||
<Route path="/onboarding/success" element={<ScanSuccessScreen />} />
|
||||
<Route path="/onboarding/failure" element={<RegistrationFailureScreen />} />
|
||||
<Route path="/onboarding/kyc-failure" element={<KycFailureScreen />} />
|
||||
<Route path="/proving" element={<ProvingScreen />} />
|
||||
<Route path="/proving/result" element={<VerificationResultScreen />} />
|
||||
<Route path="/settings" element={<SettingsScreen />} />
|
||||
<Route path="/settings/security" element={<SecurityScreen />} />
|
||||
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
|
||||
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
|
||||
{import.meta.env.DEV && <Route path="/debug/keychain" element={<KeychainDebugScreen />} />}
|
||||
<Route path="/settings/backup" element={<BackupMethodPickerScreen />} />
|
||||
<Route path="/settings/recovery-phrase" element={<RecoveryPhraseScreen />} />
|
||||
<Route path="/recovery" element={<LaunchRecoveryScreen />} />
|
||||
<Route path="/recovery/phrase-input" element={<SecretPhraseInputScreen />} />
|
||||
<Route path="/recovery/success" element={<RecoverySuccessScreen />} />
|
||||
<Route path="/onboarding/backup" element={<SocialSignOnMethodPickerScreen />} />
|
||||
<Route path="/onboarding/signin" element={<SocialSignOnPickerScreen />} />
|
||||
<Route path="/onboarding/conflict" element={<ConflictDetectedScreen />} />
|
||||
<Route path="/onboarding/notifications" element={<PushNotificationPromptScreen />} />
|
||||
<Route path="/proving/receipt" element={<ProofRequestReceiptScreen />} />
|
||||
<Route path="/proving/history" element={<ProofHistoryScreen />} />
|
||||
<Route path="/proving/dialogue" element={<SimpleDialogueScreen />} />
|
||||
<Route path="/proving/dialogue-cta" element={<DialogueWithCtaScreen />} />
|
||||
<Route path="/proving/generation-dialogue" element={<ProofGenerationDialogueScreen />} />
|
||||
<Route path="/proving/generation-success" element={<ProofGenerationSuccessScreen />} />
|
||||
<Route path="/proving/backup-prompt" element={<ProofSuccessBackupScreen />} />
|
||||
<Route path="/proving/kyc-pending" element={<KycPendingScreen />} />
|
||||
<Route path="/proving/kyc-success" element={<KycSuccessScreen />} />
|
||||
<Route path="/account/verified" element={<VerificationResultScreen />} />
|
||||
<Route path="/id-data" element={<IDDataScreen />} />
|
||||
<Route path="/manage-documents" element={<ManageDocumentsScreen />} />
|
||||
<Route path="/coming-soon" element={<ComingSoonScreen />} />
|
||||
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
|
||||
<Route path="/tunnel/kyc" element={<KycMockScreen />} />
|
||||
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />
|
||||
<Route path="/tunnel/registration/id-type" element={<TunnelIDTypeScreen />} />
|
||||
<Route path="/tunnel/proof/receipt" element={<TunnelProofReceiptScreen />} />
|
||||
<Route path="/tunnel/proof/generating" element={<TunnelProvingScreen />} />
|
||||
<Route path="/tunnel/proof/result" element={<TunnelResultScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{import.meta.env.DEV && <DevRouteMenu />}
|
||||
</SelfClientProvider>
|
||||
</VerificationRequestProvider>
|
||||
</BrowserRouter>
|
||||
</PasswordGate>
|
||||
);
|
||||
|
||||
205
packages/webview-app/src/components/DevRouteMenu.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DevScreenLink {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DevScreenGroup {
|
||||
title: string;
|
||||
links: DevScreenLink[];
|
||||
}
|
||||
|
||||
const screenGroups: DevScreenGroup[] = [
|
||||
{
|
||||
title: 'Home & Documents',
|
||||
links: [
|
||||
{ href: '/manage-documents', label: 'Manage Documents' },
|
||||
{ href: '/id-data', label: 'ID Data' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Onboarding',
|
||||
links: [
|
||||
{ href: '/onboarding/tour/1', label: 'Tour' },
|
||||
{ href: '/onboarding/country', label: 'Country Picker' },
|
||||
{ href: '/onboarding/confirm', label: 'Confirm ID' },
|
||||
{ href: '/onboarding/success', label: 'Scan Success' },
|
||||
{ href: '/onboarding/failure', label: 'Registration Failure' },
|
||||
{ href: '/onboarding/backup', label: 'Social Sign-On Method' },
|
||||
{ href: '/onboarding/signin', label: 'Social Sign-On' },
|
||||
{ href: '/onboarding/conflict', label: 'Conflict Detected' },
|
||||
{ href: '/onboarding/notifications', label: 'Push Notification Prompt' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Proving',
|
||||
links: [
|
||||
{ href: '/proving/receipt', label: 'Proof Receipt' },
|
||||
{ href: '/proving/history', label: 'Proof History' },
|
||||
{ href: '/proving/dialogue', label: 'Simple Dialogue' },
|
||||
{ href: '/proving/dialogue-cta', label: 'Dialogue With CTA' },
|
||||
{ href: '/proving/generation-dialogue', label: 'Generation Dialogue' },
|
||||
{ href: '/proving/generation-success', label: 'Generation Success' },
|
||||
{ href: '/proving/backup-prompt', label: 'Backup Prompt' },
|
||||
{ href: '/proving/kyc-pending', label: 'KYC Pending' },
|
||||
{ href: '/proving/kyc-success', label: 'KYC Success' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Recovery',
|
||||
links: [
|
||||
{ href: '/settings/backup', label: 'Backup Method Picker' },
|
||||
{ href: '/settings/recovery-phrase', label: 'Recovery Phrase' },
|
||||
{ href: '/recovery', label: 'Launch Recovery' },
|
||||
{ href: '/recovery/phrase-input', label: 'Secret Phrase Input' },
|
||||
{ href: '/recovery/success', label: 'Recovery Success' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
links: [
|
||||
{ href: '/settings', label: 'Settings' },
|
||||
{ href: '/settings/dev-mode', label: 'Dev Mode' },
|
||||
{ href: '/settings/security', label: 'Security' },
|
||||
{ href: '/settings/notifications', label: 'Notification Preferences' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tunnel',
|
||||
links: [
|
||||
{ href: '/tunnel/tour/1', label: 'Tour' },
|
||||
{ href: '/tunnel/kyc', label: 'KYC Mock' },
|
||||
{ href: '/tunnel/registration/country', label: 'Country Picker' },
|
||||
{ href: '/tunnel/registration/id-type', label: 'ID Type' },
|
||||
{ href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
|
||||
{ href: '/tunnel/proof/generating', label: 'Proving' },
|
||||
{ href: '/tunnel/proof/result', label: 'Result' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Debug',
|
||||
links: [{ href: '/debug/keychain', label: 'Keychain Debug' }],
|
||||
},
|
||||
];
|
||||
|
||||
const allLinks = screenGroups.flatMap(g => g.links);
|
||||
|
||||
export const DevRouteMenu: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeRef.current) {
|
||||
activeRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const currentLabel = useMemo(
|
||||
() => allLinks.find(link => link.href === location.pathname)?.label ?? 'Dev Screens',
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 10,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
width: 240,
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.95)',
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{screenGroups.map(group => (
|
||||
<div key={group.title} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '8px 2px 2px',
|
||||
}}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
{group.links.map(link => {
|
||||
const isActive = location.pathname === link.href;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={link.href}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
onClick={() => {
|
||||
navigate(link.href);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: isActive ? '1px solid #7c8aff' : '1px solid rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: isActive ? 'rgba(124, 138, 255, 0.22)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
style={{
|
||||
minWidth: 168,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
backgroundColor: '#7c8aff',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 10px 24px rgba(124, 138, 255, 0.35)',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? 'Close' : currentLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
packages/webview-app/src/components/PasswordGate.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'self-preview-auth';
|
||||
|
||||
export const PasswordGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const password = import.meta.env.VITE_WEBVIEW_APP_PREVIEW_PASSWORD;
|
||||
|
||||
const [authenticated, setAuthenticated] = useState(() => !password || sessionStorage.getItem(STORAGE_KEY) === 'true');
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value === password) {
|
||||
sessionStorage.setItem(STORAGE_KEY, 'true');
|
||||
setAuthenticated(true);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
},
|
||||
[value, password],
|
||||
);
|
||||
|
||||
if (authenticated) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
setValue(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: error ? '1px solid #ef4444' : '1px solid #d1d5db',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
backgroundColor: '#111827',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { App } from './App';
|
||||
import { BridgeProvider } from './providers/BridgeProvider';
|
||||
|
||||
import './fonts.css';
|
||||
import './recovery.css';
|
||||
import './reset.css';
|
||||
|
||||
globalThis.Buffer = Buffer;
|
||||
|
||||
12
packages/webview-app/src/recovery.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.launch-recovery-screen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.launch-recovery-screen img[src$='/backgrounds/restore.png'],
|
||||
.launch-recovery-screen img[src$='restore.png'] {
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center !important;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ComingSoonScreen as EuclidComingSoonScreen } from '@selfxyz/euclid';
|
||||
import { useSelfClient } from '../providers/SelfClientProvider';
|
||||
import { getCountryName, renderFlag } from '../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../utils/insets';
|
||||
import { shouldUseHistoryBack } from '../utils/mockOnboardingFlow';
|
||||
|
||||
export const ComingSoonScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,6 +29,11 @@ export const ComingSoonScreen: React.FC = () => {
|
||||
const onDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('coming_soon_dismissed');
|
||||
if (shouldUseHistoryBack()) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export const DevModeScreen: React.FC = () => {
|
||||
}, [haptic, analytics]);
|
||||
|
||||
const onGenerateMockDocument = useCallback(() => {
|
||||
const countryCode = nationality === 'united states of america' ? 'US' : 'DE';
|
||||
const docTypeCode = documentType === 'passport' ? 'p' : 'i';
|
||||
mockDocumentStore.addDocument(countryCode, docTypeCode);
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('dev_mode_generate_mock', {
|
||||
documentType,
|
||||
@@ -61,55 +64,32 @@ export const DevModeScreen: React.FC = () => {
|
||||
}, [navigate, haptic, analytics, documentType, nationality, ageIndex, expiryIndex, ofacCheck]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuclidDevModeScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
documentType={documentType}
|
||||
onDocumentTypePress={() => {
|
||||
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
|
||||
}}
|
||||
nationality={nationality}
|
||||
onNationalityPress={() => {
|
||||
setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america'));
|
||||
}}
|
||||
age={ageOptions[ageIndex]}
|
||||
onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))}
|
||||
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
|
||||
documentExpiresIn={expiryOptions[expiryIndex]}
|
||||
onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))}
|
||||
onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))}
|
||||
ofacCheck={ofacCheck}
|
||||
onOfacCheckChange={value => {
|
||||
haptic.trigger('selection');
|
||||
setOfacCheck(value);
|
||||
}}
|
||||
onResetAllValues={onResetAllValues}
|
||||
onGenerateMockDocument={onGenerateMockDocument}
|
||||
/>
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={() => navigate('/debug/keychain')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
padding: '10px 18px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
backgroundColor: '#7c8aff',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
Keychain Debug
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
<EuclidDevModeScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
documentType={documentType}
|
||||
onDocumentTypePress={() => {
|
||||
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
|
||||
}}
|
||||
nationality={nationality}
|
||||
onNationalityPress={() => {
|
||||
setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america'));
|
||||
}}
|
||||
age={ageOptions[ageIndex]}
|
||||
onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))}
|
||||
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
|
||||
documentExpiresIn={expiryOptions[expiryIndex]}
|
||||
onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))}
|
||||
onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))}
|
||||
ofacCheck={ofacCheck}
|
||||
onOfacCheckChange={value => {
|
||||
haptic.trigger('selection');
|
||||
setOfacCheck(value);
|
||||
}}
|
||||
onResetAllValues={onResetAllValues}
|
||||
onGenerateMockDocument={onGenerateMockDocument}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,19 +31,19 @@ export const SecurityScreen: React.FC = () => {
|
||||
const onBackupAccount = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_backup_account_pressed');
|
||||
navigate('/coming-soon');
|
||||
navigate('/settings/backup');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRevealRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_reveal_phrase_pressed');
|
||||
navigate('/coming-soon');
|
||||
navigate('/settings/recovery-phrase');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRestoreAccount = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('security_restore_account_pressed');
|
||||
navigate('/coming-soon');
|
||||
navigate('/recovery');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onDisableBackups = useCallback(() => {
|
||||
|
||||
@@ -52,20 +52,29 @@ export const SettingsScreen: React.FC = () => {
|
||||
{
|
||||
icon: DocumentDetailsIcon,
|
||||
label: 'Manage Documents',
|
||||
description: 'Recovery phrase, passport data',
|
||||
onPress: () => navigate('/coming-soon'),
|
||||
description: 'Your registered passports and IDs',
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/manage-documents');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: LockIcon,
|
||||
label: 'Security',
|
||||
description: 'Recovery phrase, passport data',
|
||||
onPress: () => navigate('/settings/security'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/security');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: NotificationIcon,
|
||||
label: 'Notifications',
|
||||
description: 'Preferences, notification types',
|
||||
onPress: () => navigate('/settings/notifications'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/notifications');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -76,13 +85,19 @@ export const SettingsScreen: React.FC = () => {
|
||||
icon: ChatStrokeIcon,
|
||||
label: 'Get support',
|
||||
description: 'Help center & support',
|
||||
onPress: () => navigate('/coming-soon'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/coming-soon');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ShareIcon,
|
||||
label: 'Share Self',
|
||||
description: 'Share Self with friends',
|
||||
onPress: () => navigate('/coming-soon'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/coming-soon');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -93,13 +108,19 @@ export const SettingsScreen: React.FC = () => {
|
||||
icon: CodeIcon,
|
||||
label: 'Dev mode',
|
||||
description: 'Manage mock IDs, simulate proofs',
|
||||
onPress: () => navigate('/settings/dev-mode'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/dev-mode');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: CodeIcon,
|
||||
label: 'Tunnel flow',
|
||||
description: 'Demo: register + disclose in one flow',
|
||||
onPress: () => navigate('/tunnel/tour/1'),
|
||||
onPress: () => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/tunnel/tour/1');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
83
packages/webview-app/src/screens/home/IDDataScreen.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
IdCardIcon,
|
||||
IDDataScreen as EuclidIDDataScreen,
|
||||
LeftArrowIcon,
|
||||
QuestionCircleStrokeIcon,
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_ID_CARD_DETAILS = {
|
||||
profileImage: '',
|
||||
type: 'ID CARD',
|
||||
code: 'SELF',
|
||||
documentNumber: '••••••1234',
|
||||
surname: 'DOE',
|
||||
givenName: 'JOHN',
|
||||
sex: 'M',
|
||||
nationality: 'UNITED STATES',
|
||||
dateOfBirth: '1990-01-15',
|
||||
placeOfBirth: 'NEW YORK',
|
||||
dateOfIssue: '2020-01-15',
|
||||
dateOfExpiry: '2030-01-15',
|
||||
};
|
||||
|
||||
const MOCK_DOCUMENT_DATA = [
|
||||
{ label: 'ID Type', value: 'Passport' },
|
||||
{ label: 'Document number', value: '18-299217823' },
|
||||
{ label: 'Surname', value: 'Doe' },
|
||||
{ label: 'Given name', value: 'John' },
|
||||
{ label: 'Nationality', value: 'United States' },
|
||||
{ label: 'Date of birth', value: '1990-01-15' },
|
||||
];
|
||||
|
||||
export const IDDataScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onManageID = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('id_data_manage_pressed');
|
||||
navigate('/manage-documents');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidIDDataScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
idCard={{
|
||||
title: 'Passport',
|
||||
subtitleLine1: 'UNITED STATES PASSPORT',
|
||||
details: MOCK_ID_CARD_DETAILS,
|
||||
mrzLine1: 'P<USA0000000000USA9001150M3001150<<<<<<<<<<<<<<',
|
||||
mrzLine2: 'DOE<<JOHN<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<0',
|
||||
}}
|
||||
identificationDetailsTitle="Identification details"
|
||||
identificationDetailsDescription="All data is stored locally on your device. Self does not collect or share any of this information without your consent."
|
||||
identificationDetailsLogo={
|
||||
<div style={{ width: 32, height: 21, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<IdCardIcon size={24} color="#2563EB" />
|
||||
</div>
|
||||
}
|
||||
documentData={MOCK_DOCUMENT_DATA}
|
||||
onClose={onClose}
|
||||
onInfo={() => analytics.trackEvent('id_data_info_pressed')}
|
||||
onManageID={onManageID}
|
||||
closeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
infoIcon={({ size, color }) => <QuestionCircleStrokeIcon size={size} color={color} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, ManageDocumentsScreen as EuclidManageDocumentsScreen, PlusIcon } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const ManageDocumentsScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const [dialogue, setDialogue] = useState<{ title: string; description: string } | undefined>();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onAddDocument = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('manage_docs_add_pressed');
|
||||
navigate('/onboarding/country');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onDocumentPress = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('manage_docs_document_pressed');
|
||||
navigate('/id-data');
|
||||
}, [haptic, analytics, navigate]);
|
||||
|
||||
const onViewIdDetails = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('manage_docs_view_details');
|
||||
setDialogue(undefined);
|
||||
navigate('/id-data');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRemoveId = useCallback(() => {
|
||||
haptic.trigger('warning');
|
||||
analytics.trackEvent('manage_docs_remove_pressed');
|
||||
setDialogue(undefined);
|
||||
}, [haptic, analytics]);
|
||||
|
||||
const onDismissDialogue = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
setDialogue(undefined);
|
||||
}, [haptic]);
|
||||
|
||||
return (
|
||||
<EuclidManageDocumentsScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
addIcon={({ size, color }) => <PlusIcon size={size} color={color} />}
|
||||
documents={[
|
||||
{
|
||||
id: 'mock-passport',
|
||||
label: 'Passport',
|
||||
description: 'Registered',
|
||||
onPress: onDocumentPress,
|
||||
},
|
||||
]}
|
||||
onBack={onBack}
|
||||
onAddDocument={onAddDocument}
|
||||
dialogue={dialogue}
|
||||
onViewIdDetails={onViewIdDetails}
|
||||
onRemoveId={onRemoveId}
|
||||
onDismissDialogue={onDismissDialogue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ConflictDetectedScreen as EuclidConflictDetectedScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { getPromptMockFromSearch, getPromptMockSearch, shouldUseHistoryBack } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const ConflictDetectedScreen: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const mock = getPromptMockFromSearch(location.search);
|
||||
|
||||
const onPrimaryAction = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('conflict_use_existing_pressed');
|
||||
navigate(`/onboarding/signin${getPromptMockSearch(mock === 'existing-account' ? mock : 'default')}`);
|
||||
}, [mock, navigate, haptic, analytics]);
|
||||
|
||||
const onSecondaryAction = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('conflict_create_new_pressed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
if (shouldUseHistoryBack()) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/onboarding/signin${getPromptMockSearch(mock === 'existing-account' ? mock : 'default')}`);
|
||||
}, [mock, navigate, haptic]);
|
||||
|
||||
return (
|
||||
<EuclidConflictDetectedScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
title="Account Conflict Detected"
|
||||
description="An existing account was found with this identity. You can use the existing account or create a new one."
|
||||
primaryActionLabel="Use existing account"
|
||||
secondaryActionLabel="Create new account"
|
||||
onPrimaryAction={onPrimaryAction}
|
||||
onSecondaryAction={onSecondaryAction}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -65,13 +65,30 @@ export const IDSelectionScreen: React.FC = () => {
|
||||
countryCode,
|
||||
});
|
||||
|
||||
navigate('/onboarding/provider', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
if (idType.id === 'kyc') {
|
||||
navigate('/onboarding/provider', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
} else {
|
||||
navigate('/coming-soon', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, analytics, haptic, countryCode],
|
||||
);
|
||||
|
||||
// const onNotListed = useCallback(() => {
|
||||
// haptic.trigger('selection');
|
||||
// analytics.trackEvent('document_type_selected', {
|
||||
// documentType: 'kyc',
|
||||
// countryCode,
|
||||
// });
|
||||
// navigate('/onboarding/provider', {
|
||||
// state: { countryCode, documentType: 'kyc' },
|
||||
// });
|
||||
// }, [navigate, analytics, haptic, countryCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
|
||||
@@ -3,68 +3,168 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button, colors, Description, spacing, Title } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
import type { MockOnboardingNavigationState } from '../../utils/mockOnboardingFlow';
|
||||
import {
|
||||
createMockProviderResult,
|
||||
getMockOutcomeFromSearch,
|
||||
getMockOutcomeSearch,
|
||||
} from '../../utils/mockOnboardingFlow';
|
||||
import type { KycProviderResult } from '../../types/kycProvider';
|
||||
import { waitForAttestation } from '../../utils/diditAttestation';
|
||||
import { createDiditSession, launchDiditWebSdk } from '../../utils/diditProvider';
|
||||
|
||||
const CONTAINER_ID = 'didit-sdk-container';
|
||||
|
||||
type Phase = 'loading' | 'active' | 'waiting' | 'error';
|
||||
|
||||
export const ProviderLaunchScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
const { verificationId } = useVerificationRequest();
|
||||
const mockOutcome = getMockOutcomeFromSearch(location.search);
|
||||
const { verificationId: ctxVerificationId } = useVerificationRequest();
|
||||
|
||||
const { countryCode, documentType } = (location.state as MockOnboardingNavigationState | null) ?? {};
|
||||
const { countryCode = '', documentType = '' } =
|
||||
(location.state as {
|
||||
countryCode?: string;
|
||||
documentType?: string;
|
||||
}) || {};
|
||||
|
||||
const verificationId = ctxVerificationId ?? `didit-${Date.now()}`;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const destroyRef = useRef<(() => void) | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_complete', {
|
||||
status: result.status,
|
||||
provider: result.provider,
|
||||
});
|
||||
|
||||
if ((result.status === 'success' || result.status === 'partial') && sessionIdRef.current) {
|
||||
setPhase('waiting');
|
||||
const attestationResult = await waitForAttestation(sessionIdRef.current);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (attestationResult.status === 'success' && attestationResult.attestation) {
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: {
|
||||
providerResult: {
|
||||
...result,
|
||||
status: 'success' as const,
|
||||
attestation: attestationResult.attestation,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: {
|
||||
providerResult: {
|
||||
...result,
|
||||
status: 'error' as const,
|
||||
error: {
|
||||
code: 'provider_missing_attestation' as const,
|
||||
message: attestationResult.error ?? 'Failed to get signed verification data',
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_error', {
|
||||
status: result.status,
|
||||
errorCode: result.error?.code,
|
||||
provider: result.provider,
|
||||
});
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
analytics.trackEvent('provider_launch_started', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const providerResult = createMockProviderResult({
|
||||
outcome: mockOutcome,
|
||||
verificationId,
|
||||
});
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
analytics.trackEvent('provider_mock_completed', {
|
||||
status: providerResult.status,
|
||||
mockOutcome,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const session = await createDiditSession(controller.signal);
|
||||
if (cancelled) return;
|
||||
|
||||
navigate(`/onboarding/provider-result${getMockOutcomeSearch(mockOutcome)}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
providerResult,
|
||||
countryCode,
|
||||
documentType,
|
||||
retryMockOutcome: mockOutcome,
|
||||
},
|
||||
});
|
||||
}, 700);
|
||||
sessionIdRef.current = session.sessionId;
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [analytics, countryCode, documentType, mockOutcome, navigate, verificationId]);
|
||||
const destroy = await launchDiditWebSdk({
|
||||
url: session.url,
|
||||
containerId: CONTAINER_ID,
|
||||
verificationId,
|
||||
onComplete: handleComplete,
|
||||
onError: handleError,
|
||||
onEvent: (type: string, payload: unknown) => {
|
||||
analytics.trackEvent('provider_message', {
|
||||
messageType: type,
|
||||
hasPayload: payload != null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
destroyRef.current = destroy;
|
||||
setPhase('active');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : 'Failed to launch provider';
|
||||
analytics.trackEvent('provider_launch_failed', { error: message });
|
||||
setPhase('error');
|
||||
setErrorMessage(message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
controller.abort();
|
||||
destroyRef.current?.();
|
||||
destroyRef.current = null;
|
||||
};
|
||||
}, [analytics, countryCode, documentType, handleComplete, handleError, verificationId, retryCount]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_back_pressed', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
if (window.history.length > 1) {
|
||||
@@ -72,7 +172,59 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
} else {
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, mockOutcome, navigate]);
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_retry_pressed');
|
||||
setPhase('loading');
|
||||
setErrorMessage('');
|
||||
setRetryCount(c => c + 1);
|
||||
}, [haptic, analytics]);
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.slate50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 24,
|
||||
padding: spacing.xl,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.md,
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Title textAlign="center">Unable to launch verification</Title>
|
||||
<Description>{errorMessage}</Description>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary-label" text="Try Again" fullWidth onPress={handleRetry} />
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,32 +235,65 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
backgroundColor: colors.white,
|
||||
}}
|
||||
>
|
||||
<MockRegistrationFailureButton />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
gap: spacing.md,
|
||||
}}
|
||||
>
|
||||
{(phase === 'loading' || phase === 'waiting') && (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Title textAlign="center">Launching verification</Title>
|
||||
<Description textAlign="center">Preparing the mocked provider handoff for your registration flow.</Description>
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: spacing.md }}>
|
||||
<Title textAlign="center">
|
||||
{phase === 'waiting' ? 'Processing verification...' : 'Loading verification...'}
|
||||
</Title>
|
||||
{phase === 'waiting' && (
|
||||
<Description style={{ marginTop: 8 }}>
|
||||
Your documents are being verified. This may take a moment.
|
||||
</Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.shadow-card {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
iframe[class*="in-iframe"] {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
div[class*="size-full"] {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
id={CONTAINER_ID}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: phase === 'active' ? 'block' : 'none',
|
||||
width: '100%',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PushNotificationPromptScreen as EuclidPushNotificationPromptScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { getPromptMockFromSearch, getPromptMockSearch, shouldUseHistoryBack } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const PushNotificationPromptScreen: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const mock = getPromptMockFromSearch(location.search);
|
||||
|
||||
const onEnableNotifications = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('push_notification_enabled', { mock });
|
||||
navigate('/');
|
||||
}, [mock, navigate, haptic, analytics]);
|
||||
|
||||
const onDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('push_notification_dismissed', { mock });
|
||||
navigate('/');
|
||||
}, [mock, navigate, haptic, analytics]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('push_notification_header_back', { mock });
|
||||
|
||||
if (shouldUseHistoryBack()) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/onboarding/backup${getPromptMockSearch(mock)}`);
|
||||
}, [mock, navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidPushNotificationPromptScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onEnableNotifications={onEnableNotifications}
|
||||
onDismiss={onDismiss}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { MockRegistrationFailureButton } from '../../components/MockRegistration
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { mockDocumentStore } from '../../utils/mockDocumentStore';
|
||||
import { getPromptMockSearch } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const ScanSuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,10 +29,12 @@ export const ScanSuccessScreen: React.FC = () => {
|
||||
}
|
||||
}, [countryCode, documentType]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
const advanceToBackupPrompt = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('registration_success_finished');
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
navigate(`/onboarding/backup${getPromptMockSearch()}`, {
|
||||
state: { skipOnboardingRedirect: true },
|
||||
});
|
||||
}, [analytics, haptic, navigate]);
|
||||
|
||||
return (
|
||||
@@ -43,8 +46,8 @@ export const ScanSuccessScreen: React.FC = () => {
|
||||
totalSteps={4}
|
||||
currentStep={4}
|
||||
title="Your ID is now registered"
|
||||
onClose={goHome}
|
||||
onFinish={goHome}
|
||||
onClose={advanceToBackupPrompt}
|
||||
onFinish={advanceToBackupPrompt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { SocialSignOnMethodPickerScreen as EuclidSocialSignOnMethodPickerScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { getPromptMockFromSearch, getPromptMockSearch } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const SocialSignOnMethodPickerScreen: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const mock = getPromptMockFromSearch(location.search);
|
||||
|
||||
const onApple = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_apple_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onGoogle = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_google_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onSeedPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_seed_phrase_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(`/onboarding/notifications${getPromptMockSearch(mock)}`);
|
||||
}, [mock, navigate, haptic]);
|
||||
|
||||
return (
|
||||
<EuclidSocialSignOnMethodPickerScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onApple={onApple}
|
||||
onGoogle={onGoogle}
|
||||
onSeedPhrase={onSeedPhrase}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { SocialSignOnPickerScreen as EuclidSocialSignOnPickerScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { getPromptMockFromSearch, getPromptMockSearch } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const SocialSignOnPickerScreen: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const mock = getPromptMockFromSearch(location.search);
|
||||
|
||||
const onApple = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_picker_apple');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onGoogle = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_picker_google');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onICloud = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_picker_icloud');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onGoogleCloud = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_picker_google_cloud');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onSeedPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('social_sign_on_picker_seed_phrase');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
// TODO(WV-12): Replace this placeholder dismiss route when the real conflict/sign-in branch behavior is defined.
|
||||
navigate(`/onboarding/conflict${getPromptMockSearch(mock === 'existing-account' ? mock : 'default')}`);
|
||||
}, [mock, navigate, haptic]);
|
||||
|
||||
return (
|
||||
<EuclidSocialSignOnPickerScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onApple={onApple}
|
||||
onGoogle={onGoogle}
|
||||
onICloud={onICloud}
|
||||
onGoogleCloud={onGoogleCloud}
|
||||
onSeedPhrase={onSeedPhrase}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { DialogueWithCtaScreen as EuclidDialogueWithCtaScreen, HeartFillIcon } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const DialogueWithCtaScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onPrimaryPress = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('dialogue_cta_primary_pressed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onSecondaryPress = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('dialogue_cta_secondary_pressed');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidDialogueWithCtaScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
showTopNavigation
|
||||
onClose={onClose}
|
||||
backgroundImage="/backgrounds/dialogue-background.jpg"
|
||||
headerText="This is placeholder header text"
|
||||
descriptionText="When friends install Self and use your referral link you'll both receive exclusive points. Learn more"
|
||||
primaryButtonText="Begin liveness check"
|
||||
primaryButtonIcon={({ size }) => <HeartFillIcon size={size} color="#E53935" />}
|
||||
secondaryButtonText="Skip for now"
|
||||
helperContent={
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'DIN OT, DIN, sans-serif',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
What is a liveness check?
|
||||
</span>
|
||||
}
|
||||
showHelperContent
|
||||
onPrimaryButtonPress={onPrimaryPress}
|
||||
onSecondaryButtonPress={onSecondaryPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { KycPendingScreen as EuclidKycPendingScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const KycPendingScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onCheckBackLater = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('kyc_pending_check_back_later');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onReceiveLiveUpdates = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('kyc_pending_live_updates');
|
||||
navigate('/settings/notifications');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidKycPendingScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onCheckBackLater={onCheckBackLater}
|
||||
onReceiveLiveUpdates={onReceiveLiveUpdates}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { KycVerificationSuccessScreen as EuclidKycVerificationSuccessScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const KycSuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onGenerateProof = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('kyc_verification_success_generate_proof');
|
||||
navigate('/proving');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return <EuclidKycVerificationSuccessScreen insets={WEB_SAFE_AREA.insets} onGenerateProof={onGenerateProof} />;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
import { ProofGenerationScreen as EuclidProofGenerationScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const ProofGenerationDialogueScreen: React.FC = () => {
|
||||
return (
|
||||
<EuclidProofGenerationScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
step="readingRegistry"
|
||||
idCardProps={{ variant: 'unverified', cardMoire: 'moire' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ProofGenerationSuccessScreen as EuclidProofGenerationSuccessScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const ProofGenerationSuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onShieldIdentity = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('proof_generation_success_shield_pressed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return <EuclidProofGenerationSuccessScreen insets={WEB_SAFE_AREA.insets} onShieldIdentity={onShieldIdentity} />;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
LeftArrowIcon,
|
||||
ProofHistoryScreen as EuclidProofHistoryScreen,
|
||||
SelfLogo,
|
||||
ShieldLockIcon,
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_PROOF_HISTORY = [
|
||||
{
|
||||
id: '1',
|
||||
appName: 'Aave',
|
||||
timestamp: 'Yesterday',
|
||||
icon: <SelfLogo size={32} />,
|
||||
onPress: () => {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
appName: 'Binance',
|
||||
timestamp: '2 days ago',
|
||||
icon: <SelfLogo size={32} />,
|
||||
onPress: () => {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
appName: 'Coinbase',
|
||||
timestamp: 'Last week',
|
||||
icon: <SelfLogo size={32} />,
|
||||
onPress: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
export const ProofHistoryScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onInfoPress = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('proof_history_info_pressed');
|
||||
}, [haptic, analytics]);
|
||||
|
||||
const onViewIdData = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('proof_history_view_id_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidProofHistoryScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onClose={onClose}
|
||||
onInfoPress={onInfoPress}
|
||||
onViewIdData={onViewIdData}
|
||||
proofHistory={MOCK_PROOF_HISTORY}
|
||||
idCard={{
|
||||
variant: 'passport',
|
||||
title: 'Passport',
|
||||
subtitle: 'Registered',
|
||||
}}
|
||||
closeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
infoIcon={({ size, color }) => <ShieldLockIcon size={size} color={color} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ProofRequestReceiptScreen as EuclidProofRequestReceiptScreen, SelfLogo } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ label: 'Full Name' },
|
||||
{ label: 'Date of Birth' },
|
||||
{ label: 'Nationality' },
|
||||
{ label: 'Age above 18' },
|
||||
];
|
||||
const MOCK_WALLET_ADDRESS = '0x15a2...2P72';
|
||||
|
||||
export const ProofRequestReceiptScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const {
|
||||
appName = 'Self App',
|
||||
appEndpoint = 'self.xyz',
|
||||
documentType = 'passport',
|
||||
} = (location.state as {
|
||||
appName?: string;
|
||||
appEndpoint?: string;
|
||||
documentType?: string;
|
||||
}) || {};
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('proof_receipt_closed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidProofRequestReceiptScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
onClose={onClose}
|
||||
appIcon={<SelfLogo size={40} />}
|
||||
appName={appName}
|
||||
appEndpoint={appEndpoint}
|
||||
documentType={documentType}
|
||||
timestamp={Date.now()}
|
||||
// Placeholder-only mock data until this preview route is wired to real proof receipt state.
|
||||
walletAddress={MOCK_WALLET_ADDRESS}
|
||||
isCloudBackupEnabled={false}
|
||||
items={MOCK_ITEMS}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ProofSuccessBackupScreen as EuclidProofSuccessBackupScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const ProofSuccessBackupScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onRemindLater = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('proof_success_backup_remind_later');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onBackupAccount = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('proof_success_backup_pressed');
|
||||
navigate('/settings/security');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidProofSuccessBackupScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
idCard={{
|
||||
variant: 'passport',
|
||||
walletAddress: '0xd9..b94',
|
||||
footerTitle: 'US Passport',
|
||||
securityLevel: 'hi',
|
||||
}}
|
||||
onRemindLater={onRemindLater}
|
||||
onBackupAccount={onBackupAccount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, SimpleDialogueScreen as EuclidSimpleDialogueScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const SimpleDialogueScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
return (
|
||||
<EuclidSimpleDialogueScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
showTopNavigation
|
||||
onClose={onClose}
|
||||
closeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
backgroundImage="/backgrounds/dialogue-background-simple.jpg"
|
||||
headerText="Information"
|
||||
descriptionText="This is a simple dialogue screen used for displaying informational messages to the user."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
BackupMethodPickerScreen as EuclidBackupMethodPickerScreen,
|
||||
CloudKeyIcon,
|
||||
LeftArrowIcon,
|
||||
LockIcon,
|
||||
ZapShieldIcon,
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const BackupMethodPickerScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/security');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onICloudBackup = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('backup_method_icloud_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('backup_method_phrase_pressed');
|
||||
navigate('/settings/recovery-phrase');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidBackupMethodPickerScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
title="Back up your account"
|
||||
description="Choose how you'd like to secure your identity data. You can always change this later."
|
||||
subtitle="Backup"
|
||||
iconContainer={<CloudKeyIcon size={48} color="#000" />}
|
||||
options={[
|
||||
{
|
||||
id: 'icloud',
|
||||
label: 'iCloud Backup',
|
||||
icon: <CloudKeyIcon size={24} color="#000" />,
|
||||
onPress: onICloudBackup,
|
||||
},
|
||||
{
|
||||
id: 'recovery-phrase',
|
||||
label: 'Recovery Phrase',
|
||||
icon: <LockIcon size={24} color="#000" />,
|
||||
onPress: onRecoveryPhrase,
|
||||
},
|
||||
{
|
||||
id: 'turnkey',
|
||||
label: 'Turnkey Backup',
|
||||
icon: <ZapShieldIcon size={24} color="#000" />,
|
||||
onPress: () => navigate('/coming-soon'),
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
closeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LaunchRecoveryScreen as EuclidLaunchRecoveryScreen, LeftArrowIcon } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const LaunchRecoveryScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/security');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onEnterRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('recovery_enter_phrase_pressed');
|
||||
navigate('/recovery/phrase-input');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<div className="launch-recovery-screen">
|
||||
<EuclidLaunchRecoveryScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onClose={onClose}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
onEnterRecoveryPhrase={onEnterRecoveryPhrase}
|
||||
backgroundImage="/backgrounds/restore.png"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { RecoveryPhraseVariant } from '@selfxyz/euclid';
|
||||
import { RecoveryPhraseScreen as EuclidRecoveryPhraseScreen } from '@selfxyz/euclid';
|
||||
import { bridgeStorageAdapter } from '@selfxyz/webview-bridge/adapters';
|
||||
|
||||
import { useBridge } from '../../providers/BridgeProvider';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MNEMONIC_KEY = 'secret';
|
||||
|
||||
function parseMnemonicWords(raw: string | null): string[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as string | { phrase?: string };
|
||||
const phrase = typeof parsed === 'string' ? parsed : parsed.phrase;
|
||||
const words = phrase?.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
return words && words.length > 0 ? words : undefined;
|
||||
}
|
||||
|
||||
export const RecoveryPhraseScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const bridge = useBridge();
|
||||
const storage = useRef(bridgeStorageAdapter(bridge)).current;
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const [variant, setVariant] = useState<RecoveryPhraseVariant>('hidden');
|
||||
const [words, setWords] = useState<string[] | undefined>();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onReveal = useCallback(async () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('recovery_phrase_revealed');
|
||||
|
||||
let resolvedWords: string[] | undefined;
|
||||
|
||||
try {
|
||||
resolvedWords = parseMnemonicWords(await storage.get(MNEMONIC_KEY));
|
||||
} catch {
|
||||
// Storage or parsing failed — words stay undefined, Euclid shows placeholders.
|
||||
}
|
||||
|
||||
setWords(resolvedWords);
|
||||
setVariant('revealed');
|
||||
}, [haptic, analytics, storage]);
|
||||
|
||||
const onCopy = useCallback(async () => {
|
||||
analytics.trackEvent('recovery_phrase_copied');
|
||||
|
||||
if (!words?.length || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(words.join(' '));
|
||||
haptic.trigger('success');
|
||||
setVariant('copied');
|
||||
} catch {
|
||||
haptic.trigger('error');
|
||||
}
|
||||
}, [haptic, analytics, words]);
|
||||
|
||||
return (
|
||||
<EuclidRecoveryPhraseScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
words={words}
|
||||
variant={variant}
|
||||
onBack={onBack}
|
||||
onReveal={onReveal}
|
||||
onCopy={onCopy}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, RecoverySuccessScreen as EuclidRecoverySuccessScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const RecoverySuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('recovery_success_continue_pressed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidRecoverySuccessScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
logo={<img src="/logos/self.svg" alt="" width={64} height={64} aria-hidden="true" />}
|
||||
onClose={onClose}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, SecretPhraseInputScreen as EuclidSecretPhraseInputScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
import { validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist as bip39EnglishWordlist } from '@scure/bip39/wordlists/english';
|
||||
|
||||
const VALID_WORDS = new Set(bip39EnglishWordlist);
|
||||
const VALID_LENGTHS = new Set([12, 15, 18, 21, 24]);
|
||||
|
||||
export const SecretPhraseInputScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(words: string[]) => {
|
||||
if (!VALID_LENGTHS.has(words.length) || !validateMnemonic(words.join(' '), bip39EnglishWordlist)) {
|
||||
haptic.trigger('error');
|
||||
analytics.trackEvent('recovery_phrase_rejected', { wordCount: words.length });
|
||||
return;
|
||||
}
|
||||
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('recovery_phrase_submitted', { wordCount: words.length });
|
||||
navigate('/recovery/success');
|
||||
},
|
||||
[navigate, haptic, analytics],
|
||||
);
|
||||
|
||||
return (
|
||||
<EuclidSecretPhraseInputScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
validWords={VALID_WORDS}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
declare module '@sumsub/websdk' {
|
||||
interface SnsWebSdkConf {
|
||||
lang?: string;
|
||||
theme?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
i18n?: Record<string, Record<string, string>>;
|
||||
uiConf?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SnsWebSdkOptions {
|
||||
addViewportTag?: boolean;
|
||||
adaptIframeHeight?: boolean;
|
||||
}
|
||||
|
||||
interface SnsWebSdkBuilder {
|
||||
withConf(conf: SnsWebSdkConf): SnsWebSdkBuilder;
|
||||
withOptions(options: SnsWebSdkOptions): SnsWebSdkBuilder;
|
||||
on(event: string, handler: (payload: any) => void): SnsWebSdkBuilder;
|
||||
onMessage(handler: (type: string, payload: unknown) => void): SnsWebSdkBuilder;
|
||||
build(): SnsWebSdkInstance;
|
||||
}
|
||||
|
||||
interface SnsWebSdkInstance {
|
||||
launch(container: HTMLElement): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface SnsWebSdk {
|
||||
init(accessToken: string, tokenRefreshCallback: () => Promise<string>): SnsWebSdkBuilder;
|
||||
}
|
||||
|
||||
const snsWebSdk: SnsWebSdk;
|
||||
export default snsWebSdk;
|
||||
}
|
||||
80
packages/webview-app/src/utils/diditAttestation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import type { KycProviderAttestation } from '../types/kycProvider';
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
const ATTESTATION_TIMEOUT_MS = 120_000; // 2 minutes
|
||||
|
||||
export interface AttestationResult {
|
||||
status: 'success' | 'failed' | 'timeout';
|
||||
attestation?: KycProviderAttestation;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Socket.IO on the TEE and wait for the signed KYC attestation.
|
||||
* Returns the attestation (signature + applicantInfo + pubkey) or an error.
|
||||
*
|
||||
* After receiving data, emits `ack_success` to trigger session deletion on the TEE.
|
||||
*/
|
||||
export function waitForAttestation(sessionId: string, signal?: AbortSignal): Promise<AttestationResult> {
|
||||
return new Promise(resolve => {
|
||||
const socket = io(DIDIT_TEE_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.disconnect();
|
||||
resolve({ status: 'timeout', error: 'Timed out waiting for verification result' });
|
||||
}, ATTESTATION_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: 'Aborted' });
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('subscribe', sessionId);
|
||||
});
|
||||
|
||||
socket.on('success', (data: { signature: string; applicantInfo: string; pubkey: [string, string] }) => {
|
||||
socket.emit('ack_success', sessionId);
|
||||
cleanup();
|
||||
resolve({
|
||||
status: 'success',
|
||||
attestation: {
|
||||
serializedApplicantInfo: data.applicantInfo,
|
||||
signature: data.signature,
|
||||
pubkey: data.pubkey,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('verification_failed', (reason: string) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: reason });
|
||||
});
|
||||
|
||||
socket.on('error', (err: string) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: err });
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: `Connection failed: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
149
packages/webview-app/src/utils/diditProvider.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
export interface DiditLaunchConfig {
|
||||
url: string;
|
||||
containerId: string;
|
||||
verificationId: string;
|
||||
onComplete: (result: KycProviderResult) => void;
|
||||
onError: (result: KycProviderResult) => void;
|
||||
onEvent?: (event: string, payload: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DiditSession {
|
||||
sessionId: string;
|
||||
sessionToken: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildProviderResult(verificationId: string, overrides: Partial<KycProviderResult>): KycProviderResult {
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId,
|
||||
provider: 'didit',
|
||||
completedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiditSession(signal?: AbortSignal): Promise<DiditSession> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${DIDIT_TEE_URL}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Didit session (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as DiditSession;
|
||||
}
|
||||
return body as DiditSession;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new Error(`Didit session request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
throw new Error(`Failed to create Didit session: ${err.message}`);
|
||||
}
|
||||
throw new Error('Failed to create Didit session: Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchDiditWebSdk(config: DiditLaunchConfig): Promise<() => void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { DiditSdk } = (await import('@didit-protocol/sdk-web')) as any;
|
||||
|
||||
let hasCompleted = false;
|
||||
|
||||
const emitOnce = (result: KycProviderResult, isError: boolean) => {
|
||||
if (hasCompleted) return;
|
||||
hasCompleted = true;
|
||||
if (isError) {
|
||||
config.onError(result);
|
||||
} else {
|
||||
config.onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
DiditSdk.shared.onComplete = (sdkResult: {
|
||||
type: 'completed' | 'cancelled' | 'failed';
|
||||
session?: { status: string; sessionId: string };
|
||||
error?: { type: string; message: string };
|
||||
}) => {
|
||||
if (sdkResult.type === 'completed') {
|
||||
const status = sdkResult.session?.status;
|
||||
if (status === 'Declined') {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
providerSessionId: sdkResult.session?.sessionId,
|
||||
error: {
|
||||
code: 'provider_rejected',
|
||||
message: 'Verification was declined by the provider',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: status === 'Approved' ? 'success' : 'partial',
|
||||
providerSessionId: sdkResult.session?.sessionId,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else if (sdkResult.type === 'cancelled') {
|
||||
emitOnce(buildProviderResult(config.verificationId, { status: 'cancel' }), false);
|
||||
} else if (sdkResult.type === 'failed') {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_unknown_error',
|
||||
message: sdkResult.error?.message ?? 'Verification failed',
|
||||
retryable: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DiditSdk.shared.onEvent = (event: { type?: string }) => {
|
||||
config.onEvent?.(event.type ?? 'unknown', event);
|
||||
};
|
||||
|
||||
DiditSdk.shared.startVerification({
|
||||
url: config.url,
|
||||
configuration: {
|
||||
loggingEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
DiditSdk.shared.close();
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export interface MockOnboardingNavigationState {
|
||||
}
|
||||
|
||||
export type MockRegistrationOutcome = 'success' | 'kyc-failure' | 'registration-failure' | 'cancel';
|
||||
export type PromptMockState = 'default' | 'existing-account';
|
||||
|
||||
const DEFAULT_OUTCOME: MockRegistrationOutcome = 'success';
|
||||
const DEFAULT_PROMPT_MOCK: PromptMockState = 'default';
|
||||
const MOCKS_ENABLED = import.meta.env.DEV;
|
||||
|
||||
export const createMockProviderResult = ({
|
||||
@@ -93,5 +95,26 @@ export const getMockOutcomeFromSearch = (search: string): MockRegistrationOutcom
|
||||
export const getMockOutcomeSearch = (outcome: MockRegistrationOutcome = DEFAULT_OUTCOME): string =>
|
||||
MOCKS_ENABLED ? `?mock=${outcome}` : '';
|
||||
|
||||
export const getPromptMockFromSearch = (search: string): PromptMockState => {
|
||||
if (!MOCKS_ENABLED) {
|
||||
return DEFAULT_PROMPT_MOCK;
|
||||
}
|
||||
|
||||
const value = new URLSearchParams(search).get('mock');
|
||||
|
||||
switch (value) {
|
||||
case 'default':
|
||||
case 'existing-account':
|
||||
return value;
|
||||
default:
|
||||
return DEFAULT_PROMPT_MOCK;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPromptMockSearch = (mock: PromptMockState = DEFAULT_PROMPT_MOCK): string =>
|
||||
MOCKS_ENABLED ? `?mock=${mock}` : '';
|
||||
|
||||
export const getProviderPath = (outcome: MockRegistrationOutcome): string =>
|
||||
`/onboarding/provider${getMockOutcomeSearch(outcome)}`;
|
||||
|
||||
export const shouldUseHistoryBack = (): boolean => window.history.length > 1;
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const SUMSUB_TEE_URL = import.meta.env.VITE_SUMSUB_TEE_URL ?? 'https://sumsub-tee.self.xyz';
|
||||
|
||||
export interface SumsubAccessToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SumsubLaunchConfig {
|
||||
accessToken: string;
|
||||
containerId: string;
|
||||
verificationId: string;
|
||||
locale?: string;
|
||||
onComplete: (result: KycProviderResult) => void;
|
||||
onError: (result: KycProviderResult) => void;
|
||||
onMessage?: (type: SumsubMessageType, payload: unknown) => void;
|
||||
}
|
||||
|
||||
type SumsubMessageType =
|
||||
| 'idCheck.onReady'
|
||||
| 'idCheck.onInitialized'
|
||||
| 'idCheck.applicantStatus'
|
||||
| 'idCheck.onApplicantLoaded'
|
||||
| 'idCheck.onApplicantResubmitted'
|
||||
| 'idCheck.onApplicantSubmitted'
|
||||
| 'idCheck.onActionSubmitted'
|
||||
| 'idCheck.applicantReviewComplete'
|
||||
| 'idCheck.moduleResultPresented'
|
||||
| 'idCheck.onError'
|
||||
| 'idCheck.onStepCompleted'
|
||||
| 'idCheck.onStepInitiated'
|
||||
| string;
|
||||
|
||||
interface SumsubMessage {
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SumsubApplicantStatus {
|
||||
reviewStatus?: string;
|
||||
reviewResult?: {
|
||||
reviewAnswer?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSumsubAccessToken(signal?: AbortSignal): Promise<SumsubAccessToken> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SUMSUB_TEE_URL}/access-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Sumsub access token (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as SumsubAccessToken;
|
||||
}
|
||||
return body as SumsubAccessToken;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new Error(`Sumsub access token request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
|
||||
}
|
||||
throw new Error('Failed to get Sumsub access token: Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderResult(verificationId: string, overrides: Partial<KycProviderResult>): KycProviderResult {
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId,
|
||||
provider: 'sumsub',
|
||||
completedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function launchSumsubWebSdk(config: SumsubLaunchConfig): Promise<() => void> {
|
||||
const { default: snsWebSdk } = await import('@sumsub/websdk');
|
||||
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (!container) {
|
||||
throw new Error(`Container element #${config.containerId} not found`);
|
||||
}
|
||||
|
||||
let hasCompleted = false;
|
||||
|
||||
const emitOnce = (result: KycProviderResult, isError: boolean) => {
|
||||
if (hasCompleted) return;
|
||||
hasCompleted = true;
|
||||
if (isError) {
|
||||
config.onError(result);
|
||||
} else {
|
||||
config.onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
const snsWebSdkInstance = snsWebSdk
|
||||
.init(config.accessToken, () => fetchSumsubAccessToken().then(t => t.token))
|
||||
.withConf({ lang: config.locale ?? 'en' })
|
||||
.withOptions({ addViewportTag: false, adaptIframeHeight: true })
|
||||
.on('idCheck.onReady', () => {
|
||||
config.onMessage?.('idCheck.onReady', {});
|
||||
})
|
||||
.on('idCheck.onError', (error: unknown) => {
|
||||
config.onMessage?.('idCheck.onError', error);
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Provider error';
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_unknown_error',
|
||||
message,
|
||||
retryable: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
})
|
||||
.on('idCheck.applicantStatus', (status: SumsubApplicantStatus) => {
|
||||
config.onMessage?.('idCheck.applicantStatus', status);
|
||||
})
|
||||
.on('idCheck.onApplicantSubmitted', () => {
|
||||
config.onMessage?.('idCheck.onApplicantSubmitted', {});
|
||||
emitOnce(buildProviderResult(config.verificationId, { status: 'partial' }), false);
|
||||
})
|
||||
.on('idCheck.applicantReviewComplete', (status: SumsubApplicantStatus) => {
|
||||
config.onMessage?.('idCheck.applicantReviewComplete', status);
|
||||
const result = normalizeSumsubStatus(config.verificationId, status);
|
||||
const isError = result.status === 'error';
|
||||
emitOnce(result, isError);
|
||||
})
|
||||
.on('idCheck.moduleResultPresented', (payload: SumsubMessage) => {
|
||||
config.onMessage?.('idCheck.moduleResultPresented', payload);
|
||||
})
|
||||
.onMessage((type: SumsubMessageType, payload: unknown) => {
|
||||
config.onMessage?.(type, payload);
|
||||
})
|
||||
.build();
|
||||
|
||||
snsWebSdkInstance.launch(container);
|
||||
|
||||
return () => {
|
||||
snsWebSdkInstance.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSumsubStatus(
|
||||
verificationId: string,
|
||||
applicantStatus: SumsubApplicantStatus | undefined,
|
||||
): KycProviderResult {
|
||||
const reviewAnswer = applicantStatus?.reviewResult?.reviewAnswer;
|
||||
const reviewStatus = applicantStatus?.reviewStatus;
|
||||
|
||||
if (reviewAnswer === 'GREEN') {
|
||||
return buildProviderResult(verificationId, { status: 'success' });
|
||||
}
|
||||
|
||||
if (reviewAnswer === 'RED') {
|
||||
return buildProviderResult(verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_rejected',
|
||||
message: 'Verification was rejected by the provider',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (reviewStatus === 'pending' || reviewStatus === 'onHold' || reviewStatus === 'queued') {
|
||||
return buildProviderResult(verificationId, { status: 'partial' });
|
||||
}
|
||||
|
||||
return buildProviderResult(verificationId, { status: 'partial' });
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getPromptMockFromSearch, getPromptMockSearch } from './mockOnboardingFlow';
|
||||
import { parseBrowserHostTargetOrigin, parseVerificationRequestContext } from './verificationRequest';
|
||||
|
||||
describe('verificationRequest utils', () => {
|
||||
@@ -57,9 +58,31 @@ describe('verificationRequest utils', () => {
|
||||
timestamp: 123456789,
|
||||
requestType: 'documentOwnershipConfirmed',
|
||||
verificationId: 'verif-1',
|
||||
environment: 'prod',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse environment and version from query params', () => {
|
||||
const staging = parseVerificationRequestContext('?environment=staging&version=2');
|
||||
expect(staging.environment).toBe('stg');
|
||||
expect(staging.version).toBe(2);
|
||||
|
||||
const stg = parseVerificationRequestContext('?environment=stg');
|
||||
expect(stg.environment).toBe('stg');
|
||||
|
||||
const prod = parseVerificationRequestContext('?environment=prod');
|
||||
expect(prod.environment).toBe('prod');
|
||||
|
||||
const invalid = parseVerificationRequestContext('?environment=unknown&version=abc');
|
||||
expect(invalid.environment).toBe('prod');
|
||||
expect(invalid.version).toBe(1);
|
||||
|
||||
const missing = parseVerificationRequestContext('');
|
||||
expect(missing.environment).toBe('prod');
|
||||
expect(missing.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should fall back when request type or endpoint are invalid', () => {
|
||||
const context = parseVerificationRequestContext('?appEndpoint=http://evil.example/path&resultType=unexpected');
|
||||
|
||||
@@ -68,4 +91,16 @@ describe('verificationRequest utils', () => {
|
||||
expect(context.verificationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt mock utils', () => {
|
||||
it('should parse supported prompt mock states', () => {
|
||||
expect(getPromptMockFromSearch('?mock=default')).toBe('default');
|
||||
expect(getPromptMockFromSearch('?mock=existing-account')).toBe('existing-account');
|
||||
});
|
||||
|
||||
it('should fall back to the default prompt mock state', () => {
|
||||
expect(getPromptMockFromSearch('?mock=unexpected')).toBe('default');
|
||||
expect(getPromptMockSearch()).toBe('?mock=default');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ParsedVerificationRequestContext {
|
||||
timestamp: number;
|
||||
requestType: string;
|
||||
verificationId?: string;
|
||||
environment: 'prod' | 'stg';
|
||||
version: number;
|
||||
}
|
||||
|
||||
const ALLOWED_REQUEST_TYPES = new Set(['proofRequested', 'documentOwnershipConfirmed']);
|
||||
@@ -37,6 +39,13 @@ export function parseVerificationRequestContext(search: string): ParsedVerificat
|
||||
const queryTimestamp = params.get('timestamp');
|
||||
const parsedTimestamp = queryTimestamp ? Number(queryTimestamp) : Number.NaN;
|
||||
|
||||
const rawEnv = params.get('environment');
|
||||
const environment: 'prod' | 'stg' = rawEnv === 'staging' || rawEnv === 'stg' ? 'stg' : 'prod';
|
||||
|
||||
const rawVersion = params.get('version');
|
||||
const parsedVersion = rawVersion ? Number(rawVersion) : Number.NaN;
|
||||
const version = Number.isFinite(parsedVersion) ? parsedVersion : 1;
|
||||
|
||||
return {
|
||||
request,
|
||||
displayLabels: parseDisplayLabels(params),
|
||||
@@ -45,6 +54,8 @@ export function parseVerificationRequestContext(search: string): ParsedVerificat
|
||||
timestamp: Number.isFinite(parsedTimestamp) ? parsedTimestamp : Date.now(),
|
||||
requestType: normalizeRequestType(params.get('resultType')),
|
||||
verificationId: params.get('verificationId') ?? undefined,
|
||||
environment,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||