Skip to content
Open
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 .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
17 changes: 10 additions & 7 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import { colors } from '../../constants/colors';
import { Card } from '../../components/shared/Card';
import { useAuthStore } from '../../stores/auth.store';
import { useUserStore } from '../../stores/user.store';
import { useWalletStore } from '../../stores/wallet.store';
import { biometricService } from '../../src/security/biometric.service';
import { signOutService } from '../../services/sign-out.service';
import { useTranslation } from '../../hooks/useTranslation';

interface MenuItemProps {
Expand Down Expand Up @@ -90,12 +90,10 @@ const LANGUAGES = [

export default function SettingsScreen() {
const { t, currentLanguage, changeLanguage } = useTranslation();
const clearAuth = useAuthStore((s) => s.clearAuth);
const walletAddress = useAuthStore((s) => s.walletAddress);
const profile = useUserStore((s) => s.profile);
const clearUser = useUserStore((s) => s.clearUser);
const setDisconnected = useWalletStore((s) => s.setDisconnected);
const [copied, setCopied] = useState(false);
const [isSigningOut, setIsSigningOut] = useState(false);

const [biometricsEnabled, setBiometricsEnabled] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
Expand Down Expand Up @@ -139,9 +137,14 @@ export default function SettingsScreen() {
text: t('settings.signOutConfirm'),
style: 'destructive',
onPress: async () => {
setDisconnected();
clearUser();
await clearAuth();
setIsSigningOut(true);
try {
await signOutService.signOut();
} catch {
// Teardown is best-effort; auth will still be cleared
} finally {
setIsSigningOut(false);
}
},
},
],
Expand Down
22 changes: 15 additions & 7 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,20 @@ function RootLayout() {
clearIdleTimer();
idleTimerRef.current = setTimeout(() => {
if (useAuthStore.getState().isAuthenticated) {
useSecurityStore.getState().lock();
void useSecurityStore.getState().lock();
}
}, IDLE_TIMEOUT_MS);
}, [clearIdleTimer]);

useEffect(() => {
void hydrate();
void useUserStore.getState().hydrate();
async function hydrateAll() {
await Promise.all([
hydrate(),
useUserStore.getState().hydrate(),
useSecurityStore.getState().hydrate(),
]);
}
hydrateAll();
initPromise.then(() => setI18nReady(true));
}, [hydrate]);

Expand All @@ -105,15 +111,15 @@ function RootLayout() {
useEffect(() => {
if (!isLoading && isAuthenticated && !biometricCheckDone) {
useSecurityStore.getState().markBiometricCheckDone();
biometricService.isBiometricsEnabled().then((enabled) => {
biometricService.isBiometricsEnabled().then(async (enabled) => {
if (enabled) {
useSecurityStore.getState().lock();
await useSecurityStore.getState().lock();
}
});
}

if (!isAuthenticated) {
useSecurityStore.getState().reset();
void useSecurityStore.getState().reset();
}
}, [isLoading, isAuthenticated, biometricCheckDone]);

Expand All @@ -127,14 +133,16 @@ function RootLayout() {
elapsed >= IDLE_TIMEOUT_MS &&
useAuthStore.getState().isAuthenticated
) {
useSecurityStore.getState().lock();
void useSecurityStore.getState().lock();
}
if (!useSecurityStore.getState().isLocked) {
startIdleTimer();
}
} else if (state === 'background' || state === 'inactive') {
lastActiveRef.current = Date.now();
clearIdleTimer();
// Persist lastActiveAt so kill-during-background is detected on next cold start
void useSecurityStore.getState().recordActivity();
}
});

Expand Down
19 changes: 19 additions & 0 deletions jest.unit.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** @type {import('jest').Config} */
module.exports = {
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json',
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testEnvironment: 'node',
testMatch: [
'**/src/security/__tests__/**/*.test.ts',
'**/services/__tests__/**/*.test.ts',
],
};
129 changes: 129 additions & 0 deletions services/__tests__/sign-out.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// ─── Mock all store modules ──────────────────────────────────────────
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
const mockClearAuth = jest.fn().mockResolvedValue(undefined);
const mockClearUser = jest.fn();
const mockClearLoans = jest.fn();
const mockSecurityReset = jest.fn().mockResolvedValue(undefined);
const mockDisableBiometrics = jest.fn().mockResolvedValue(undefined);

jest.mock('../../stores/wallet.store', () => ({
useWalletStore: {
getState: () => ({ disconnect: mockDisconnect }),
},
}));

jest.mock('../../stores/auth.store', () => ({
useAuthStore: {
getState: () => ({ clearAuth: mockClearAuth }),
},
}));

jest.mock('../../stores/user.store', () => ({
useUserStore: {
getState: () => ({ clearUser: mockClearUser }),
},
}));

jest.mock('../../stores/loans.store', () => ({
useLoansStore: {
getState: () => ({ clearLoans: mockClearLoans }),
},
}));

jest.mock('../../src/security/security.store', () => ({
useSecurityStore: {
getState: () => ({ reset: mockSecurityReset }),
},
}));

jest.mock('../../src/security/biometric.service', () => ({
biometricService: {
disableBiometrics: mockDisableBiometrics,
},
}));

import { signOutService } from '../sign-out.service';

beforeEach(() => {
jest.clearAllMocks();
});

describe('signOutService.signOut', () => {
it('calls disconnect on wallet store', async () => {
await signOutService.signOut();
expect(mockDisconnect).toHaveBeenCalledTimes(1);
});

it('clears auth tokens', async () => {
await signOutService.signOut();
expect(mockClearAuth).toHaveBeenCalledTimes(1);
});

it('clears user profile', async () => {
await signOutService.signOut();
expect(mockClearUser).toHaveBeenCalledTimes(1);
});

it('clears loans', async () => {
await signOutService.signOut();
expect(mockClearLoans).toHaveBeenCalledTimes(1);
});

it('resets security store', async () => {
await signOutService.signOut();
expect(mockSecurityReset).toHaveBeenCalledTimes(1);
});

it('disables biometrics', async () => {
await signOutService.signOut();
expect(mockDisableBiometrics).toHaveBeenCalledTimes(1);
});

it('calls all teardown steps in order', async () => {
const callOrder: string[] = [];
mockDisconnect.mockImplementation(async () => { callOrder.push('disconnect'); });
mockClearAuth.mockImplementation(async () => { callOrder.push('clearAuth'); });
mockClearUser.mockImplementation(() => { callOrder.push('clearUser'); });
mockClearLoans.mockImplementation(() => { callOrder.push('clearLoans'); });
mockSecurityReset.mockImplementation(async () => { callOrder.push('securityReset'); });
mockDisableBiometrics.mockImplementation(async () => { callOrder.push('disableBiometrics'); });

await signOutService.signOut();

expect(callOrder).toEqual([
'disconnect',
'clearAuth',
'clearUser',
'clearLoans',
'securityReset',
'disableBiometrics',
]);
});

it('completes teardown even if WC disconnect throws', async () => {
mockDisconnect.mockRejectedValueOnce(new Error('WC session expired'));

await signOutService.signOut();

// All subsequent steps should still be called
expect(mockClearAuth).toHaveBeenCalledTimes(1);
expect(mockClearUser).toHaveBeenCalledTimes(1);
expect(mockClearLoans).toHaveBeenCalledTimes(1);
expect(mockSecurityReset).toHaveBeenCalledTimes(1);
expect(mockDisableBiometrics).toHaveBeenCalledTimes(1);
});

it('completes teardown even if clearAuth throws', async () => {
mockClearAuth.mockRejectedValueOnce(new Error('Storage error'));

await signOutService.signOut();

expect(mockClearUser).toHaveBeenCalledTimes(1);
expect(mockSecurityReset).toHaveBeenCalledTimes(1);
});

it('is idempotent — calling twice does not throw', async () => {
await signOutService.signOut();
await expect(signOutService.signOut()).resolves.toBeUndefined();
});
});
61 changes: 61 additions & 0 deletions services/sign-out.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useAuthStore } from '../stores/auth.store';
import { useUserStore } from '../stores/user.store';
import { useWalletStore } from '../stores/wallet.store';
import { useLoansStore } from '../stores/loans.store';
import { useSecurityStore } from '../src/security/security.store';
import { biometricService } from '../src/security/biometric.service';

/**
* Atomic sign-out orchestrator.
*
* Clears every store and disconnects the WalletConnect v2 session
* so no orphaned sessions survive after the user signs out.
*
* Individual failures are caught so the teardown always completes.
* WC disconnect is best-effort; local state wipe is mandatory.
*/
export const signOutService = {
async signOut(): Promise<void> {
// 1. Disconnect WalletConnect v2 session (best-effort)
try {
await useWalletStore.getState().disconnect();
} catch {
// Session may already be gone — safe to ignore
}

// 2. Clear auth tokens from secure storage
try {
await useAuthStore.getState().clearAuth();
} catch {
// Best-effort
}

// 3. Clear user profile
try {
useUserStore.getState().clearUser();
} catch {
// Best-effort
}

// 4. Clear loans
try {
useLoansStore.getState().clearLoans();
} catch {
// Best-effort
}

// 5. Reset security state (wipes persisted lock / attempt counters)
try {
await useSecurityStore.getState().reset();
} catch {
// Best-effort
}

// 6. Clear biometric / PIN preferences
try {
await biometricService.disableBiometrics();
} catch {
// Best-effort
}
},
};
Loading
Loading