mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Add recovery and backup screen surfaces (WV-15) (#1876)
* Add recovery and backup screens (SELF-2423) Add 5 recovery/backup screen wrappers around euclid components: BackupMethodPickerScreen, RecoveryPhraseScreen, LaunchRecoveryScreen, SecretPhraseInputScreen, and RecoverySuccessScreen. Wire SecurityScreen actions to the new routes instead of /coming-soon. Register all routes in App.tsx. * Add background image to LaunchRecoveryScreen and animation assets (SELF-2423) Add dialogue-background.jpg and Lottie animation JSON files to public/ for proper screen rendering. Pass backgroundImage prop to LaunchRecoveryScreen for visual consistency with other dialogue screens. * add test * link up * fixes * revert fix. we need to fix in euclid * update euclic * dev menu dx, add password * fix launch recovery screen * fix recovery success screen * fix recovery phrase tests * updates * fixes * fixes --------- Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
22
packages/webview-app/public/logos/self.svg
Normal file
22
packages/webview-app/public/logos/self.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="37" height="37" viewBox="0 0 37 37" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_16462_4504)">
|
||||
<path d="M18.5051 14.1988H18.5C16.1245 14.1988 14.1987 16.1245 14.1987 18.5V18.5051C14.1987 20.8807 16.1245 22.8064 18.5 22.8064H18.5051C20.8806 22.8064 22.8064 20.8807 22.8064 18.5051V18.5C22.8064 16.1245 20.8806 14.1988 18.5051 14.1988Z" fill="#00FFB6"/>
|
||||
<path d="M10.0619 14.5174C10.0619 11.9633 12.1329 9.89236 14.6869 9.89236H23.6183L33.5107 0H8.84917L0 8.84917V23.4076H10.0619V14.5122V14.5174Z" fill="url(#paint0_linear_16462_4504)"/>
|
||||
<path d="M26.9381 13.5564V22.1435C26.9381 24.6975 24.8671 26.7685 22.3131 26.7685H13.726L3.48932 37.0051H28.1508L37 28.156V13.5615H26.9381V13.5564Z" fill="url(#paint1_linear_16462_4504)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_16462_4504" x1="0" y1="11.7038" x2="33.5107" y2="11.7038" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E2EDF8"/>
|
||||
<stop offset="0.63" stop-color="white"/>
|
||||
<stop offset="1" stop-color="#EAF1F9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_16462_4504" x1="3.48932" y1="25.2808" x2="37" y2="25.2808" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E2EDF8"/>
|
||||
<stop offset="0.63" stop-color="white"/>
|
||||
<stop offset="1" stop-color="#EAF1F9"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_16462_4504">
|
||||
<rect width="37" height="37" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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 = () => (
|
||||
<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="/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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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<HTMLButtonElement>(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)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Mock Screens
|
||||
</div>
|
||||
{mockScreenLinks.map(link => {
|
||||
const isActive = location.pathname === link.href;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={link.href}
|
||||
onClick={() => {
|
||||
navigate(link.href);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
{screenGroups.map(group => (
|
||||
<div key={group.title} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: isActive ? '1px solid #7c8aff' : '1px solid rgba(255, 255, 255, 0.12)',
|
||||
backgroundColor: isActive ? 'rgba(124, 138, 255, 0.22)' : 'rgba(255, 255, 255, 0.08)',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '8px 2px 2px',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{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
|
||||
@@ -116,7 +185,7 @@ export const DevRouteMenu: React.FC = () => {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? 'Close Mock Screens' : currentLabel}
|
||||
{isOpen ? 'Close' : currentLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
86
packages/webview-app/src/components/PasswordGate.tsx
Normal file
86
packages/webview-app/src/components/PasswordGate.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'self-preview-auth';
|
||||
|
||||
export const PasswordGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const password = import.meta.env.VITE_WEBVIEW_APP_PREVIEW_PASSWORD;
|
||||
|
||||
const [authenticated, setAuthenticated] = useState(() => !password || sessionStorage.getItem(STORAGE_KEY) === 'true');
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value === password) {
|
||||
sessionStorage.setItem(STORAGE_KEY, 'true');
|
||||
setAuthenticated(true);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
},
|
||||
[value, password],
|
||||
);
|
||||
|
||||
if (authenticated) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
setValue(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: error ? '1px solid #ef4444' : '1px solid #d1d5db',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
backgroundColor: '#111827',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { App } from './App';
|
||||
import { BridgeProvider } from './providers/BridgeProvider';
|
||||
|
||||
import './fonts.css';
|
||||
import './recovery.css';
|
||||
import './reset.css';
|
||||
|
||||
globalThis.Buffer = Buffer;
|
||||
|
||||
12
packages/webview-app/src/recovery.css
Normal file
12
packages/webview-app/src/recovery.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.launch-recovery-screen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.launch-recovery-screen img[src$='/backgrounds/restore.png'],
|
||||
.launch-recovery-screen img[src$='restore.png'] {
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center !important;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
BackupMethodPickerScreen as EuclidBackupMethodPickerScreen,
|
||||
CloudKeyIcon,
|
||||
LeftArrowIcon,
|
||||
LockIcon,
|
||||
ZapShieldIcon,
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const BackupMethodPickerScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/security');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onICloudBackup = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('backup_method_icloud_pressed');
|
||||
navigate('/coming-soon');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('backup_method_phrase_pressed');
|
||||
navigate('/settings/recovery-phrase');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidBackupMethodPickerScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
title="Back up your account"
|
||||
description="Choose how you'd like to secure your identity data. You can always change this later."
|
||||
subtitle="Backup"
|
||||
iconContainer={<CloudKeyIcon size={48} color="#000" />}
|
||||
options={[
|
||||
{
|
||||
id: 'icloud',
|
||||
label: 'iCloud Backup',
|
||||
icon: <CloudKeyIcon size={24} color="#000" />,
|
||||
onPress: onICloudBackup,
|
||||
},
|
||||
{
|
||||
id: 'recovery-phrase',
|
||||
label: 'Recovery Phrase',
|
||||
icon: <LockIcon size={24} color="#000" />,
|
||||
onPress: onRecoveryPhrase,
|
||||
},
|
||||
{
|
||||
id: 'turnkey',
|
||||
label: 'Turnkey Backup',
|
||||
icon: <ZapShieldIcon size={24} color="#000" />,
|
||||
onPress: () => navigate('/coming-soon'),
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
closeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LaunchRecoveryScreen as EuclidLaunchRecoveryScreen, LeftArrowIcon } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const LaunchRecoveryScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate('/settings/security');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onEnterRecoveryPhrase = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('recovery_enter_phrase_pressed');
|
||||
navigate('/recovery/phrase-input');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<div className="launch-recovery-screen">
|
||||
<EuclidLaunchRecoveryScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onClose={onClose}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
onEnterRecoveryPhrase={onEnterRecoveryPhrase}
|
||||
backgroundImage="/backgrounds/restore.png"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { RecoveryPhraseVariant } from '@selfxyz/euclid';
|
||||
import { RecoveryPhraseScreen as EuclidRecoveryPhraseScreen } from '@selfxyz/euclid';
|
||||
import { bridgeStorageAdapter } from '@selfxyz/webview-bridge/adapters';
|
||||
|
||||
import { useBridge } from '../../providers/BridgeProvider';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MNEMONIC_KEY = 'secret';
|
||||
|
||||
function parseMnemonicWords(raw: string | null): string[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as string | { phrase?: string };
|
||||
const phrase = typeof parsed === 'string' ? parsed : parsed.phrase;
|
||||
const words = phrase?.trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
return words && words.length > 0 ? words : undefined;
|
||||
}
|
||||
|
||||
export const RecoveryPhraseScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const bridge = useBridge();
|
||||
const storage = useRef(bridgeStorageAdapter(bridge)).current;
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const [variant, setVariant] = useState<RecoveryPhraseVariant>('hidden');
|
||||
const [words, setWords] = useState<string[] | undefined>();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onReveal = useCallback(async () => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('recovery_phrase_revealed');
|
||||
|
||||
let resolvedWords: string[] | undefined;
|
||||
|
||||
try {
|
||||
resolvedWords = parseMnemonicWords(await storage.get(MNEMONIC_KEY));
|
||||
} catch {
|
||||
// Storage or parsing failed — words stay undefined, Euclid shows placeholders.
|
||||
}
|
||||
|
||||
setWords(resolvedWords);
|
||||
setVariant('revealed');
|
||||
}, [haptic, analytics, storage]);
|
||||
|
||||
const onCopy = useCallback(async () => {
|
||||
analytics.trackEvent('recovery_phrase_copied');
|
||||
|
||||
if (!words?.length || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(words.join(' '));
|
||||
haptic.trigger('success');
|
||||
setVariant('copied');
|
||||
} catch {
|
||||
haptic.trigger('error');
|
||||
}
|
||||
}, [haptic, analytics, words]);
|
||||
|
||||
return (
|
||||
<EuclidRecoveryPhraseScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
words={words}
|
||||
variant={variant}
|
||||
onBack={onBack}
|
||||
onReveal={onReveal}
|
||||
onCopy={onCopy}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, RecoverySuccessScreen as EuclidRecoverySuccessScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const RecoverySuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('recovery_success_continue_pressed');
|
||||
navigate('/');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
return (
|
||||
<EuclidRecoverySuccessScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
logo={<img src="/logos/self.svg" alt="" width={64} height={64} aria-hidden="true" />}
|
||||
onClose={onClose}
|
||||
onAppleBackup={() => navigate('/coming-soon')}
|
||||
onGoogleBackup={() => navigate('/coming-soon')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LeftArrowIcon, SecretPhraseInputScreen as EuclidSecretPhraseInputScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
import { validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist as bip39EnglishWordlist } from '@scure/bip39/wordlists/english';
|
||||
|
||||
const VALID_WORDS = new Set(bip39EnglishWordlist);
|
||||
const VALID_LENGTHS = new Set([12, 15, 18, 21, 24]);
|
||||
|
||||
export const SecretPhraseInputScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const onBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
navigate(-1);
|
||||
}, [navigate, haptic]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(words: string[]) => {
|
||||
if (!VALID_LENGTHS.has(words.length) || !validateMnemonic(words.join(' '), bip39EnglishWordlist)) {
|
||||
haptic.trigger('error');
|
||||
analytics.trackEvent('recovery_phrase_rejected', { wordCount: words.length });
|
||||
return;
|
||||
}
|
||||
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('recovery_phrase_submitted', { wordCount: words.length });
|
||||
navigate('/recovery/success');
|
||||
},
|
||||
[navigate, haptic, analytics],
|
||||
);
|
||||
|
||||
return (
|
||||
<EuclidSecretPhraseInputScreen
|
||||
insets={WEB_SAFE_AREA.insets}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
validWords={VALID_WORDS}
|
||||
/>
|
||||
);
|
||||
};
|
||||
71
packages/webview-app/tests/components/PasswordGate.test.tsx
Normal file
71
packages/webview-app/tests/components/PasswordGate.test.tsx
Normal file
@@ -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(
|
||||
<PasswordGate>
|
||||
<div>Unlocked content</div>
|
||||
</PasswordGate>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PasswordGate>
|
||||
<div>Unlocked content</div>
|
||||
</PasswordGate>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PasswordGate>
|
||||
<div>Unlocked content</div>
|
||||
</PasswordGate>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Unlocked content')).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('Password')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string | null>>();
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="variant">{variant}</div>
|
||||
<div data-testid="words">{words?.join(' ') ?? ''}</div>
|
||||
<button onClick={onReveal} type="button">
|
||||
Reveal phrase
|
||||
</button>
|
||||
<button onClick={onCopy} type="button">
|
||||
Copy phrase
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RecoveryPhraseScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RecoveryPhraseScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<RecoveryPhraseScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }> }> }) => (
|
||||
<div>
|
||||
{sections.flatMap(section =>
|
||||
section.items.map(item => (
|
||||
<button key={item.label} onClick={item.onPress} type="button">
|
||||
{item.label}
|
||||
</button>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
SecurityScreen: ({
|
||||
onBackupAccount,
|
||||
onRestoreAccount,
|
||||
onRevealRecoveryPhrase,
|
||||
}: {
|
||||
onBackupAccount: () => void;
|
||||
onRestoreAccount: () => void;
|
||||
onRevealRecoveryPhrase: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={onBackupAccount} type="button">
|
||||
Back up account
|
||||
</button>
|
||||
<button onClick={onRevealRecoveryPhrase} type="button">
|
||||
Reveal recovery phrase
|
||||
</button>
|
||||
<button onClick={onRestoreAccount} type="button">
|
||||
Restore account
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
BackupMethodPickerScreen: ({ options }: { options: Array<{ label: string; onPress: () => void }> }) => (
|
||||
<div>
|
||||
{options.map(option => (
|
||||
<button key={option.label} onClick={option.onPress} type="button">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
RecoveryPhraseScreen: ({ onReveal, onCopy }: { onReveal: () => void; onCopy: () => void }) => (
|
||||
<div>
|
||||
<button onClick={onReveal} type="button">
|
||||
Reveal phrase
|
||||
</button>
|
||||
<button onClick={onCopy} type="button">
|
||||
Copy phrase
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
LaunchRecoveryScreen: ({
|
||||
onEnterRecoveryPhrase,
|
||||
onClose,
|
||||
}: {
|
||||
onEnterRecoveryPhrase: () => void;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={onEnterRecoveryPhrase} type="button">
|
||||
Enter recovery phrase
|
||||
</button>
|
||||
<button onClick={onClose} type="button">
|
||||
Close recovery
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
SecretPhraseInputScreen: ({ onSubmit }: { onSubmit: (words: string[]) => void }) => (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onSubmit('bacon rubber extend tonight rocket race ill wash flame expect oval street'.split(' '))}
|
||||
type="button"
|
||||
>
|
||||
Submit valid phrase
|
||||
</button>
|
||||
<button onClick={() => onSubmit(['abandon', 'ability', 'able'])} type="button">
|
||||
Submit invalid phrase
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
RecoverySuccessScreen: ({ onClose }: { onClose: () => void }) => (
|
||||
<button onClick={onClose} type="button">
|
||||
Finish recovery
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const LocationDisplay: React.FC = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{location.pathname}</div>;
|
||||
};
|
||||
|
||||
const renderRoutes = (initialEntries: string[]) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LocationDisplay />} />
|
||||
<Route path="/settings" element={<SettingsScreen />} />
|
||||
<Route path="/settings/security" element={<SecurityScreen />} />
|
||||
<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="/coming-soon" element={<LocationDisplay />} />
|
||||
</Routes>
|
||||
<LocationDisplay />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
23
yarn.lock
23
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"
|
||||
|
||||
Reference in New Issue
Block a user