diff --git a/nftopia-mobile-app/package-lock.json b/nftopia-mobile-app/package-lock.json index 660ad93..dbb74d6 100644 --- a/nftopia-mobile-app/package-lock.json +++ b/nftopia-mobile-app/package-lock.json @@ -8,6 +8,7 @@ "name": "nftopia-mobile-app", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "^3.0.2", "axios": "^1.13.6", "expo": "~53.0.17", "expo-crypto": "^55.0.10", @@ -16,7 +17,8 @@ "react": "19.0.0", "react-native": "0.79.5", "stellar-hd-wallet": "^1.0.2", - "stellar-sdk": "^12.3.0" + "stellar-sdk": "^12.3.0", + "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -3797,6 +3799,19 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz", + "integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==", + "license": "MIT", + "dependencies": { + "idb": "8.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.79.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", @@ -7161,6 +7176,12 @@ "node": ">=10.17.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -14785,6 +14806,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/nftopia-mobile-app/package.json b/nftopia-mobile-app/package.json index 6ddb7e1..f37e31d 100644 --- a/nftopia-mobile-app/package.json +++ b/nftopia-mobile-app/package.json @@ -10,6 +10,7 @@ "test": "jest" }, "dependencies": { + "@react-native-async-storage/async-storage": "^3.0.2", "axios": "^1.13.6", "expo": "~53.0.17", "expo-crypto": "^55.0.10", @@ -18,7 +19,8 @@ "react": "19.0.0", "react-native": "0.79.5", "stellar-hd-wallet": "^1.0.2", - "stellar-sdk": "^12.3.0" + "stellar-sdk": "^12.3.0", + "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/nftopia-mobile-app/src/stores/__tests__/authStore.test.ts b/nftopia-mobile-app/src/stores/__tests__/authStore.test.ts new file mode 100644 index 0000000..347d563 --- /dev/null +++ b/nftopia-mobile-app/src/stores/__tests__/authStore.test.ts @@ -0,0 +1,311 @@ +import { Keypair } from 'stellar-sdk'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const asyncStorageStore: Record = {}; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn((key: string) => Promise.resolve(asyncStorageStore[key] ?? null)), + setItem: jest.fn((key: string, value: string) => { + asyncStorageStore[key] = value; + return Promise.resolve(); + }), + removeItem: jest.fn((key: string) => { + delete asyncStorageStore[key]; + return Promise.resolve(); + }), + mergeItem: jest.fn(), + clear: jest.fn(() => { + Object.keys(asyncStorageStore).forEach((k) => delete asyncStorageStore[k]); + return Promise.resolve(); + }), + getAllKeys: jest.fn(() => Promise.resolve(Object.keys(asyncStorageStore))), + multiGet: jest.fn(), + multiSet: jest.fn(), + multiRemove: jest.fn(), +})); + +const secureStoreData: Record = {}; + +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn((key: string, value: string) => { + secureStoreData[key] = value; + return Promise.resolve(); + }), + getItemAsync: jest.fn((key: string) => Promise.resolve(secureStoreData[key] ?? null)), + deleteItemAsync: jest.fn((key: string) => { + delete secureStoreData[key]; + return Promise.resolve(); + }), +})); + +jest.mock('expo-crypto', () => ({ + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digestStringAsync: jest.fn().mockResolvedValue('mockedhash'), +})); + +// ── Imports (after mocks) ───────────────────────────────────────────────────── + +import { useAuthStore } from '../authStore'; +import { Wallet } from '../../services/stellar/types'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeWallet = (): Wallet => { + const kp = Keypair.random(); + return { publicKey: kp.publicKey(), secretKey: kp.secret() }; +}; + +function getStore() { + return useAuthStore.getState(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useAuthStore', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + wallet: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + Object.keys(secureStoreData).forEach((k) => delete secureStoreData[k]); + Object.keys(asyncStorageStore).forEach((k) => delete asyncStorageStore[k]); + }); + + // ── Initial state ─────────────────────────────────────────────────────────── + + describe('initial state', () => { + it('has the correct default values', () => { + const { user, wallet, isAuthenticated, isLoading, error } = getStore(); + expect(user).toBeNull(); + expect(wallet).toBeNull(); + expect(isAuthenticated).toBe(false); + expect(isLoading).toBe(false); + expect(error).toBeNull(); + }); + }); + + // ── Simple setters ────────────────────────────────────────────────────────── + + describe('simple setters', () => { + it('setUser updates the user field', () => { + const user = { id: '1', email: 'a@b.com', username: 'alice' }; + getStore().setUser(user); + expect(getStore().user).toEqual(user); + }); + + it('setUser accepts null', () => { + getStore().setUser({ id: '1', email: 'a@b.com', username: 'alice' }); + getStore().setUser(null); + expect(getStore().user).toBeNull(); + }); + + it('setWallet updates the wallet field', () => { + const wallet = makeWallet(); + getStore().setWallet(wallet); + expect(getStore().wallet).toEqual(wallet); + }); + + it('setWallet accepts null', () => { + getStore().setWallet(makeWallet()); + getStore().setWallet(null); + expect(getStore().wallet).toBeNull(); + }); + + it('setAuthenticated toggles isAuthenticated', () => { + getStore().setAuthenticated(true); + expect(getStore().isAuthenticated).toBe(true); + getStore().setAuthenticated(false); + expect(getStore().isAuthenticated).toBe(false); + }); + + it('setLoading toggles isLoading', () => { + getStore().setLoading(true); + expect(getStore().isLoading).toBe(true); + getStore().setLoading(false); + expect(getStore().isLoading).toBe(false); + }); + + it('setError stores the error message', () => { + getStore().setError('something went wrong'); + expect(getStore().error).toBe('something went wrong'); + }); + + it('clearError resets error to null', () => { + getStore().setError('oops'); + getStore().clearError(); + expect(getStore().error).toBeNull(); + }); + }); + + // ── loginWithWallet ───────────────────────────────────────────────────────── + + describe('loginWithWallet', () => { + it('sets wallet and isAuthenticated on success', async () => { + const wallet = makeWallet(); + await getStore().loginWithWallet(wallet); + + const { wallet: storedWallet, isAuthenticated, isLoading, error } = getStore(); + expect(storedWallet).toEqual(wallet); + expect(isAuthenticated).toBe(true); + expect(isLoading).toBe(false); + expect(error).toBeNull(); + }); + + it('saves the wallet to secure storage', async () => { + const SecureStore = require('expo-secure-store'); + const wallet = makeWallet(); + await getStore().loginWithWallet(wallet); + expect(SecureStore.setItemAsync).toHaveBeenCalled(); + }); + + it('sets error when storage fails', async () => { + const SecureStore = require('expo-secure-store'); + SecureStore.setItemAsync.mockRejectedValueOnce(new Error('storage failure')); + + const wallet = makeWallet(); + await getStore().loginWithWallet(wallet); + + expect(getStore().error).toBe('Failed to save wallet: storage failure'); + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().isLoading).toBe(false); + }); + + it('does not run if isLoading is true', async () => { + const SecureStore = require('expo-secure-store'); + SecureStore.setItemAsync.mockClear(); + useAuthStore.setState({ isLoading: true }); + + await getStore().loginWithWallet(makeWallet()); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + }); + }); + + // ── loginWithEmail ────────────────────────────────────────────────────────── + + describe('loginWithEmail', () => { + it('sets an error because the service is not yet implemented', async () => { + await getStore().loginWithEmail('user@example.com', 'password123'); + expect(getStore().error).toBe('Email login not yet implemented'); + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().isLoading).toBe(false); + }); + + it('does not run if isLoading is true', async () => { + useAuthStore.setState({ isLoading: true }); + const before = getStore().error; + await getStore().loginWithEmail('user@example.com', 'password123'); + expect(getStore().error).toBe(before); + }); + }); + + // ── registerWithEmail ─────────────────────────────────────────────────────── + + describe('registerWithEmail', () => { + it('sets an error because the service is not yet implemented', async () => { + await getStore().registerWithEmail('user@example.com', 'password123', 'alice'); + expect(getStore().error).toBe('Email registration not yet implemented'); + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().isLoading).toBe(false); + }); + + it('does not run if isLoading is true', async () => { + useAuthStore.setState({ isLoading: true }); + const before = getStore().error; + await getStore().registerWithEmail('user@example.com', 'password123', 'alice'); + expect(getStore().error).toBe(before); + }); + }); + + // ── logout ────────────────────────────────────────────────────────────────── + + describe('logout', () => { + it('clears user, wallet and isAuthenticated', async () => { + useAuthStore.setState({ + user: { id: '1', email: 'a@b.com', username: 'alice' }, + wallet: makeWallet(), + isAuthenticated: true, + }); + + await getStore().logout(); + + const { user, wallet, isAuthenticated, isLoading } = getStore(); + expect(user).toBeNull(); + expect(wallet).toBeNull(); + expect(isAuthenticated).toBe(false); + expect(isLoading).toBe(false); + }); + + it('deletes wallet from secure storage', async () => { + const SecureStore = require('expo-secure-store'); + secureStoreData['nftopia_wallet'] = JSON.stringify(makeWallet()); + + await getStore().logout(); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('nftopia_wallet'); + }); + + it('removes auth token from AsyncStorage', async () => { + const AsyncStorage = require('@react-native-async-storage/async-storage'); + asyncStorageStore['nftopia_auth_token'] = 'some-token'; + + await getStore().logout(); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('nftopia_auth_token'); + }); + + it('still clears state even when storage throws', async () => { + const SecureStore = require('expo-secure-store'); + SecureStore.deleteItemAsync.mockRejectedValueOnce(new Error('delete failed')); + + useAuthStore.setState({ isAuthenticated: true, wallet: makeWallet() }); + await getStore().logout(); + + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().wallet).toBeNull(); + }); + }); + + // ── checkAuth ─────────────────────────────────────────────────────────────── + + describe('checkAuth', () => { + it('returns false and sets isAuthenticated false when nothing is stored', async () => { + const result = await getStore().checkAuth(); + expect(result).toBe(false); + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().isLoading).toBe(false); + }); + + it('returns true and sets isAuthenticated when auth token exists', async () => { + asyncStorageStore['nftopia_auth_token'] = 'valid-token'; + + const result = await getStore().checkAuth(); + expect(result).toBe(true); + expect(getStore().isAuthenticated).toBe(true); + expect(getStore().isLoading).toBe(false); + }); + + it('returns true and restores wallet when wallet is stored but no token', async () => { + const wallet = makeWallet(); + secureStoreData['nftopia_wallet'] = JSON.stringify(wallet); + + const result = await getStore().checkAuth(); + expect(result).toBe(true); + expect(getStore().isAuthenticated).toBe(true); + expect(getStore().wallet).toEqual(wallet); + expect(getStore().isLoading).toBe(false); + }); + + it('returns false and sets error when storage throws', async () => { + const AsyncStorage = require('@react-native-async-storage/async-storage'); + AsyncStorage.getItem.mockRejectedValueOnce(new Error('read error')); + + const result = await getStore().checkAuth(); + expect(result).toBe(false); + expect(getStore().isAuthenticated).toBe(false); + expect(getStore().error).toBeTruthy(); + expect(getStore().isLoading).toBe(false); + }); + }); +}); diff --git a/nftopia-mobile-app/src/stores/authStore.ts b/nftopia-mobile-app/src/stores/authStore.ts new file mode 100644 index 0000000..cfb21e4 --- /dev/null +++ b/nftopia-mobile-app/src/stores/authStore.ts @@ -0,0 +1,131 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Wallet } from '../services/stellar/types'; +import { SecureStorage } from '../services/stellar/secureStorage'; +import { AuthState, User } from './types'; + +const secureStorage = new SecureStorage(); + +const AUTH_TOKEN_KEY = 'nftopia_auth_token'; + +export const useAuthStore = create()( + persist( + (set, get) => ({ + // Initial state + user: null, + wallet: null, + isAuthenticated: false, + isLoading: false, + error: null, + + // Simple setters + setUser: (user) => set({ user }), + setWallet: (wallet) => set({ wallet }), + setAuthenticated: (value) => set({ isAuthenticated: value }), + setLoading: (value) => set({ isLoading: value }), + setError: (error) => set({ error }), + clearError: () => set({ error: null }), + + // Login with email and password + loginWithEmail: async (email, password) => { + if (get().isLoading) return; + set({ isLoading: true, error: null }); + try { + // TODO: replace with real auth service call when available + // const { user, token } = await authService.loginWithEmail(email, password); + // await AsyncStorage.setItem(AUTH_TOKEN_KEY, token); + // set({ user, isAuthenticated: true }); + throw new Error('Email login not yet implemented'); + } catch (err) { + set({ error: (err as Error).message }); + } finally { + set({ isLoading: false }); + } + }, + + // Login with an existing Stellar wallet + loginWithWallet: async (wallet: Wallet) => { + if (get().isLoading) return; + set({ isLoading: true, error: null }); + try { + await secureStorage.saveWallet(wallet); + set({ wallet, isAuthenticated: true }); + } catch (err) { + set({ error: (err as Error).message }); + } finally { + set({ isLoading: false }); + } + }, + + // Register a new account with email and password + registerWithEmail: async (email, password, username) => { + if (get().isLoading) return; + set({ isLoading: true, error: null }); + try { + // TODO: replace with real auth service call when available + // const { user, token } = await authService.register(email, password, username); + // await AsyncStorage.setItem(AUTH_TOKEN_KEY, token); + // set({ user, isAuthenticated: true }); + throw new Error('Email registration not yet implemented'); + } catch (err) { + set({ error: (err as Error).message }); + } finally { + set({ isLoading: false }); + } + }, + + // Logout: clear all auth state and stored credentials + logout: async () => { + set({ isLoading: true, error: null }); + try { + await AsyncStorage.removeItem(AUTH_TOKEN_KEY); + await secureStorage.deleteWallet(); + } catch { + // Ignore storage errors on logout to ensure state is always cleared + } finally { + set({ user: null, wallet: null, isAuthenticated: false, isLoading: false }); + } + }, + + // Check if a valid auth session exists (wallet or token) + checkAuth: async () => { + set({ isLoading: true, error: null }); + try { + const token = await AsyncStorage.getItem(AUTH_TOKEN_KEY); + if (token) { + // TODO: validate token with auth service when available + // const user = await authService.validateToken(token); + // set({ user, isAuthenticated: true }); + set({ isAuthenticated: true }); + return true; + } + + const hasWallet = await secureStorage.hasWallet(); + if (hasWallet) { + const wallet = await secureStorage.getWallet(); + set({ wallet, isAuthenticated: true }); + return true; + } + + set({ isAuthenticated: false }); + return false; + } catch (err) { + set({ error: (err as Error).message, isAuthenticated: false }); + return false; + } finally { + set({ isLoading: false }); + } + }, + }), + { + name: 'nftopia-auth-storage', + storage: createJSONStorage(() => AsyncStorage), + // Only persist non-sensitive state; credentials are managed by SecureStorage + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + }, + ), +); diff --git a/nftopia-mobile-app/src/stores/types.ts b/nftopia-mobile-app/src/stores/types.ts new file mode 100644 index 0000000..7d886d9 --- /dev/null +++ b/nftopia-mobile-app/src/stores/types.ts @@ -0,0 +1,31 @@ +import { Wallet } from '../services/stellar/types'; + +export interface User { + id: string; + email: string; + username: string; +} + +export interface AuthState { + // State + user: User | null; + wallet: Wallet | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + + // Simple setters + setUser: (user: User | null) => void; + setWallet: (wallet: Wallet | null) => void; + setAuthenticated: (value: boolean) => void; + setLoading: (value: boolean) => void; + setError: (error: string | null) => void; + clearError: () => void; + + // Complex actions + loginWithEmail: (email: string, password: string) => Promise; + loginWithWallet: (wallet: Wallet) => Promise; + registerWithEmail: (email: string, password: string, username: string) => Promise; + logout: () => Promise; + checkAuth: () => Promise; +}