From ae0a481504cbbf8766648575d051158971eb1f30 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 30 Mar 2026 23:56:18 -0700 Subject: [PATCH] feat(mobile): complete block A auth session flow --- mobile/src/app/auth.tsx | 65 ++++++++++++++++++++++++++--- mobile/src/app/index.tsx | 16 ++++++- mobile/src/store/auth-store.test.ts | 29 ++++++++++++- mobile/src/store/auth-store.ts | 28 ++++++++++--- 4 files changed, 124 insertions(+), 14 deletions(-) diff --git a/mobile/src/app/auth.tsx b/mobile/src/app/auth.tsx index c40fa4c..cd7556b 100644 --- a/mobile/src/app/auth.tsx +++ b/mobile/src/app/auth.tsx @@ -1,20 +1,43 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { router } from 'expo-router'; import { useAuthStore, type AuthState } from '../store/auth-store'; import { colors } from '../theme'; export default function AuthScreen() { + const [mode, setMode] = useState<'signin' | 'register'>('signin'); + const [displayName, setDisplayName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [errorMessage, setErrorMessage] = useState(null); + const isAuthenticated = useAuthStore((state: AuthState) => state.isAuthenticated); const signIn = useAuthStore((state: AuthState) => state.signIn); + const register = useAuthStore((state: AuthState) => state.register); const isLoading = useAuthStore((state: AuthState) => state.isLoading); + useEffect(() => { + if (isAuthenticated) { + router.replace('/(tabs)'); + } + }, [isAuthenticated]); + return ( NoteLett - Mobile MVP auth shell + {mode === 'signin' ? 'Sign in to continue' : 'Create your account'} + {mode === 'register' ? ( + { + setErrorMessage(null); + setDisplayName(value); + }} + placeholder="Display name" + placeholderTextColor={colors.textTertiary} + style={styles.input} + value={displayName} + /> + ) : null} {errorMessage} : null} { - const didSignIn = await signIn(email, password); - if (didSignIn) { + const didAuthenticate = + mode === 'signin' + ? await signIn(email.trim(), password) + : await register(email.trim(), password, displayName.trim()); + + if (didAuthenticate) { router.replace('/(tabs)'); return; } - setErrorMessage('Sign-in failed. Check your credentials or connection and try again.'); + setErrorMessage( + mode === 'signin' + ? 'Sign-in failed. Check your credentials or connection and try again.' + : 'Registration failed. Check your details or connection and try again.', + ); }} style={styles.button} > - {isLoading ? 'Signing in…' : 'Continue'} + + {isLoading ? (mode === 'signin' ? 'Signing in…' : 'Creating account…') : mode === 'signin' ? 'Sign in' : 'Register'} + + + { + setErrorMessage(null); + setMode((current) => (current === 'signin' ? 'register' : 'signin')); + }} + style={styles.modeSwitch} + > + + {mode === 'signin' ? 'Need an account? Register' : 'Already have an account? Sign in'} + ); @@ -99,4 +143,13 @@ const styles = StyleSheet.create({ color: colors.danger, fontSize: 14, }, + modeSwitch: { + alignItems: 'center', + paddingVertical: 4, + }, + modeSwitchText: { + color: colors.accentSecondary, + fontSize: 14, + fontWeight: '600', + }, }); diff --git a/mobile/src/app/index.tsx b/mobile/src/app/index.tsx index ef27a36..7cf9f20 100644 --- a/mobile/src/app/index.tsx +++ b/mobile/src/app/index.tsx @@ -1,5 +1,19 @@ import { Redirect } from 'expo-router'; +import { Text, View } from 'react-native'; +import { useAuthStore, type AuthState } from '../store/auth-store'; +import { colors } from '../theme'; export default function Index(): React.JSX.Element { - return ; + const hasBootstrapped = useAuthStore((state: AuthState) => state.hasBootstrapped); + const isAuthenticated = useAuthStore((state: AuthState) => state.isAuthenticated); + + if (!hasBootstrapped) { + return ( + + Checking session… + + ); + } + + return ; } diff --git a/mobile/src/store/auth-store.test.ts b/mobile/src/store/auth-store.test.ts index 3df0fe5..7da8368 100644 --- a/mobile/src/store/auth-store.test.ts +++ b/mobile/src/store/auth-store.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const isAuthenticatedMock = vi.fn(); const getMeMock = vi.fn(); const loginMock = vi.fn(); +const registerMock = vi.fn(); const clearTokensMock = vi.fn(); vi.mock('../api/auth', () => ({ @@ -10,6 +11,7 @@ vi.mock('../api/auth', () => ({ isAuthenticated: isAuthenticatedMock, getMe: getMeMock, login: loginMock, + register: registerMock, clearTokens: clearTokensMock, getAccessToken: vi.fn(() => null), }), @@ -19,6 +21,7 @@ import { useAuthStore } from './auth-store'; function resetStore() { useAuthStore.setState({ + hasBootstrapped: false, isAuthenticated: false, isLoading: false, email: null, @@ -35,6 +38,7 @@ describe('useAuthStore', () => { isAuthenticatedMock.mockReturnValue(true); getMeMock.mockResolvedValueOnce({ email: 'test@example.com' }); await useAuthStore.getState().bootstrap(); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(true); expect(useAuthStore.getState().email).toBe('test@example.com'); }); @@ -42,6 +46,7 @@ describe('useAuthStore', () => { it('bootstrap sets unauthenticated when no token', async () => { isAuthenticatedMock.mockReturnValue(false); await useAuthStore.getState().bootstrap(); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(false); expect(useAuthStore.getState().email).toBeNull(); }); @@ -51,6 +56,7 @@ describe('useAuthStore', () => { getMeMock.mockRejectedValueOnce(new Error('expired')); await useAuthStore.getState().bootstrap(); expect(clearTokensMock).toHaveBeenCalled(); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(false); }); @@ -58,6 +64,7 @@ describe('useAuthStore', () => { loginMock.mockResolvedValueOnce(undefined); const ok = await useAuthStore.getState().signIn('test@example.com', 'pass'); expect(ok).toBe(true); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(true); expect(useAuthStore.getState().email).toBe('test@example.com'); }); @@ -66,13 +73,33 @@ describe('useAuthStore', () => { loginMock.mockRejectedValueOnce(new Error('bad credentials')); const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong'); expect(ok).toBe(false); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(clearTokensMock).toHaveBeenCalled(); + }); + + it('register sets authenticated on success', async () => { + registerMock.mockResolvedValueOnce(undefined); + const ok = await useAuthStore.getState().register('new@example.com', 'pass', 'New User'); + expect(ok).toBe(true); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(useAuthStore.getState().email).toBe('new@example.com'); + }); + + it('register returns false on failure', async () => { + registerMock.mockRejectedValueOnce(new Error('register failed')); + const ok = await useAuthStore.getState().register('bad@example.com', 'wrong', 'Bad'); + expect(ok).toBe(false); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(false); expect(clearTokensMock).toHaveBeenCalled(); }); it('signOut clears state', () => { - useAuthStore.setState({ isAuthenticated: true, email: 'test@example.com' }); + useAuthStore.setState({ hasBootstrapped: true, isAuthenticated: true, email: 'test@example.com' }); useAuthStore.getState().signOut(); + expect(useAuthStore.getState().hasBootstrapped).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(false); expect(useAuthStore.getState().email).toBeNull(); expect(clearTokensMock).toHaveBeenCalled(); diff --git a/mobile/src/store/auth-store.ts b/mobile/src/store/auth-store.ts index 2eba72f..6b37b4a 100644 --- a/mobile/src/store/auth-store.ts +++ b/mobile/src/store/auth-store.ts @@ -2,22 +2,25 @@ import { create } from 'zustand'; import { getAuthClient } from '../api/auth'; export type AuthState = { + hasBootstrapped: boolean; isAuthenticated: boolean; isLoading: boolean; email: string | null; bootstrap: () => Promise; signIn: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; signOut: () => void; }; export const useAuthStore = create((set) => ({ + hasBootstrapped: false, isAuthenticated: false, isLoading: false, email: null, async bootstrap() { const client = getAuthClient(); if (!client.isAuthenticated()) { - set({ isAuthenticated: false, email: null }); + set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false }); return; } @@ -25,10 +28,10 @@ export const useAuthStore = create((set) => ({ try { const me = await client.getMe(); - set({ isAuthenticated: true, email: me.email, isLoading: false }); + set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false }); } catch { client.clearTokens(); - set({ isAuthenticated: false, email: null, isLoading: false }); + set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false }); } }, async signIn(email: string, password: string) { @@ -36,16 +39,29 @@ export const useAuthStore = create((set) => ({ try { await getAuthClient().login(email, password); - set({ isAuthenticated: true, email, isLoading: false }); + set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false }); return true; } catch { getAuthClient().clearTokens(); - set({ isAuthenticated: false, email: null, isLoading: false }); + set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false }); + return false; + } + }, + async register(email: string, password: string, displayName: string) { + set({ isLoading: true }); + + try { + await getAuthClient().register(email, password, displayName); + set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false }); + return true; + } catch { + getAuthClient().clearTokens(); + set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false }); return false; } }, signOut() { getAuthClient().clearTokens(); - set({ isAuthenticated: false, email: null, isLoading: false }); + set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false }); }, }));