Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ See [docs/adr-nfc-library.md](docs/adr-nfc-library.md) for platform constraints

- [Product flows & system definition](docs/ding-payments.md)
- [Client MVP build plan](docs/build-plan-client-mvp.md)
- [Wallet flow (C09)](docs/wallet-flow.md)
- [NFC library ADR](docs/adr-nfc-library.md)
- [Receive payment flow (C12)](docs/receive-flow.md)
- [NFC runtime flow and troubleshooting](docs/nfc-flow.md)
Expand Down
402 changes: 402 additions & 0 deletions docs/wallet-flow.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/constants/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export const AnalyticsEvents = {
NFC_READ_FAILURE: 'nfc_read_failure',
NFC_WRITE_SUCCESS: 'nfc_write_success',
NFC_WRITE_FAILURE: 'nfc_write_failure',

// Wallet errors (CLI-041)
WALLET_ERROR: 'wallet_error',
} as const;

export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
Expand All @@ -33,3 +36,8 @@ export interface ReceiveEventProperties {
reason?: string;
duration_ms?: number;
}

/** Allowed properties for wallet error events. Code only — no cause or Horizon payloads. */
export interface WalletEventProperties {
code: string;
}
22 changes: 16 additions & 6 deletions src/features/auth/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
/**
* CLI-019 — AuthGuard
* CLI-019 / CLI-042 — AuthGuard
*
* Route guard component that redirects users based on auth state.
* Prevents protected tabs from rendering when auth is incomplete.
* Route guard for protected tabs: auth state first, then local wallet readiness.
*
* State → redirect rules:
* - LOADING → render nothing (splash is shown by AnimatedSplashOverlay)
* - UNAUTHENTICATED → redirect to onboarding welcome
* - ONBOARDING → redirect to create-passkey
* - LOCKED → redirect to locked screen (re-auth)
* - READY → render children
* - READY → require walletStore hydrated + status ready + publicKey
*
* Loop safety: uses replace semantics so the back button doesn't loop.
* Loop safety: uses replace semantics; wallet-setup lives outside (tabs) guard.
*/

import { Redirect } from 'expo-router';

import { useWalletStore } from '@/features/wallet/state/walletStore';
import { useAuth } from '../hooks/useAuth';

export function AuthGuard({ children }: { children: React.ReactNode }) {
const { state } = useAuth();
const hasHydrated = useWalletStore((walletState) => walletState.hasHydrated);
const walletStatus = useWalletStore((walletState) => walletState.status);
const walletPublicKey = useWalletStore((walletState) => walletState.publicKey);

switch (state.status) {
case 'LOADING':
// Splash overlay is handling the loading state
return null;

case 'UNAUTHENTICATED':
Expand All @@ -36,6 +38,14 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
return <Redirect href="/(onboarding)/locked" />;

case 'READY':
if (!hasHydrated) {
return null;
}

if (walletStatus !== 'ready' || !walletPublicKey) {
return <Redirect href="/(onboarding)/wallet-setup" />;
}

return <>{children}</>;

default:
Expand Down
8 changes: 4 additions & 4 deletions src/features/auth/components/SettingsAuthSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
import { Button } from '@/components/ui/Button';
import { ThemedText } from '@/components/themed-text';
import { useTheme } from '@/hooks/use-theme';
import { useWallet } from '@/features/wallet/hooks/useWallet';
import { useAuth } from '../hooks/useAuth';

export function SettingsAuthSection() {
const { state, logout } = useAuth();
const { logout } = useAuth();
const { publicKey } = useWallet();
const theme = useTheme();
const [pubkeyCopied, setPubkeyCopied] = useState(false);

const publicKey = state.publicKey;

const handleLogout = useCallback(() => {
Alert.alert(
'Cerrar sesión',
Expand Down Expand Up @@ -95,7 +95,7 @@ export function SettingsAuthSection() {
) : (
<View style={[styles.pubkeyRow, { backgroundColor: theme.backgroundElement }]}>
<ThemedText type="small" themeColor="textSecondary">
Sin llave de acceso registrada
Billetera no configurada
</ThemedText>
</View>
)}
Expand Down
161 changes: 161 additions & 0 deletions src/features/auth/components/__tests__/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React from 'react';
import { Text } from 'react-native';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';

import { useWalletStore } from '@/features/wallet/state/walletStore';
import { AuthGuard } from '../AuthGuard';
import { useAuth } from '../../hooks/useAuth';
import type { AuthState } from '../../state/authStore';

jest.mock('expo-router', () => {
const React = require('react');

Check warning on line 11 in src/features/auth/components/__tests__/AuthGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Build, lint & format

A `require()` style import is forbidden
const { Text } = require('react-native');

Check warning on line 12 in src/features/auth/components/__tests__/AuthGuard.test.tsx

View workflow job for this annotation

GitHub Actions / Build, lint & format

A `require()` style import is forbidden

return {
Redirect: ({ href }: { href: string }) =>
React.createElement(Text, { testID: 'redirect' }, href),
};
});

jest.mock('../../hooks/useAuth', () => ({
useAuth: jest.fn(),
}));

const mockUseAuth = useAuth as jest.Mock;

function setAuthState(status: AuthState['status']) {
mockUseAuth.mockReturnValue({
state: { status } as AuthState,
});
}

function resetWalletStore() {
useWalletStore.getState().reset();
}

async function renderGuard() {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(
<AuthGuard>
<Text testID="protected-content">Tabs</Text>
</AuthGuard>
);
});
return renderer;
}

describe('AuthGuard', () => {
beforeEach(() => {
jest.clearAllMocks();
resetWalletStore();
});

it('returns null while auth is LOADING', async () => {
setAuthState('LOADING');

const renderer = await renderGuard();

expect(renderer.root.findAllByProps({ testID: 'protected-content' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0);
});

it('redirects UNAUTHENTICATED users to welcome', async () => {
setAuthState('UNAUTHENTICATED');

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/welcome'
);
});

it('redirects ONBOARDING users to create-passkey', async () => {
setAuthState('ONBOARDING');

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/create-passkey'
);
});

it('redirects LOCKED users to locked', async () => {
setAuthState('LOCKED');

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/locked'
);
});

it('returns null for READY auth while wallet is not hydrated', async () => {
setAuthState('READY');
useWalletStore.setState({ hasHydrated: false, status: 'idle', publicKey: null });

const renderer = await renderGuard();

expect(renderer.root.findAllByProps({ testID: 'protected-content' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0);
});

it('redirects READY auth to wallet-setup when wallet status is not ready', async () => {
setAuthState('READY');
useWalletStore.setState({
hasHydrated: true,
status: 'creating',
publicKey: 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO',
});

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/wallet-setup'
);
});

it('redirects READY auth to wallet-setup when wallet publicKey is missing', async () => {
setAuthState('READY');
useWalletStore.setState({
hasHydrated: true,
status: 'ready',
publicKey: null,
});

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/wallet-setup'
);
});

it('renders children when auth is READY and wallet is ready with a publicKey', async () => {
setAuthState('READY');
useWalletStore.setState({
hasHydrated: true,
status: 'ready',
publicKey: 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO',
});

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'protected-content' }).props.children).toBe('Tabs');
expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0);
});

it('does not apply wallet checks outside READY auth', async () => {
setAuthState('LOCKED');
useWalletStore.setState({
hasHydrated: false,
status: 'idle',
publicKey: null,
});

const renderer = await renderGuard();

expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe(
'/(onboarding)/locked'
);
});
});
Loading
Loading