diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx index f620b6a..c008fc7 100644 --- a/app/(app)/(tabs)/profile.tsx +++ b/app/(app)/(tabs)/profile.tsx @@ -15,6 +15,12 @@ import { spacing, } from '@/design/tokens'; import { useSession } from '@/providers/SessionProvider'; +import { useState } from 'react'; +import { InlineError } from '@/components/feedback/InlineError'; +import { ConnectionBanner } from '@/components/feedback/ConnectionBanner'; +import { Spinner } from '@/components/ui/Spinner'; +// Temporary AppError import until API is merged +import { AppError } from '@/services/storage/TokenStore'; type Badge = { id: string; @@ -139,6 +145,36 @@ export default function ProfileRoute() { session.status === 'authenticated' ? session.user.role : 'student'; const initial = displayName.charAt(0).toUpperCase(); const earnedCount = BADGES.filter((b) => b.earned).length; + const [signingOut, setSigningOut] = useState(false); + const [error, setError] = useState(''); + const [connectionError, setConnectionError] = useState(''); + + const signOutPressed = async () => { + setSigningOut(true); + setError(''); + setConnectionError(''); + + try { + await signOut(); + } catch (error) { + setSigningOut(false); + if (error instanceof AppError) { + switch (error.code) { + case 'NETWORK_ERROR': + setConnectionError(error.message); + break; + case 'REQUEST_TIMEOUT': + setConnectionError(error.message); + break; + default: + setError(error.message); + break; + } + } else { + setError('Sign out failed. Please try again.'); + } + } + }; return ( @@ -258,15 +294,26 @@ export default function ProfileRoute() { [ s.signOutBtn, pressed && s.signOutBtnPressed, ]} > - Sign out + {signingOut ? ( + + ) : ( + Sign out + )} + {error && } + + {connectionError && ( + + )} + diff --git a/app/(public)/sign-in.tsx b/app/(public)/sign-in.tsx index f3c3dfe..a5584aa 100644 --- a/app/(public)/sign-in.tsx +++ b/app/(public)/sign-in.tsx @@ -26,17 +26,29 @@ import { radii, spacing, } from '@/design/tokens'; + import { useSession } from '@/providers/SessionProvider'; +import { Spinner } from '@/components/ui/Spinner'; +import { InlineError } from '@/components/feedback/InlineError'; +import { ConnectionBanner } from '@/components/feedback/ConnectionBanner'; +import { isNonEmptyString } from '@/utils/isNonEmptyString'; +// Temporary AppError import until API is merged +import { AppError } from '@/services/storage/TokenStore'; const chessPiecePattern = require('@/assets/images/chess-piece-pattern.png'); export default function SignInRoute() { - const { mockSignInAs } = useSession(); + const { mockSignInAs, signIn, session } = useSession(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [usernameFocused, setUsernameFocused] = useState(false); const [passwordFocused, setPasswordFocused] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [signingIn, setSigningIn] = useState(false); + const [error, setError] = useState( + session.status === 'expired' ? session.expiredMessage : '', + ); + const [connectionError, setConnectionError] = useState(''); const [btnScale] = useState(() => new Animated.Value(1)); const pressIn = () => @@ -54,6 +66,41 @@ export default function SignInRoute() { bounciness: 4, }).start(); + const signInPressed = async () => { + setError(''); + setConnectionError(''); + setSigningIn(true); + + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { + setSigningIn(false); + + setError('Please enter your username and password'); + + return; + } + + try { + await signIn(username, password); + } catch (error) { + setSigningIn(false); + if (error instanceof AppError) { + switch (error.code) { + case 'NETWORK_ERROR': + setConnectionError(error.message); + break; + case 'REQUEST_TIMEOUT': + setConnectionError(error.message); + break; + default: + setError(error.message); + break; + } + } else { + setError('Sign in failed. Please try again.'); + } + } + }; + return ( {/* ── Patterned Background ──────────────── */} @@ -94,6 +141,7 @@ export default function SignInRoute() { 👤 setUsernameFocused(false)} @@ -110,6 +158,7 @@ export default function SignInRoute() { 🔒 setPasswordFocused(false)} @@ -142,12 +191,23 @@ export default function SignInRoute() { accessibilityRole="button" onPressIn={pressIn} onPressOut={pressOut} + onPress={signInPressed} + disabled={signingIn} style={s.enterBtn} > - Sign In → + {signingIn ? ( + + ) : ( + Sign In → + )} + {error && } + + {connectionError && ( + + )} New here? @@ -157,34 +217,42 @@ export default function SignInRoute() { {/* ── Dev Quick-Login ───────────────────── */} - - - - DEV ONLY - - - - {(['student', 'mentor'] as const).map((role) => ( - mockSignInAs(role)} - style={({ pressed }) => [ - s.devBtn, - pressed && s.devBtnPressed, - ]} - > - - {role === 'student' ? '🎒' : '🎓'} - - - {role === 'student' ? 'Student' : 'Mentor'} - - - ))} + {__DEV__ && ( + + + + DEV ONLY + + + + {(['student', 'mentor'] as const).map((role) => ( + { + void mockSignInAs(role).catch(() => { + setError( + 'Unable to start a test session. Please try again.', + ); + }); + }} + style={({ pressed }) => [ + s.devBtn, + pressed && s.devBtnPressed, + ]} + > + + {role === 'student' ? '🎒' : '🎓'} + + + {role === 'student' ? 'Student' : 'Mentor'} + + + ))} + - + )} diff --git a/app/_layout.tsx b/app/_layout.tsx index 60995a0..4590d13 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,8 @@ import { QueryProvider } from '@/providers/QueryProvider'; import '@/config/env'; +import ErrorScreen from '@/features/auth/components/error-screen'; + export default function RootLayout() { return ( @@ -20,7 +22,7 @@ export default function RootLayout() { } function RootNavigator() { - const { session } = useSession(); + const { session, restoreSession, signOut } = useSession(); if (session.status === 'bootstrapping') { return ( @@ -30,9 +32,31 @@ function RootNavigator() { ); } + // If an error occured on launch while checking if a user has a session. + if (session.status === 'error') { + // Get error message from session + const message = session.errorMessage; + return ( + + + + ); + } + return ( - + diff --git a/src/features/auth/components/error-screen.tsx b/src/features/auth/components/error-screen.tsx new file mode 100644 index 0000000..71a75d4 --- /dev/null +++ b/src/features/auth/components/error-screen.tsx @@ -0,0 +1,20 @@ +import { Button } from '@/components/ui/Button'; +import { InlineError } from '@/components/feedback/InlineError'; + +export default function ErrorScreen({ + message, + restoreSession, + signOut, +}: { + message: string; + restoreSession: () => Promise; + signOut: () => Promise; +}) { + return ( + <> + + + + + ); +} diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index be6dd66..100a672 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -1,35 +1,60 @@ import { createContext, type PropsWithChildren, + useCallback, useContext, useEffect, useReducer, + useRef, } from 'react'; import { initialSessionState, sessionReducer, type SessionState, - type SessionUser, type UserRole, } from '@/state/session'; +// Temporary AppError import until API is merged +import { AppError, tokenStore } from '@/services/storage/TokenStore'; + +import { useQueryClient } from '@tanstack/react-query'; +import { + authGateway, + studentToken, + mentorToken, +} from '@/services/auth/AuthGateway'; +import { decodeJWT } from '@/services/auth/decodeJWT'; +import type { AuthGateway } from '@/types/authGateway'; + type SessionContextValue = { session: SessionState; - mockSignInAs: (role: UserRole) => void; - signOut: () => void; + mockSignInAs: (role: UserRole) => Promise; + signIn: (username: string, password: string) => Promise; + signOut: () => Promise; + expiredSession: () => Promise; + restoreSession: () => Promise; }; -const mockUsers: Record = { - student: { - id: 'mock-student', - displayName: 'Mock Student', - role: 'student', +const fakeAuthGateway: AuthGateway = { + async login(username) { + if (username === 'student') return studentToken; + if (username === 'mentor') return mentorToken; + throw new AppError('UNKNOWN', 'Unknown test account.'); + }, + async logout() { + try { + await tokenStore.deleteToken(); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError('UNKNOWN', 'Sign out failed. Please try again.'); + } }, - mentor: { - id: 'mock-mentor', - displayName: 'Mock Mentor', - role: 'mentor', + async validate(token) { + return token === studentToken || token === mentorToken; }, }; @@ -38,27 +63,213 @@ const SessionContext = createContext(null); export function SessionProvider({ children }: PropsWithChildren) { const [session, dispatch] = useReducer(sessionReducer, initialSessionState); - useEffect(() => { - dispatch({ type: 'bootstrapCompleted' }); - }, []); + // Used to clear cache when signing out. + const queryClient = useQueryClient(); + + const expiringRef = useRef(false); + const expiredSession = useCallback(async () => { + if (expiringRef.current) { + return; + } + + expiringRef.current = true; + + // This ensures if deleteToken fails cancelQueries still gets called + try { + await queryClient.cancelQueries(); + queryClient.clear(); + + await tokenStore.deleteToken(); + + dispatch({ + type: 'expired', + expiredMessage: 'Your session has expired. Please sign in again.', + }); + } catch { + dispatch({ + type: 'error', + errorMessage: + 'Your session has expired, but we could not sign you out. Please sign out.', + }); + } finally { + expiringRef.current = false; + } + }, [queryClient]); + + const restoreRef = useRef(false); + + const restoreSession = useCallback(async (): Promise => { + // This prevents a user from pressing on the retry button multiple times. + // Which will cause this function to run multiple times. + if (restoreRef.current) { + return; + } + + restoreRef.current = true; + + dispatch({ type: 'restore' }); + + try { + const token = await tokenStore.getToken(); + + if (token) { + // For dev testing + const isFakeToken = + __DEV__ && (token === studentToken || token === mentorToken); + const validToken = isFakeToken + ? await fakeAuthGateway.validate(token) + : await authGateway.validate(token); + + if (validToken) { + const decoded = decodeJWT(token); + + if ( + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); + } + + dispatch({ + type: 'authenticated', + user: { + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, + role: decoded.role, + }, + }); + } else { + // token is invalid. Session state is expired + await expiredSession(); + } + } else { + // There is no saved token / previous session so state is anonymous + dispatch({ type: 'anonymous' }); + } + } catch (error) { + // Show an error state. Ask to refetch, since there may be a valid token. + // Error is caused either by secure token get failing, or connection failure when validating. + if (error instanceof AppError && error.code === 'SESSION_EXPIRED') { + await expiredSession(); + } else { + const message = + error instanceof AppError + ? error.message + : 'Failed to restore session. Please try again.'; + + dispatch({ + type: 'error', + errorMessage: message, + }); + } + } finally { + restoreRef.current = false; + } + }, [expiredSession]); + + async function mockSignInAs(role: UserRole) { + if (!__DEV__) return; + + const token = await fakeAuthGateway.login(role, ''); + + const decoded = decodeJWT(token); + + if ( + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); + } + + await tokenStore.setToken(token); - function mockSignInAs(role: UserRole) { dispatch({ type: 'authenticated', - user: mockUsers[role], + user: { + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, + role: decoded.role, + }, }); } - function signOut() { - dispatch({ type: 'signedOut' }); + async function signIn(username: string, password: string): Promise { + try { + const token = await authGateway.login(username, password); + + if (!token) { + // Empty token recieved + throw new AppError( + 'INVALID_RESPONSE', + 'The server returned an invalid response.', + ); + } + + const decoded = decodeJWT(token); + + if ( + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); + } + + await tokenStore.setToken(token); + + dispatch({ + type: 'authenticated', + user: { + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, + role: decoded.role, + }, + }); + } catch (error) { + if (error instanceof AppError) { + throw error; + } else { + throw new AppError('UNKNOWN', 'Sign in failed. Please try again.'); + } + } + } + + async function signOut() { + try { + await queryClient.cancelQueries(); + queryClient.clear(); + + await authGateway.logout(); + + dispatch({ type: 'signedOut' }); + } catch (error) { + if (error instanceof AppError) { + throw error; + } else { + throw new AppError('UNKNOWN', 'Sign out failed. Please try again.'); + } + } } + // On launch check if a user has a valid session + useEffect(() => { + restoreSession(); + }, [restoreSession]); + return ( {children} diff --git a/src/services/auth/AuthGateway.ts b/src/services/auth/AuthGateway.ts new file mode 100644 index 0000000..4858a16 --- /dev/null +++ b/src/services/auth/AuthGateway.ts @@ -0,0 +1,91 @@ +// Temporary AppError import until API is merged +import { AppError, tokenStore } from '../storage/TokenStore'; +import { AuthGateway, LoginResponse } from '@/types/authGateway'; + +export const studentToken = + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stc3R1ZGVudCIsImZpcnN0TmFtZSI6Ik1vY2siLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake'; + +export const mentorToken = + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stbWVudG9yIiwiZmlyc3ROYW1lIjoiTW9jayIsImxhc3ROYW1lIjoiTWVudG9yIiwicm9sZSI6Im1lbnRvciJ9.fake'; + +// Fake apiClient until Brandons API client is merged. +export const fakeApiClient = { + async post( + endpoint: string, + body: B, + options?: { headers?: Record }, + ): Promise { + if (!__DEV__) { + throw new AppError('UNKNOWN', 'Sign in is unavailable.'); + } + + if (endpoint === '/auth/login') { + const username = (body as { username?: string })?.username; + if (username === 'student') return { token: studentToken } as T; + if (username === 'mentor') return { token: mentorToken } as T; + throw new AppError('UNKNOWN', 'Invalid username or password.'); + } + + if (endpoint === '/auth/validate') { + const bearer = options?.headers?.Authorization; + if ( + bearer === `Bearer ${studentToken}` || + bearer === `Bearer ${mentorToken}` + ) { + return null; // Successful 200 with no response body + } + throw new AppError('SESSION_EXPIRED', 'Your session has expired.', 401); + } + + throw new AppError('UNKNOWN', 'Unsupported authentication request.'); + }, +}; + +export function createAuthGateway(client: typeof fakeApiClient): AuthGateway { + return { + async login(username, password) { + try { + const response: LoginResponse | null = await client.post( + '/auth/login', + { username, password }, + ); + + return response?.token ?? ''; + } catch (error) { + throw error; + } + }, + + async logout() { + try { + await tokenStore.deleteToken(); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError('UNKNOWN', 'Sign out failed. Please try again.'); + } + }, + + async validate(token) { + try { + await client.post('/auth/validate', undefined, { + headers: { Authorization: `Bearer ${token}` }, + }); + return true; + } catch (error) { + if ( + error instanceof AppError && + error.code === 'SESSION_EXPIRED' && + error.status === 401 + ) { + return false; + } + throw error; + } + }, + }; +} + +export const authGateway = createAuthGateway(fakeApiClient); diff --git a/src/services/auth/decodeJWT.ts b/src/services/auth/decodeJWT.ts new file mode 100644 index 0000000..b2e8737 --- /dev/null +++ b/src/services/auth/decodeJWT.ts @@ -0,0 +1,20 @@ +export function decodeJWT(token: string): Record { + const parts = token.split('.'); + if (parts.length !== 3 || !parts[1]) throw new Error('Invalid JWT'); + + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const bytes = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')); + const json = decodeURIComponent( + Array.from( + bytes, + (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`, + ).join(''), + ); + const payload: unknown = JSON.parse(json); + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Invalid JWT payload'); + } + + return payload as Record; +} diff --git a/src/services/storage/TokenStore.ts b/src/services/storage/TokenStore.ts new file mode 100644 index 0000000..5bc1ef0 --- /dev/null +++ b/src/services/storage/TokenStore.ts @@ -0,0 +1,55 @@ +import * as SecureStore from 'expo-secure-store'; + +// Temporary AppError decloration here until Brandons API code is merged. +export type AppErrorCode = + | 'NETWORK_ERROR' + | 'REQUEST_TIMEOUT' + | 'REQUEST_CANCEL' + | 'INVALID_RESPONSE' + | 'SESSION_EXPIRED' + | 'PERMISSION_DENIED' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +export class AppError extends Error { + constructor( + public readonly code: AppErrorCode, + message: string, + public readonly status?: number, // Pass in response.status + ) { + super(message); + this.name = 'AppError'; + } +} + +const TOKEN_KEY = 'jwt-token'; + +interface TokenStore { + getToken: () => Promise; + setToken: (token: string) => Promise; + deleteToken: () => Promise; +} + +export const tokenStore: TokenStore = { + async getToken() { + try { + return await SecureStore.getItemAsync(TOKEN_KEY); + } catch { + throw new AppError('UNKNOWN', 'Failed to get token please retry.'); + } + }, + async setToken(token: string) { + try { + await SecureStore.setItemAsync(TOKEN_KEY, token); + } catch { + throw new AppError('UNKNOWN', 'Failed to set token please try again.'); + } + }, + async deleteToken() { + try { + await SecureStore.deleteItemAsync(TOKEN_KEY); + } catch { + throw new AppError('UNKNOWN', 'Failed to delete token please try again.'); + } + }, +}; diff --git a/src/state/session.ts b/src/state/session.ts index 40adc05..6787934 100644 --- a/src/state/session.ts +++ b/src/state/session.ts @@ -1,7 +1,7 @@ export type UserRole = 'student' | 'mentor'; export type SessionUser = { - readonly id: string; + readonly username: string; readonly displayName: string; readonly role: UserRole; }; @@ -16,6 +16,14 @@ export type SessionState = | { readonly status: 'authenticated'; readonly user: SessionUser; + } + | { + readonly status: 'expired'; + readonly expiredMessage: string; + } + | { + readonly status: 'error'; + readonly errorMessage: string; }; export type SessionAction = @@ -28,6 +36,20 @@ export type SessionAction = } | { readonly type: 'signedOut'; + } + | { + readonly type: 'anonymous'; + } + | { + readonly type: 'error'; + readonly errorMessage: string; + } + | { + readonly type: 'restore'; + } + | { + readonly type: 'expired'; + readonly expiredMessage: string; }; export const initialSessionState: SessionState = { @@ -48,6 +70,18 @@ export function sessionReducer( }; case 'signedOut': return state.status === 'anonymous' ? state : { status: 'anonymous' }; + case 'anonymous': + return { status: 'anonymous' }; + case 'expired': + return { status: 'expired', expiredMessage: action.expiredMessage }; + case 'error': + return { + status: 'error', + errorMessage: action.errorMessage, + }; + case 'restore': + return { status: 'bootstrapping' }; + default: { const exhaustiveAction: never = action; return exhaustiveAction; diff --git a/src/types/authGateway.ts b/src/types/authGateway.ts new file mode 100644 index 0000000..bcd7fcd --- /dev/null +++ b/src/types/authGateway.ts @@ -0,0 +1,7 @@ +export interface AuthGateway { + login: (username: string, password: string) => Promise; + logout: () => Promise; + validate: (token: string) => Promise; +} + +export type LoginResponse = { token: string }; diff --git a/test/SessionProvider.test.tsx b/test/SessionProvider.test.tsx index 60df8d2..8b43396 100644 --- a/test/SessionProvider.test.tsx +++ b/test/SessionProvider.test.tsx @@ -6,6 +6,7 @@ import { } from '@testing-library/react-native'; import { Button, Text, View } from 'react-native'; +import { QueryProvider } from '@/providers/QueryProvider'; import { SessionProvider, useSession } from '@/providers/SessionProvider'; function SessionTestHarness() { @@ -31,9 +32,11 @@ function SessionTestHarness() { describe('SessionProvider', () => { it('finishes bootstrap as anonymous', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -43,18 +46,22 @@ describe('SessionProvider', () => { it('supports mocked student sign-in and sign-out', async () => { render( - - - , + + + + + , ); + await screen.findByText('anonymous'); + fireEvent.press( screen.getByRole('button', { name: 'Mock student', }), ); - expect(screen.getByText('authenticated')).toBeOnTheScreen(); + expect(await screen.findByText('authenticated')).toBeOnTheScreen(); expect(screen.getByText('student')).toBeOnTheScreen(); @@ -64,22 +71,26 @@ describe('SessionProvider', () => { }), ); - expect(screen.getByText('anonymous')).toBeOnTheScreen(); + await screen.findByText('anonymous'); }); - it('supports mocked mentor sign-in', () => { + it('supports mocked mentor sign-in', async () => { render( - - - , + + + + + , ); + await screen.findByText('anonymous'); + fireEvent.press( screen.getByRole('button', { name: 'Mock mentor', }), ); - expect(screen.getByText('mentor')).toBeOnTheScreen(); + expect(await screen.findByText('mentor')).toBeOnTheScreen(); }); }); diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx new file mode 100644 index 0000000..f9dd67a --- /dev/null +++ b/test/authentication.test.tsx @@ -0,0 +1,277 @@ +import { router } from 'expo-router'; +import { + act, + fireEvent, + renderRouter, + screen, + waitFor, +} from 'expo-router/testing-library'; +import { render } from '@testing-library/react-native'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { Button, Text, View } from 'react-native'; + +import { authGateway } from '@/services/auth/AuthGateway'; +import { tokenStore } from '@/services/storage/TokenStore'; +import { createQueryClient } from '@/providers/QueryProvider'; +import { SessionProvider, useSession } from '@/providers/SessionProvider'; +import { renderAnonymous } from './fixtures/renderAnonymous'; +// Temporary AppError import until API is merged +import { AppError } from '@/services/storage/TokenStore'; + +const studentToken = + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6InRlc3Qtc3R1ZGVudCIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake'; + +function submitSignIn(username: string, password: string) { + fireEvent.changeText(screen.getByTestId('username-input'), username); + fireEvent.changeText(screen.getByTestId('password-input'), password); + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); +} + +function MockScreen() { + const { session, mockSignInAs, signOut } = useSession(); + + return ( + + {session.status} +