Merge pull request #1889 from selfxyz/release/staging-2026-03-30

Release to Staging v2.9.16 - 2026-03-30
This commit is contained in:
Justin Hernandez
2026-03-30 11:52:35 -07:00
committed by GitHub
122 changed files with 8967 additions and 571 deletions

View File

@@ -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
View File

@@ -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/

View File

@@ -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]);
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,5 +1,6 @@
{
"name": "self-workspace-root",
"private": true,
"workspaces": {
"packages": [
"app",

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View 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>

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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 ?? ""
}
}

View File

@@ -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? {

View File

@@ -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")

View File

@@ -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:

View File

@@ -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"

View File

@@ -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 },
)
)
},

View File

@@ -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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View 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

View File

@@ -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>
);

View 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>
);
};

View 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>
);
};

View File

@@ -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;

View 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;
}

View File

@@ -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]);

View File

@@ -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}
/>
);
};

View File

@@ -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(() => {

View File

@@ -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');
},
},
],
},

View 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} />}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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 />

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
</>
);

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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} />;
};

View File

@@ -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' }}
/>
);
};

View File

@@ -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} />;
};

View File

@@ -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} />}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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."
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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')}
/>
);
};

View File

@@ -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')}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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;
}

View 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}` });
});
});
}

View 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();
};
}

View File

@@ -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;

View File

@@ -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' });
}

View File

@@ -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');
});
});
});

View File

@@ -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,
};
}

Some files were not shown because too many files have changed in this diff Show More