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 { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useAuthStore, type AuthState } from '../store/auth-store';
|
import { useAuthStore, type AuthState } from '../store/auth-store';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
|
||||||
export default function AuthScreen() {
|
export default function AuthScreen() {
|
||||||
|
const [mode, setMode] = useState<'signin' | 'register'>('signin');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const isAuthenticated = useAuthStore((state: AuthState) => state.isAuthenticated);
|
||||||
const signIn = useAuthStore((state: AuthState) => state.signIn);
|
const signIn = useAuthStore((state: AuthState) => state.signIn);
|
||||||
|
const register = useAuthStore((state: AuthState) => state.register);
|
||||||
const isLoading = useAuthStore((state: AuthState) => state.isLoading);
|
const isLoading = useAuthStore((state: AuthState) => state.isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>NoteLett</Text>
|
<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
|
<TextInput
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
@ -41,17 +64,38 @@ export default function AuthScreen() {
|
|||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const didSignIn = await signIn(email, password);
|
const didAuthenticate =
|
||||||
if (didSignIn) {
|
mode === 'signin'
|
||||||
|
? await signIn(email.trim(), password)
|
||||||
|
: await register(email.trim(), password, displayName.trim());
|
||||||
|
|
||||||
|
if (didAuthenticate) {
|
||||||
router.replace('/(tabs)');
|
router.replace('/(tabs)');
|
||||||
return;
|
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}
|
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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -99,4 +143,13 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.danger,
|
color: colors.danger,
|
||||||
fontSize: 14,
|
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 { 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 {
|
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 isAuthenticatedMock = vi.fn();
|
||||||
const getMeMock = vi.fn();
|
const getMeMock = vi.fn();
|
||||||
const loginMock = vi.fn();
|
const loginMock = vi.fn();
|
||||||
|
const registerMock = vi.fn();
|
||||||
const clearTokensMock = vi.fn();
|
const clearTokensMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../api/auth', () => ({
|
vi.mock('../api/auth', () => ({
|
||||||
@ -10,6 +11,7 @@ vi.mock('../api/auth', () => ({
|
|||||||
isAuthenticated: isAuthenticatedMock,
|
isAuthenticated: isAuthenticatedMock,
|
||||||
getMe: getMeMock,
|
getMe: getMeMock,
|
||||||
login: loginMock,
|
login: loginMock,
|
||||||
|
register: registerMock,
|
||||||
clearTokens: clearTokensMock,
|
clearTokens: clearTokensMock,
|
||||||
getAccessToken: vi.fn(() => null),
|
getAccessToken: vi.fn(() => null),
|
||||||
}),
|
}),
|
||||||
@ -19,6 +21,7 @@ import { useAuthStore } from './auth-store';
|
|||||||
|
|
||||||
function resetStore() {
|
function resetStore() {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
|
hasBootstrapped: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
email: null,
|
email: null,
|
||||||
@ -35,6 +38,7 @@ describe('useAuthStore', () => {
|
|||||||
isAuthenticatedMock.mockReturnValue(true);
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
getMeMock.mockResolvedValueOnce({ email: 'test@example.com' });
|
getMeMock.mockResolvedValueOnce({ email: 'test@example.com' });
|
||||||
await useAuthStore.getState().bootstrap();
|
await useAuthStore.getState().bootstrap();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useAuthStore.getState().email).toBe('test@example.com');
|
expect(useAuthStore.getState().email).toBe('test@example.com');
|
||||||
});
|
});
|
||||||
@ -42,6 +46,7 @@ describe('useAuthStore', () => {
|
|||||||
it('bootstrap sets unauthenticated when no token', async () => {
|
it('bootstrap sets unauthenticated when no token', async () => {
|
||||||
isAuthenticatedMock.mockReturnValue(false);
|
isAuthenticatedMock.mockReturnValue(false);
|
||||||
await useAuthStore.getState().bootstrap();
|
await useAuthStore.getState().bootstrap();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(useAuthStore.getState().email).toBeNull();
|
expect(useAuthStore.getState().email).toBeNull();
|
||||||
});
|
});
|
||||||
@ -51,6 +56,7 @@ describe('useAuthStore', () => {
|
|||||||
getMeMock.mockRejectedValueOnce(new Error('expired'));
|
getMeMock.mockRejectedValueOnce(new Error('expired'));
|
||||||
await useAuthStore.getState().bootstrap();
|
await useAuthStore.getState().bootstrap();
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -58,6 +64,7 @@ describe('useAuthStore', () => {
|
|||||||
loginMock.mockResolvedValueOnce(undefined);
|
loginMock.mockResolvedValueOnce(undefined);
|
||||||
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
|
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useAuthStore.getState().email).toBe('test@example.com');
|
expect(useAuthStore.getState().email).toBe('test@example.com');
|
||||||
});
|
});
|
||||||
@ -66,13 +73,33 @@ describe('useAuthStore', () => {
|
|||||||
loginMock.mockRejectedValueOnce(new Error('bad credentials'));
|
loginMock.mockRejectedValueOnce(new Error('bad credentials'));
|
||||||
const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong');
|
const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong');
|
||||||
expect(ok).toBe(false);
|
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(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signOut clears state', () => {
|
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();
|
useAuthStore.getState().signOut();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(useAuthStore.getState().email).toBeNull();
|
expect(useAuthStore.getState().email).toBeNull();
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
|
|||||||
@ -2,22 +2,25 @@ import { create } from 'zustand';
|
|||||||
import { getAuthClient } from '../api/auth';
|
import { getAuthClient } from '../api/auth';
|
||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
|
hasBootstrapped: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
bootstrap: () => Promise<void>;
|
bootstrap: () => Promise<void>;
|
||||||
signIn: (email: string, password: string) => Promise<boolean>;
|
signIn: (email: string, password: string) => Promise<boolean>;
|
||||||
|
register: (email: string, password: string, displayName: string) => Promise<boolean>;
|
||||||
signOut: () => void;
|
signOut: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
hasBootstrapped: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
email: null,
|
email: null,
|
||||||
async bootstrap() {
|
async bootstrap() {
|
||||||
const client = getAuthClient();
|
const client = getAuthClient();
|
||||||
if (!client.isAuthenticated()) {
|
if (!client.isAuthenticated()) {
|
||||||
set({ isAuthenticated: false, email: null });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,10 +28,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const me = await client.getMe();
|
const me = await client.getMe();
|
||||||
set({ isAuthenticated: true, email: me.email, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
|
||||||
} catch {
|
} catch {
|
||||||
client.clearTokens();
|
client.clearTokens();
|
||||||
set({ isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async signIn(email: string, password: string) {
|
async signIn(email: string, password: string) {
|
||||||
@ -36,16 +39,29 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await getAuthClient().login(email, password);
|
await getAuthClient().login(email, password);
|
||||||
set({ isAuthenticated: true, email, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
getAuthClient().clearTokens();
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signOut() {
|
signOut() {
|
||||||
getAuthClient().clearTokens();
|
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