diff --git a/packages/webview-app/package.json b/packages/webview-app/package.json index 08cfc31fc..f3aaf200a 100644 --- a/packages/webview-app/package.json +++ b/packages/webview-app/package.json @@ -17,8 +17,9 @@ "types": "tsc --noEmit" }, "dependencies": { - "@selfxyz/euclid": "1.2.6", - "@selfxyz/euclid-core": "1.2.6", + "@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", diff --git a/packages/webview-app/public/logos/self.svg b/packages/webview-app/public/logos/self.svg new file mode 100644 index 000000000..6748e43b3 --- /dev/null +++ b/packages/webview-app/public/logos/self.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview-app/src/App.tsx b/packages/webview-app/src/App.tsx index f231341ba..032d38c8e 100644 --- a/packages/webview-app/src/App.tsx +++ b/packages/webview-app/src/App.tsx @@ -6,6 +6,7 @@ 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'; @@ -41,6 +42,11 @@ import { ProofSuccessBackupScreen } from './screens/proving/ProofSuccessBackupSc 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'; @@ -50,55 +56,62 @@ import { TunnelProvingScreen } from './screens/tunnel/TunnelProvingScreen'; import { TunnelResultScreen } from './screens/tunnel/TunnelResultScreen'; export const App: React.FC = () => ( - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {import.meta.env.DEV && } />} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {import.meta.env.DEV && } - - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {import.meta.env.DEV && } />} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {import.meta.env.DEV && } + + + + ); diff --git a/packages/webview-app/src/components/DevRouteMenu.tsx b/packages/webview-app/src/components/DevRouteMenu.tsx index 96d0f1e3a..850041197 100644 --- a/packages/webview-app/src/components/DevRouteMenu.tsx +++ b/packages/webview-app/src/components/DevRouteMenu.tsx @@ -3,32 +3,95 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type React from 'react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -const mockScreenLinks = [ - { href: '/settings/dev-mode', label: 'Dev Mode' }, - { href: '/manage-documents', label: 'Manage Documents' }, - { href: '/id-data', label: 'ID Data' }, - { 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' }, - { href: '/debug/keychain', label: 'Keychain Debug' }, +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/dev-mode', label: 'Dev Mode' }, + { href: '/settings/security', label: 'Security' }, + { href: '/settings/notifications', label: 'Notification Preferences' }, + ], + }, + { + 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(null); + + useEffect(() => { + if (isOpen && activeRef.current) { + activeRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, [isOpen]); const currentLabel = useMemo( - () => mockScreenLinks.find(link => link.href === location.pathname)?.label ?? 'Mock Screens', + () => allLinks.find(link => link.href === location.pathname)?.label ?? 'Dev Screens', [location.pathname], ); @@ -53,51 +116,57 @@ export const DevRouteMenu: React.FC = () => { overflowY: 'auto', display: 'flex', flexDirection: 'column', - gap: 8, + gap: 4, padding: 14, borderRadius: 14, backgroundColor: 'rgba(17, 24, 39, 0.95)', boxShadow: '0 12px 32px rgba(0, 0, 0, 0.3)', }} > -
- Mock Screens -
- {mockScreenLinks.map(link => { - const isActive = location.pathname === link.href; - - return ( - - ); - })} + {group.title} + + {group.links.map(link => { + const isActive = location.pathname === link.href; + + return ( + + ); + })} + + ))} )} ); diff --git a/packages/webview-app/src/components/PasswordGate.tsx b/packages/webview-app/src/components/PasswordGate.tsx new file mode 100644 index 000000000..968cfd0bd --- /dev/null +++ b/packages/webview-app/src/components/PasswordGate.tsx @@ -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 ( +
+
+ { + 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 + /> + +
+
+ ); +}; diff --git a/packages/webview-app/src/main.tsx b/packages/webview-app/src/main.tsx index 9f0c1543a..3c1d6be07 100644 --- a/packages/webview-app/src/main.tsx +++ b/packages/webview-app/src/main.tsx @@ -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; diff --git a/packages/webview-app/src/recovery.css b/packages/webview-app/src/recovery.css new file mode 100644 index 000000000..977a2267d --- /dev/null +++ b/packages/webview-app/src/recovery.css @@ -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; +} diff --git a/packages/webview-app/src/screens/account/SecurityScreen.tsx b/packages/webview-app/src/screens/account/SecurityScreen.tsx index bcad3258a..ff07d8aca 100644 --- a/packages/webview-app/src/screens/account/SecurityScreen.tsx +++ b/packages/webview-app/src/screens/account/SecurityScreen.tsx @@ -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(() => { diff --git a/packages/webview-app/src/screens/home/ManageDocumentsScreen.tsx b/packages/webview-app/src/screens/home/ManageDocumentsScreen.tsx index 75b5782d9..35f27b21f 100644 --- a/packages/webview-app/src/screens/home/ManageDocumentsScreen.tsx +++ b/packages/webview-app/src/screens/home/ManageDocumentsScreen.tsx @@ -29,11 +29,9 @@ export const ManageDocumentsScreen: React.FC = () => { const onDocumentPress = useCallback(() => { haptic.trigger('selection'); - setDialogue({ - title: 'Manage Document', - description: 'View details or remove this document from your Self ID.', - }); - }, [haptic]); + analytics.trackEvent('manage_docs_document_pressed'); + navigate('/id-data'); + }, [haptic, analytics, navigate]); const onViewIdDetails = useCallback(() => { haptic.trigger('selection'); diff --git a/packages/webview-app/src/screens/recovery/BackupMethodPickerScreen.tsx b/packages/webview-app/src/screens/recovery/BackupMethodPickerScreen.tsx new file mode 100644 index 000000000..8a9a95105 --- /dev/null +++ b/packages/webview-app/src/screens/recovery/BackupMethodPickerScreen.tsx @@ -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 ( + } + options={[ + { + id: 'icloud', + label: 'iCloud Backup', + icon: , + onPress: onICloudBackup, + }, + { + id: 'recovery-phrase', + label: 'Recovery Phrase', + icon: , + onPress: onRecoveryPhrase, + }, + { + id: 'turnkey', + label: 'Turnkey Backup', + icon: , + onPress: () => navigate('/coming-soon'), + disabled: true, + }, + ]} + closeIcon={({ size, color }) => } + onClose={onClose} + /> + ); +}; diff --git a/packages/webview-app/src/screens/recovery/LaunchRecoveryScreen.tsx b/packages/webview-app/src/screens/recovery/LaunchRecoveryScreen.tsx new file mode 100644 index 000000000..f428d85ef --- /dev/null +++ b/packages/webview-app/src/screens/recovery/LaunchRecoveryScreen.tsx @@ -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 ( +
+ } + onClose={onClose} + onAppleBackup={() => navigate('/coming-soon')} + onGoogleBackup={() => navigate('/coming-soon')} + onEnterRecoveryPhrase={onEnterRecoveryPhrase} + backgroundImage="/backgrounds/restore.png" + /> +
+ ); +}; diff --git a/packages/webview-app/src/screens/recovery/RecoveryPhraseScreen.tsx b/packages/webview-app/src/screens/recovery/RecoveryPhraseScreen.tsx new file mode 100644 index 000000000..4b209ee13 --- /dev/null +++ b/packages/webview-app/src/screens/recovery/RecoveryPhraseScreen.tsx @@ -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('hidden'); + const [words, setWords] = useState(); + + 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 ( + navigate('/coming-soon')} + onGoogleBackup={() => navigate('/coming-soon')} + /> + ); +}; diff --git a/packages/webview-app/src/screens/recovery/RecoverySuccessScreen.tsx b/packages/webview-app/src/screens/recovery/RecoverySuccessScreen.tsx new file mode 100644 index 000000000..5f14a8c25 --- /dev/null +++ b/packages/webview-app/src/screens/recovery/RecoverySuccessScreen.tsx @@ -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 ( + } + logo={} + onClose={onClose} + onAppleBackup={() => navigate('/coming-soon')} + onGoogleBackup={() => navigate('/coming-soon')} + /> + ); +}; diff --git a/packages/webview-app/src/screens/recovery/SecretPhraseInputScreen.tsx b/packages/webview-app/src/screens/recovery/SecretPhraseInputScreen.tsx new file mode 100644 index 000000000..2cbab348b --- /dev/null +++ b/packages/webview-app/src/screens/recovery/SecretPhraseInputScreen.tsx @@ -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 ( + } + onBack={onBack} + onSubmit={onSubmit} + validWords={VALID_WORDS} + /> + ); +}; diff --git a/packages/webview-app/tests/components/PasswordGate.test.tsx b/packages/webview-app/tests/components/PasswordGate.test.tsx new file mode 100644 index 000000000..1928abc2c --- /dev/null +++ b/packages/webview-app/tests/components/PasswordGate.test.tsx @@ -0,0 +1,71 @@ +// 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. + +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PasswordGate } from '../../src/components/PasswordGate'; + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +describe('PasswordGate', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + sessionStorage.clear(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + }); + + it('renders children immediately when no preview password is configured', () => { + vi.stubEnv('VITE_WEBVIEW_APP_PREVIEW_PASSWORD', ''); + + render( + +
Unlocked content
+
, + ); + + expect(screen.getByText('Unlocked content')).toBeTruthy(); + expect(screen.queryByPlaceholderText('Password')).toBeNull(); + }); + + it('unlocks after the correct password and persists the session', () => { + vi.stubEnv('VITE_WEBVIEW_APP_PREVIEW_PASSWORD', 'secret-preview'); + + render( + +
Unlocked content
+
, + ); + + expect(screen.queryByText('Unlocked content')).toBeNull(); + + fireEvent.change(screen.getByPlaceholderText('Password'), { + target: { value: 'secret-preview' }, + }); + fireEvent.click(screen.getByRole('button', { name: /enter/i })); + + expect(screen.getByText('Unlocked content')).toBeTruthy(); + expect(sessionStorage.getItem('self-preview-auth')).toBe('true'); + }); + + it('renders children immediately when the session is already authenticated', () => { + vi.stubEnv('VITE_WEBVIEW_APP_PREVIEW_PASSWORD', 'secret-preview'); + sessionStorage.setItem('self-preview-auth', 'true'); + + render( + +
Unlocked content
+
, + ); + + expect(screen.getByText('Unlocked content')).toBeTruthy(); + expect(screen.queryByPlaceholderText('Password')).toBeNull(); + }); +}); diff --git a/packages/webview-app/tests/screens/home/homeSupportScreens.test.tsx b/packages/webview-app/tests/screens/home/homeSupportScreens.test.tsx index e0a49075c..8ebb35d1a 100644 --- a/packages/webview-app/tests/screens/home/homeSupportScreens.test.tsx +++ b/packages/webview-app/tests/screens/home/homeSupportScreens.test.tsx @@ -174,7 +174,6 @@ describe('WV-14 support screens', () => { expect(screen.getByTestId('location').textContent).toBe('/manage-documents'); fireEvent.click(screen.getByRole('button', { name: /passport/i })); - fireEvent.click(screen.getByRole('button', { name: /view details/i })); expect(screen.getByTestId('location').textContent).toBe('/id-data'); fireEvent.click(screen.getByRole('button', { name: /manage id/i })); @@ -188,7 +187,7 @@ describe('WV-14 support screens', () => { expect(screen.getByRole('button', { name: /open settings/i })).toBeTruthy(); }); - fireEvent.click(screen.getByRole('button', { name: /mock screens/i })); + fireEvent.click(screen.getByRole('button', { name: /dev screens/i })); expect(screen.getByRole('button', { name: 'Manage Documents' })).toBeTruthy(); expect(screen.getByRole('button', { name: 'ID Data' })).toBeTruthy(); diff --git a/packages/webview-app/tests/screens/recovery/recoveryPhraseScreen.test.tsx b/packages/webview-app/tests/screens/recovery/recoveryPhraseScreen.test.tsx new file mode 100644 index 000000000..fe08b30f8 --- /dev/null +++ b/packages/webview-app/tests/screens/recovery/recoveryPhraseScreen.test.tsx @@ -0,0 +1,148 @@ +// 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. + +// @vitest-environment jsdom + +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RecoveryPhraseScreen } from '../../../src/screens/recovery/RecoveryPhraseScreen'; + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const analytics = { trackEvent: vi.fn() }; +const haptic = { trigger: vi.fn() }; + +const storageGet = vi.fn<() => Promise>(); + +vi.mock('../../../src/providers/SelfClientProvider', () => ({ + useSelfClient: () => ({ + analytics, + haptic, + }), +})); + +vi.mock('../../../src/providers/BridgeProvider', () => ({ + useBridge: () => ({}), +})); + +vi.mock('@selfxyz/webview-bridge/adapters', () => ({ + bridgeStorageAdapter: () => ({ + get: storageGet, + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('@selfxyz/euclid', () => ({ + createSafeAreaProps: ({ top, bottom }: { top: number; bottom: number }) => ({ + insets: { top, bottom, left: 0, right: 0 }, + safeArea: { top, bottom, left: 0, right: 0 }, + }), + RecoveryPhraseScreen: ({ + words, + variant, + onReveal, + onCopy, + }: { + words?: string[]; + variant: string; + onReveal: () => void; + onCopy: () => void; + }) => ( +
+
{variant}
+
{words?.join(' ') ?? ''}
+ + +
+ ), +})); + +describe('RecoveryPhraseScreen', () => { + beforeEach(() => { + vi.clearAllMocks(); + storageGet.mockResolvedValue(null); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('reveals placeholders when secure storage is empty', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i })); + + await waitFor(() => { + expect(screen.getByTestId('variant').textContent).toBe('revealed'); + expect(screen.getByTestId('words').textContent).toBe(''); + }); + }); + + it('copies the resolved words to the clipboard', async () => { + storageGet.mockResolvedValue(JSON.stringify({ phrase: 'alpha beta gamma' })); + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i })); + + await waitFor(() => { + expect(screen.getByTestId('words').textContent).toBe('alpha beta gamma'); + }); + + fireEvent.click(screen.getByRole('button', { name: /copy phrase/i })); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('alpha beta gamma'); + expect(screen.getByTestId('variant').textContent).toBe('copied'); + }); + }); + + it('does not switch to copied when clipboard write fails', async () => { + storageGet.mockResolvedValue(JSON.stringify({ phrase: 'alpha beta gamma' })); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error('denied')), + }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i })); + + await waitFor(() => { + expect(screen.getByTestId('words').textContent).toBe('alpha beta gamma'); + }); + + fireEvent.click(screen.getByRole('button', { name: /copy phrase/i })); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('alpha beta gamma'); + expect(screen.getByTestId('variant').textContent).toBe('revealed'); + expect(haptic.trigger).toHaveBeenCalledWith('error'); + }); + }); +}); diff --git a/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx b/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx new file mode 100644 index 000000000..e43676919 --- /dev/null +++ b/packages/webview-app/tests/screens/recovery/recoverySupportScreens.test.tsx @@ -0,0 +1,231 @@ +// 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. + +// @vitest-environment jsdom + +import type React from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SecurityScreen } from '../../../src/screens/account/SecurityScreen'; +import { SettingsScreen } from '../../../src/screens/account/SettingsScreen'; +import { BackupMethodPickerScreen } from '../../../src/screens/recovery/BackupMethodPickerScreen'; +import { LaunchRecoveryScreen } from '../../../src/screens/recovery/LaunchRecoveryScreen'; +import { RecoveryPhraseScreen } from '../../../src/screens/recovery/RecoveryPhraseScreen'; +import { RecoverySuccessScreen } from '../../../src/screens/recovery/RecoverySuccessScreen'; +import { SecretPhraseInputScreen } from '../../../src/screens/recovery/SecretPhraseInputScreen'; + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +const analytics = { trackEvent: vi.fn() }; +const haptic = { trigger: vi.fn() }; +const lifecycle = { dismiss: vi.fn() }; + +vi.mock('../../../src/providers/SelfClientProvider', () => ({ + useSelfClient: () => ({ + analytics, + haptic, + lifecycle, + }), +})); + +vi.mock('../../../src/providers/BridgeProvider', () => ({ + useBridge: () => ({}), +})); + +vi.mock('@selfxyz/webview-bridge/adapters', () => ({ + bridgeStorageAdapter: () => ({ + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock('@selfxyz/euclid', () => ({ + createSafeAreaProps: ({ top, bottom }: { top: number; bottom: number }) => ({ + insets: { top, bottom, left: 0, right: 0 }, + safeArea: { top, bottom, left: 0, right: 0 }, + }), + ChatStrokeIcon: () => null, + CloudKeyIcon: () => null, + CodeIcon: () => null, + DocumentDetailsIcon: () => null, + LeftArrowIcon: () => null, + LockIcon: () => null, + NotificationIcon: () => null, + QuestionCircleStrokeIcon: () => null, + SelfLogo: () => null, + ShareIcon: () => null, + ZapShieldIcon: () => null, + SettingsViewScreen: ({ sections }: { sections: Array<{ items: Array<{ label: string; onPress: () => void }> }> }) => ( +
+ {sections.flatMap(section => + section.items.map(item => ( + + )), + )} +
+ ), + SecurityScreen: ({ + onBackupAccount, + onRestoreAccount, + onRevealRecoveryPhrase, + }: { + onBackupAccount: () => void; + onRestoreAccount: () => void; + onRevealRecoveryPhrase: () => void; + }) => ( +
+ + + +
+ ), + BackupMethodPickerScreen: ({ options }: { options: Array<{ label: string; onPress: () => void }> }) => ( +
+ {options.map(option => ( + + ))} +
+ ), + RecoveryPhraseScreen: ({ onReveal, onCopy }: { onReveal: () => void; onCopy: () => void }) => ( +
+ + +
+ ), + LaunchRecoveryScreen: ({ + onEnterRecoveryPhrase, + onClose, + }: { + onEnterRecoveryPhrase: () => void; + onClose: () => void; + }) => ( +
+ + +
+ ), + SecretPhraseInputScreen: ({ onSubmit }: { onSubmit: (words: string[]) => void }) => ( +
+ + +
+ ), + RecoverySuccessScreen: ({ onClose }: { onClose: () => void }) => ( + + ), +})); + +const LocationDisplay: React.FC = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +const renderRoutes = (initialEntries: string[]) => + render( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + , + ); + +describe('recovery support screens', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + const expectLocation = (expected: string) => { + expect(screen.getByTestId('location').textContent).toBe(expected); + }; + + it('stitches settings through security to recovery phrase backup flow', () => { + renderRoutes(['/settings']); + + fireEvent.click(screen.getByRole('button', { name: /security/i })); + expectLocation('/settings/security'); + + fireEvent.click(screen.getByRole('button', { name: /back up account/i })); + expectLocation('/settings/backup'); + + fireEvent.click(screen.getByRole('button', { name: /recovery phrase/i })); + expectLocation('/settings/recovery-phrase'); + }); + + it('stitches settings through security to recovery restore success flow', () => { + renderRoutes(['/settings']); + + fireEvent.click(screen.getByRole('button', { name: /security/i })); + expectLocation('/settings/security'); + + fireEvent.click(screen.getByRole('button', { name: /restore account/i })); + expectLocation('/recovery'); + + fireEvent.click(screen.getByRole('button', { name: /enter recovery phrase/i })); + expectLocation('/recovery/phrase-input'); + + fireEvent.click(screen.getByRole('button', { name: /submit valid phrase/i })); + expectLocation('/recovery/success'); + }); + + it('rejects an invalid mnemonic and stays on phrase input', () => { + renderRoutes(['/recovery/phrase-input']); + expectLocation('/recovery/phrase-input'); + + fireEvent.click(screen.getByRole('button', { name: /submit invalid phrase/i })); + expectLocation('/recovery/phrase-input'); + + expect(haptic.trigger).toHaveBeenCalledWith('error'); + expect(analytics.trackEvent).toHaveBeenCalledWith('recovery_phrase_rejected', { wordCount: 3 }); + }); + + it('launch recovery close returns to previous screen', () => { + renderRoutes(['/settings/security', '/recovery']); + expectLocation('/recovery'); + + fireEvent.click(screen.getByRole('button', { name: /close recovery/i })); + expectLocation('/settings/security'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 56e4be6c2..0fb2db91f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10784,26 +10784,26 @@ __metadata: languageName: unknown linkType: soft -"@selfxyz/euclid-core@npm:1.2.6, @selfxyz/euclid-core@npm:^1.2.6": - version: 1.2.6 - resolution: "@selfxyz/euclid-core@npm:1.2.6" +"@selfxyz/euclid-core@npm:1.3.0, @selfxyz/euclid-core@npm:^1.3.0": + version: 1.3.0 + resolution: "@selfxyz/euclid-core@npm:1.3.0" peerDependencies: react: ">=18.2.0" - checksum: 10c0/0496da70a4eb5e3b7bbae51f2379f8080ed21950eac6e09c3ad9a39020399ef0a7065798d626e86ac187a34ee40c04e0bac1d8186aa1507900c83b667ac6816a + checksum: 10c0/346831b2e2128014a1143f2af079229c40bfe64a622ef54600f0d927a8b8f78e8ad624fd4805488e03fbc2504171313fcc62c4123970afa30597c7380c817c3b languageName: node linkType: hard -"@selfxyz/euclid@npm:1.2.6": - version: 1.2.6 - resolution: "@selfxyz/euclid@npm:1.2.6" +"@selfxyz/euclid@npm:1.3.0": + version: 1.3.0 + resolution: "@selfxyz/euclid@npm:1.3.0" dependencies: "@lottiefiles/dotlottie-react": "npm:^0.18.4" - "@selfxyz/euclid-core": "npm:^1.2.6" + "@selfxyz/euclid-core": "npm:^1.3.0" lottie-react: "npm:^2.4.1" peerDependencies: react: ">=18.2.0" react-dom: ">=18.2.0" - checksum: 10c0/af56ea1abc20645dbe58f29d062ab4e2686f626de92fb5626cfbe7971b4ec31926edf8e0d598dd173a4f1b9749e6ccf690e3189e7694178e2653a2ddff195f4d + checksum: 10c0/056e53cfa6566056fc107fb553157de586dcdd384eedf101316b11b08d39d5800226321d53e119e38160977ef346632f6db16c27ad67f003d0bf085aed8a0f48 languageName: node linkType: hard @@ -11260,8 +11260,9 @@ __metadata: version: 0.0.0-use.local resolution: "@selfxyz/webview-app@workspace:packages/webview-app" dependencies: - "@selfxyz/euclid": "npm:1.2.6" - "@selfxyz/euclid-core": "npm:1.2.6" + "@scure/bip39": "npm:^1.6.0" + "@selfxyz/euclid": "npm:1.3.0" + "@selfxyz/euclid-core": "npm:1.3.0" "@selfxyz/mobile-sdk-alpha": "workspace:^" "@selfxyz/webview-bridge": "workspace:^" "@sumsub/websdk": "npm:^2.0.0"