feat(mobile): complete block A auth session flow

This commit is contained in:
saravanakumardb1 2026-03-30 23:56:18 -07:00
parent 8aff92d6b2
commit ae0a481504
4 changed files with 124 additions and 14 deletions

View File

@ -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<string | null>(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 (
<View style={styles.container}>
<Text style={styles.title}>NoteLett</Text>
<Text style={styles.subtitle}>Mobile MVP auth shell</Text>
<Text style={styles.subtitle}>{mode === 'signin' ? 'Sign in to continue' : 'Create your account'}</Text>
{mode === 'register' ? (
<TextInput
autoCapitalize="words"
onChangeText={(value: string) => {
setErrorMessage(null);
setDisplayName(value);
}}
placeholder="Display name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
value={displayName}
/>
) : null}
<TextInput
autoCapitalize="none"
keyboardType="email-address"
@ -41,17 +64,38 @@ export default function AuthScreen() {
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<Pressable
onPress={async () => {
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}
>
<Text style={styles.buttonText}>{isLoading ? 'Signing in…' : 'Continue'}</Text>
<Text style={styles.buttonText}>
{isLoading ? (mode === 'signin' ? 'Signing in…' : 'Creating account…') : mode === 'signin' ? 'Sign in' : 'Register'}
</Text>
</Pressable>
<Pressable
onPress={() => {
setErrorMessage(null);
setMode((current) => (current === 'signin' ? 'register' : 'signin'));
}}
style={styles.modeSwitch}
>
<Text style={styles.modeSwitchText}>
{mode === 'signin' ? 'Need an account? Register' : 'Already have an account? Sign in'}
</Text>
</Pressable>
</View>
);
@ -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',
},
});

View File

@ -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 <Redirect href="/auth" />;
const hasBootstrapped = useAuthStore((state: AuthState) => state.hasBootstrapped);
const isAuthenticated = useAuthStore((state: AuthState) => state.isAuthenticated);
if (!hasBootstrapped) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bgCanvas }}>
<Text style={{ color: colors.textSecondary }}>Checking session</Text>
</View>
);
}
return <Redirect href={isAuthenticated ? '/(tabs)' : '/auth'} />;
}

View File

@ -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();

View File

@ -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<void>;
signIn: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, displayName: string) => Promise<boolean>;
signOut: () => void;
};
export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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 });
},
}));