feat(mobile): complete block A auth session flow
This commit is contained in:
parent
8aff92d6b2
commit
ae0a481504
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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'} />;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user