diff --git a/App.tsx b/App.tsx index a8a228b..4deb0d3 100644 --- a/App.tsx +++ b/App.tsx @@ -1,14 +1,29 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; -import InvestScreen from 'components/pages/InvestScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { MainLayout } from 'components/shared/MainLayout'; +import InvestScreen from 'components/pages/InvestScreen'; +import CreateAccountScreen from 'components/pages/CreateAccountScreen'; +import PayScreen from 'components/pages/PayScreen'; +import { RootStackParamList } from 'types/Navigation'; import './global.css'; +const Stack = createNativeStackNavigator(); + export default function App() { return ( - - - + + + + + + + + + ); -} +} \ No newline at end of file diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..947dde4 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,52 @@ +/** + * Auth API module + * Covers: POST /auth/nonce and POST /auth/verify + */ + +import { apiRequest } from '../lib/apiClient'; + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +export interface NonceRequestDto { + wallet: string; +} + +export interface NonceResponseDto { + nonce: string; + expiresAt: string; // ISO-8601 datetime string +} + +export interface VerifyRequestDto { + wallet: string; + nonce: string; + signature: string; +} + +export interface VerifyResponseDto { + accessToken: string; + refreshToken: string; +} + +// ─── API calls ─────────────────────────────────────────────────────────────── + +/** + * Step 1 — Request a nonce for the given wallet address. + * Public endpoint, no auth header needed. + */ +export async function fetchNonce(wallet: string): Promise { + return apiRequest('POST', '/auth/nonce', { + authenticated: false, + body: { wallet } satisfies NonceRequestDto, + }); +} + +/** + * Step 2 — Submit the signed nonce to authenticate (register or login). + * Returns accessToken and refreshToken on success. + */ +export async function verifySignature(dto: VerifyRequestDto): Promise { + return apiRequest('POST', '/auth/verify', { + authenticated: false, + body: dto, + }); +} \ No newline at end of file diff --git a/api/users.ts b/api/users.ts new file mode 100644 index 0000000..e3e172e --- /dev/null +++ b/api/users.ts @@ -0,0 +1,42 @@ +/** + * Users API module + * Covers: GET /users/me and PATCH /users/me + */ + +import { apiRequest } from '../lib/apiClient'; + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +export interface UserProfile { + id: string; + wallet: string; + username: string; + displayName: string; + profileImage: string | null; +} + +export interface UpdateProfileDto { + username?: string; + displayName?: string; + profileImage?: string | null; +} + +// ─── API calls ─────────────────────────────────────────────────────────────── + +/** + * Fetch the authenticated user's profile. + * Requires a valid access token (attached automatically by apiRequest). + */ +export async function getMe(): Promise { + return apiRequest('GET', '/users/me', { authenticated: true }); +} + +/** + * Update the authenticated user's profile with the provided fields. + */ +export async function updateMe(dto: UpdateProfileDto): Promise { + return apiRequest('PATCH', '/users/me', { + authenticated: true, + body: dto, + }); +} \ No newline at end of file diff --git a/components/pages/CreateAccountScreen.tsx b/components/pages/CreateAccountScreen.tsx index 0c3a7e2..e3e172e 100644 --- a/components/pages/CreateAccountScreen.tsx +++ b/components/pages/CreateAccountScreen.tsx @@ -1,208 +1,42 @@ -import React from 'react'; -import { View, Text, TextInput, TouchableOpacity, Image, ScrollView } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useCreateAccount } from '../../hooks/auth/use-create-account'; -// Centralized color palette shared with Tailwind -const colors = require('../../theme/colors.json'); - -const CreateAccountScreen = ({ navigation }: any) => { - const { - formState, - errors, - isSubmitting, - showSuccess, - handleWalletAddressChange, - handleUsernameChange, - handleDisplayNameChange, - handleTermsAcceptedChange, - pickImage, - createAccount, - isFormValid, - } = useCreateAccount(); - - const { profileImage, walletAddress, username, displayName, termsAccepted } = formState; - - return ( - - {/* Header */} - - navigation?.goBack()} - className="mr-3" - accessibilityLabel="Go back"> - - - Create Account - - - - - {/* Profile Picture Upload */} - - - - {profileImage ? ( - - ) : ( - - )} - - - - - - Upload profile picture (optional) - {errors.profileImage ? ( - {errors.profileImage} - ) : null} - - - {/* Wallet Address Input */} - - Wallet Address - - - - - {errors.walletAddress ? ( - {errors.walletAddress} - ) : null} - - - {/* Username Input */} - - Username - - - @ - - - {errors.username ? ( - {errors.username} - ) : null} - - - {/* Display Name Input */} - - Display Name - - - - - {errors.displayName ? ( - {errors.displayName} - ) : null} - - - {/* Info Box */} - - - - - - - Your wallet is your identity - - - Make sure you have access to your wallet address. This will be used to verify your - transactions and build your reputation. - - - - - {/* Terms & Conditions Checkbox */} - handleTermsAcceptedChange(!termsAccepted)} - className="mb-6 flex-row items-start" - activeOpacity={0.7} - accessibilityLabel="Accept terms and conditions" - accessibilityRole="checkbox"> - - {termsAccepted && } - - - By creating an account, you agree to our{' '} - Terms of Service and{' '} - Privacy Policy - - - - {/* Create Account Button */} - - {isSubmitting ? ( - <> - Creating Account... - - - ) : ( - <> - - Create Account - - - - )} - - - - - {/* Success Notification */} - {showSuccess && ( - - - - - - - Account Created Successfully! - - Welcome to TrustUp, @{username} - - - )} - - ); -}; - -export default CreateAccountScreen; +/** + * Users API module + * Covers: GET /users/me and PATCH /users/me + */ + +import { apiRequest } from '../lib/apiClient'; + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +export interface UserProfile { + id: string; + wallet: string; + username: string; + displayName: string; + profileImage: string | null; +} + +export interface UpdateProfileDto { + username?: string; + displayName?: string; + profileImage?: string | null; +} + +// ─── API calls ─────────────────────────────────────────────────────────────── + +/** + * Fetch the authenticated user's profile. + * Requires a valid access token (attached automatically by apiRequest). + */ +export async function getMe(): Promise { + return apiRequest('GET', '/users/me', { authenticated: true }); +} + +/** + * Update the authenticated user's profile with the provided fields. + */ +export async function updateMe(dto: UpdateProfileDto): Promise { + return apiRequest('PATCH', '/users/me', { + authenticated: true, + body: dto, + }); +} \ No newline at end of file diff --git a/hooks/auth/use-create-account.ts b/hooks/auth/use-create-account.ts index d5a823e..1c63226 100644 --- a/hooks/auth/use-create-account.ts +++ b/hooks/auth/use-create-account.ts @@ -1,10 +1,13 @@ import { useState, useCallback } from 'react'; import { Alert } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; +import { fetchNonce, verifySignature } from '../../api/auth'; +import { updateMe } from '../../api/users'; +import { saveAccessToken, saveRefreshToken } from '../../lib/tokenStorage'; +import { ApiError } from '../../lib/apiClient'; + +// ─── Types ─────────────────────────────────────────────────────────────────── -/** - * Form field state for the create account form - */ export interface CreateAccountFormState { profileImage: string | null; walletAddress: string; @@ -13,9 +16,6 @@ export interface CreateAccountFormState { termsAccepted: boolean; } -/** - * Validation errors for the create account form - */ export interface CreateAccountErrors { walletAddress: string; username: string; @@ -23,9 +23,6 @@ export interface CreateAccountErrors { profileImage: string; } -/** - * Return type for the useCreateAccount hook - */ export interface UseCreateAccountReturn { // Form state formState: CreateAccountFormState; @@ -41,16 +38,15 @@ export interface UseCreateAccountReturn { // Actions pickImage: () => Promise; - createAccount: () => void; + createAccount: () => Promise; resetSuccess: () => void; // Validation isFormValid: () => boolean; } -/** - * Initial form state - */ +// ─── Initial state ──────────────────────────────────────────────────────────── + const initialFormState: CreateAccountFormState = { profileImage: null, walletAddress: '', @@ -59,9 +55,6 @@ const initialFormState: CreateAccountFormState = { termsAccepted: false, }; -/** - * Initial errors state - */ const initialErrors: CreateAccountErrors = { walletAddress: '', username: '', @@ -69,9 +62,11 @@ const initialErrors: CreateAccountErrors = { profileImage: '', }; +// ─── Pure helpers ───────────────────────────────────────────────────────────── + /** - * Validates a Stellar wallet address - * Stellar wallet addresses start with G and are 56 characters long + * Validates a Stellar wallet address. + * Stellar addresses start with G and are 56 characters long. */ export const validateWalletAddress = (address: string): boolean => { const stellarPattern = /^G[A-Z0-9]{55}$/; @@ -79,92 +74,110 @@ export const validateWalletAddress = (address: string): boolean => { }; /** - * Cleans username input to only allow alphanumeric characters and underscores + * Strips characters that are not alphanumeric or underscores. */ export const cleanUsername = (text: string): string => { return text.replace(/[^a-zA-Z0-9_]/g, ''); }; -/** - * Validates image file size (must be less than 2MB) - */ const validateImageSize = async (uri: string): Promise => { try { const response = await fetch(uri); const blob = await response.blob(); - const sizeInMB = blob.size / (1024 * 1024); - return sizeInMB <= 2; + return blob.size / (1024 * 1024) <= 2; } catch { return false; } }; -/** - * Validates image file type (must be JPG, PNG, or WebP) - */ const validateImageType = async (uri: string): Promise => { try { const response = await fetch(uri); const blob = await response.blob(); - const blobType = blob.type.toLowerCase(); const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; - return validTypes.includes(blobType); + return validTypes.includes(blob.type.toLowerCase()); } catch { return false; } }; /** - * Custom hook for Create Account functionality - * Handles all form state, validation, and account creation logic + * Signs a nonce with the given Stellar wallet address. + * + * NOTE: Stellar transaction signing requires access to the wallet's secret key + * or a browser-extension wallet such as Freighter. The exact signing mechanism + * depends on the wallet integration chosen for this app. + * + * Current implementation: returns the nonce string as a placeholder signature + * so the rest of the auth flow can be developed and tested end-to-end. Replace + * this function with the real signing logic once the wallet SDK is integrated + * (e.g. Freighter's signTransaction / signMessage, or StellarSdk.Keypair). + * + * @param walletAddress - The public Stellar wallet address (G…) + * @param nonce - The nonce string returned by POST /auth/nonce + * @returns - A signature string to send to POST /auth/verify */ -export const useCreateAccount = (): UseCreateAccountReturn => { +async function signNonce(walletAddress: string, nonce: string): Promise { + // TODO: replace with real wallet signing logic. + // Example using Freighter: + // import { signMessage } from '@stellar/freighter-api'; + // return signMessage(nonce, walletAddress); + return nonce; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export const useCreateAccount = ( + onSuccess?: () => void +): UseCreateAccountReturn => { const [formState, setFormState] = useState(initialFormState); const [errors, setErrors] = useState(initialErrors); const [isSubmitting, setIsSubmitting] = useState(false); const [showSuccess, setShowSuccess] = useState(false); - // Field change handlers + // ── Field handlers ────────────────────────────────────────────────────────── + const handleWalletAddressChange = useCallback((text: string) => { setFormState((prev) => ({ ...prev, walletAddress: text })); - - if (text && !validateWalletAddress(text)) { - setErrors((prev) => ({ ...prev, walletAddress: 'Invalid Stellar wallet address format' })); - } else { - setErrors((prev) => ({ ...prev, walletAddress: '' })); - } + setErrors((prev) => ({ + ...prev, + walletAddress: + text && !validateWalletAddress(text) ? 'Invalid Stellar wallet address format' : '', + })); }, []); const handleUsernameChange = useCallback((text: string) => { - const cleanedText = cleanUsername(text); - setFormState((prev) => ({ ...prev, username: cleanedText })); - - if (cleanedText.length > 0 && cleanedText.length < 3) { - setErrors((prev) => ({ ...prev, username: 'Username must be at least 3 characters' })); - } else { - setErrors((prev) => ({ ...prev, username: '' })); - } + const cleaned = cleanUsername(text); + setFormState((prev) => ({ ...prev, username: cleaned })); + setErrors((prev) => ({ + ...prev, + username: + cleaned.length > 0 && cleaned.length < 3 + ? 'Username must be at least 3 characters' + : '', + })); }, []); const handleDisplayNameChange = useCallback((text: string) => { setFormState((prev) => ({ ...prev, displayName: text })); - - if (text.length > 0 && text.length < 2) { - setErrors((prev) => ({ ...prev, displayName: 'Display name must be at least 2 characters' })); - } else { - setErrors((prev) => ({ ...prev, displayName: '' })); - } + setErrors((prev) => ({ + ...prev, + displayName: + text.length > 0 && text.length < 2 + ? 'Display name must be at least 2 characters' + : '', + })); }, []); const handleTermsAcceptedChange = useCallback((accepted: boolean) => { setFormState((prev) => ({ ...prev, termsAccepted: accepted })); }, []); - // Image picking with validation + // ── Image picker ──────────────────────────────────────────────────────────── + const pickImage = useCallback(async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - - if (permissionResult.granted === false) { + if (!permissionResult.granted) { Alert.alert('Permission Required', 'Permission to access camera roll is required!'); return; } @@ -176,42 +189,31 @@ export const useCreateAccount = (): UseCreateAccountReturn => { quality: 0.8, }); - if (!result.canceled && result.assets && result.assets[0]) { + if (!result.canceled && result.assets?.[0]) { const imageUri = result.assets[0].uri; - try { - // Validate file size - const isSizeValid = await validateImageSize(imageUri); - if (!isSizeValid) { + if (!(await validateImageSize(imageUri))) { setErrors((prev) => ({ ...prev, profileImage: 'File size must be less than 2MB' })); return; } - - // Validate file type - const isTypeValid = await validateImageType(imageUri); - if (!isTypeValid) { + if (!(await validateImageType(imageUri))) { setErrors((prev) => ({ ...prev, profileImage: 'Only JPG, PNG, and WebP formats are allowed', })); return; } - - // All validations passed setErrors((prev) => ({ ...prev, profileImage: '' })); setFormState((prev) => ({ ...prev, profileImage: imageUri })); - } catch (error) { - console.error('Error processing image:', error); + } catch { setErrors((prev) => ({ ...prev, profileImage: 'Error processing image' })); } } }, []); - // Form validation - const isFormValid = useCallback((): boolean => { - // Profile image is optional, but if there's an error with uploaded image, block submission - const hasImageError = errors.profileImage !== ''; + // ── Validation ────────────────────────────────────────────────────────────── + const isFormValid = useCallback((): boolean => { return ( formState.walletAddress.trim() !== '' && validateWalletAddress(formState.walletAddress) && @@ -221,73 +223,80 @@ export const useCreateAccount = (): UseCreateAccountReturn => { errors.walletAddress === '' && errors.username === '' && errors.displayName === '' && - !hasImageError + errors.profileImage === '' ); }, [formState, errors]); - // Account creation handler - const createAccount = useCallback(() => { - if (isFormValid()) { - setIsSubmitting(true); + // ── Account creation — real API flow ──────────────────────────────────────── - const accountData = { - profileImage: formState.profileImage, - walletAddress: formState.walletAddress, + const createAccount = useCallback(async () => { + if (!isFormValid()) return; + + setIsSubmitting(true); + try { + // 1. Fetch nonce from the backend + const { nonce, expiresAt } = await fetchNonce(formState.walletAddress); + + // Validate nonce expiry client-side + if (new Date(expiresAt) <= new Date()) { + Alert.alert('Session Expired', 'The authentication nonce has expired. Please try again.'); + return; + } + + // 2. Sign the nonce with the wallet + const signature = await signNonce(formState.walletAddress, nonce); + + // 3. Verify signature — registers or logs in the user + const { accessToken, refreshToken } = await verifySignature({ + wallet: formState.walletAddress, + nonce, + signature, + }); + + // 4. Persist tokens securely + await saveAccessToken(accessToken); + await saveRefreshToken(refreshToken); + + // 5. Sync profile data with the backend + await updateMe({ username: formState.username, displayName: formState.displayName, - termsAccepted: formState.termsAccepted, - }; - - console.log('✅ Account Created Successfully:', accountData); + profileImage: formState.profileImage, + }); - // Show success notification + // 6. Show success banner, then navigate setShowSuccess(true); - - // Simulate account creation delay setTimeout(() => { - setIsSubmitting(false); - - Alert.alert( - '✅ Account Created!', - `Welcome, ${formState.displayName}!\n\nYour account has been created successfully.\n\nUsername: @${formState.username}\nWallet: ${formState.walletAddress.substring(0, 10)}...`, - [ - { - text: 'OK', - onPress: () => { - setShowSuccess(false); - console.log('Account creation confirmed'); - }, - }, - ] - ); - }, 500); + setShowSuccess(false); + onSuccess?.(); + }, 1200); + } catch (error) { + const message = + error instanceof ApiError + ? error.message + : 'Something went wrong. Please check your connection and try again.'; + Alert.alert('Account Creation Failed', message); + } finally { + setIsSubmitting(false); } - }, [isFormValid, formState]); + }, [isFormValid, formState, onSuccess]); - // Reset success state - const resetSuccess = useCallback(() => { - setShowSuccess(false); - }, []); + // ── Reset ─────────────────────────────────────────────────────────────────── + + const resetSuccess = useCallback(() => setShowSuccess(false), []); return { - // Form state formState, errors, isSubmitting, showSuccess, - - // Field handlers handleWalletAddressChange, handleUsernameChange, handleDisplayNameChange, handleTermsAcceptedChange, - - // Actions pickImage, createAccount, resetSuccess, - - // Validation isFormValid, }; -}; +}; \ No newline at end of file diff --git a/lib/apiClient.ts b/lib/apiClient.ts new file mode 100644 index 0000000..e752ca3 --- /dev/null +++ b/lib/apiClient.ts @@ -0,0 +1,85 @@ +/** + * Central HTTP client for TrustUp API + * Base URL is read from EXPO_PUBLIC_API_URL (see docs/contributing.md) + */ + +import { getAccessToken } from './tokenStorage'; + +const getBaseUrl = (): string => { + const url = process.env.EXPO_PUBLIC_API_URL; + if (!url) { + throw new Error('EXPO_PUBLIC_API_URL is not defined. Add it to your .env file.'); + } + // Strip trailing slash for safe path joining + return url.replace(/\/$/, ''); +}; + +type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +interface RequestOptions { + /** Whether to attach the Authorization: Bearer header */ + authenticated?: boolean; + body?: unknown; +} + +/** + * Generic API request helper. + * Throws an ApiError for non-2xx responses so callers can catch and display + * user-facing messages. + */ +export async function apiRequest( + method: HttpMethod, + path: string, + options: RequestOptions = {} +): Promise { + const { authenticated = false, body } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authenticated) { + const token = await getAccessToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + const response = await fetch(`${getBaseUrl()}${path}`, { + method, + headers, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + let message = `Request failed with status ${response.status}`; + try { + const errorBody = await response.json(); + message = errorBody?.message ?? message; + } catch { + // Non-JSON error body — keep default message + } + throw new ApiError(message, response.status); + } + + // 204 No Content — return undefined cast to T + if (response.status === 204) { + return undefined as unknown as T; + } + + return response.json() as Promise; +} + +/** + * Typed API error that carries the HTTP status code so the UI can + * distinguish between e.g. 401 Unauthorized and 500 Server Error. + */ +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} \ No newline at end of file diff --git a/lib/tokenStorage.ts b/lib/tokenStorage.ts new file mode 100644 index 0000000..fc1ed5d --- /dev/null +++ b/lib/tokenStorage.ts @@ -0,0 +1,49 @@ +/** + * Secure token storage using expo-secure-store. + * + * expo-secure-store uses the iOS Keychain and Android Keystore under the hood, + * which is the recommended approach for storing sensitive auth tokens in Expo + * apps (never use AsyncStorage for tokens). + * + * Install if not already present: + * npx expo install expo-secure-store + */ + +import * as SecureStore from 'expo-secure-store'; + +const ACCESS_TOKEN_KEY = 'trustup_access_token'; +const REFRESH_TOKEN_KEY = 'trustup_refresh_token'; + +// ─── Access Token ──────────────────────────────────────────────────────────── + +export async function saveAccessToken(token: string): Promise { + await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token); +} + +export async function getAccessToken(): Promise { + return SecureStore.getItemAsync(ACCESS_TOKEN_KEY); +} + +export async function deleteAccessToken(): Promise { + await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY); +} + +// ─── Refresh Token ─────────────────────────────────────────────────────────── + +export async function saveRefreshToken(token: string): Promise { + await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token); +} + +export async function getRefreshToken(): Promise { + return SecureStore.getItemAsync(REFRESH_TOKEN_KEY); +} + +export async function deleteRefreshToken(): Promise { + await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY); +} + +// ─── Clear all auth tokens (e.g. on logout) ────────────────────────────────── + +export async function clearTokens(): Promise { + await Promise.all([deleteAccessToken(), deleteRefreshToken()]); +} \ No newline at end of file