From b4ef3a04bb40d3c764ec9b7f520264881f1be2bd Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Thu, 1 Oct 2026 19:42:03 -0700 Subject: [PATCH 01/18] feat(Authentication): added a new folders that contain token save, get, delete logic --- src/features/auth/secureStore.ts | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/features/auth/secureStore.ts diff --git a/src/features/auth/secureStore.ts b/src/features/auth/secureStore.ts new file mode 100644 index 0000000..575d90e --- /dev/null +++ b/src/features/auth/secureStore.ts @@ -0,0 +1,33 @@ +import * as SecureStore from 'expo-secure-store'; + +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 Error("Failed to get token please retry."); + } + }, + async setToken(token: string) { + try { + await SecureStore.setItemAsync(TOKEN_KEY, token); + } catch { + throw Error("Failed to set token please try again.") + } + }, + async deleteToken() { + try { + await SecureStore.deleteItemAsync(TOKEN_KEY); + } catch { + throw Error("Failed to delete token please try again.") + } + } +} \ No newline at end of file From 01c73749da4ca6ad2249363a359b40529c5e522b Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Fri, 2 Oct 2026 19:06:31 -0700 Subject: [PATCH 02/18] feat(Authentication): Added error session state and session action for session restoration failure --- src/state/session.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/state/session.ts b/src/state/session.ts index 40adc05..8e1f95f 100644 --- a/src/state/session.ts +++ b/src/state/session.ts @@ -1,3 +1,5 @@ +import { readonly } from "zod"; + export type UserRole = 'student' | 'mentor'; export type SessionUser = { @@ -16,7 +18,12 @@ export type SessionState = | { readonly status: 'authenticated'; readonly user: SessionUser; - }; + } + | + { + readonly status: 'error'; + readonly errorMessage: string; + }; export type SessionAction = | { @@ -28,7 +35,18 @@ export type SessionAction = } | { readonly type: 'signedOut'; - }; + } + | { + readonly type: 'anonymous'; + } + | { + readonly type: 'launchError'; + readonly errorMessage: string; + } + | { + readonly type: 'launchRestore'; + } + export const initialSessionState: SessionState = { status: 'bootstrapping', @@ -48,6 +66,15 @@ export function sessionReducer( }; case 'signedOut': return state.status === 'anonymous' ? state : { status: 'anonymous' }; + case 'launchError': + return { + status: 'error', + errorMessage: action.errorMessage, + }; + case 'anonymous': + return { status: 'anonymous' }; + case 'launchRestore': + return { status: 'bootstrapping'} default: { const exhaustiveAction: never = action; return exhaustiveAction; From a4f4effe635b56f31f92cf6408282605fbb6ea98 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Fri, 2 Oct 2026 19:16:44 -0700 Subject: [PATCH 03/18] feat(Authentication): Added signIn, signOut, restoreSession logic with face AuthGateway --- src/providers/SessionProvider.tsx | 153 +++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 4 deletions(-) diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index be6dd66..670ade7 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useReducer, + useRef, } from 'react'; import { @@ -14,10 +15,16 @@ import { type UserRole, } from '@/state/session'; +import { tokenStore } from '@/features/auth/secureStore'; + +import { useQueryClient } from '@tanstack/react-query'; + type SessionContextValue = { session: SessionState; mockSignInAs: (role: UserRole) => void; - signOut: () => void; + signIn: (username: string, password: string) => Promise; + signOut: () => Promise; + restoreSession: () => Promise; }; const mockUsers: Record = { @@ -33,14 +40,100 @@ const mockUsers: Record = { }, }; +// FAKE GATEWAY +interface AuthGateway { + login: (username: string, password: string) => Promise; + validate: (token: string) => Promise; +} + +const authGateway: AuthGateway = { + async login(username: string, password: string): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + return 'token123'; + }, + async validate(token: string): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + return true; + }, +}; + const SessionContext = createContext(null); export function SessionProvider({ children }: PropsWithChildren) { const [session, dispatch] = useReducer(sessionReducer, initialSessionState); + // Used to clear cache when signing out. + const queryClient = useQueryClient(); + + // On launch check if a user has a valid session useEffect(() => { - dispatch({ type: 'bootstrapCompleted' }); + restoreSession(); }, []); + + const restoreRef = useRef(false); + async function restoreSession(): 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: 'launchRestore'}) + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + try { + const token = await tokenStore.getToken(); + + // A saved token exist, now validate it. + if (token) { + const validToken = await authGateway.validate(token); + + // for now mock role + const role = 'student'; + + if (validToken) { + dispatch({ + type: 'authenticated', + user: mockUsers[role], + }); + } else { + // token is invalid. Make an expired token state + + // Delete the expired token + await tokenStore.deleteToken(); + + // For now just make state anonymous + dispatch({ type: 'bootstrapCompleted' }); + } + } else { + // There is no saveed token 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. + const message = + error instanceof Error + ? error.message + : 'Failed to restore session. Please try again.'; + + dispatch({ + type: 'launchError', + errorMessage: message, + }); + } finally { + restoreRef.current = false; + } + } function mockSignInAs(role: UserRole) { dispatch({ @@ -49,8 +142,58 @@ export function SessionProvider({ children }: PropsWithChildren) { }); } - 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 Error('Invalid token. Please try again.'); + } + + // Validate token + const valid = await authGateway.validate(token); + + if (!valid) { + throw new Error('Invalid token. Please sign in again.'); + } + + await tokenStore.setToken(token); + + // For now assume role until we're able to decode the token and get users role + const role = 'student'; + + dispatch({ + type: 'authenticated', + user: mockUsers[role], + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Sign in failed. Please try again.'; + throw Error(message); + } + } + + async function signOut() { + try { + // delete the saved token + await tokenStore.deleteToken(); + + // cancel any on going queries and clear cache. + await queryClient.cancelQueries(); + queryClient.clear(); + + // make the users sign out and their status anonymous + dispatch({ type: 'signedOut' }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Sign out failed. Please try again.'; + throw Error(message); + } } return ( @@ -58,7 +201,9 @@ export function SessionProvider({ children }: PropsWithChildren) { value={{ session, mockSignInAs, + signIn, signOut, + restoreSession, }} > {children} From 6a4ffc1dadd9becc9633eeb144c54b3e31fc468c Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Tue, 6 Oct 2026 16:52:15 -0700 Subject: [PATCH 04/18] feat(Authentication): Added sign out to profile.tsx with InlineError if there is an error --- app/(app)/(tabs)/profile.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx index f620b6a..b09afc9 100644 --- a/app/(app)/(tabs)/profile.tsx +++ b/app/(app)/(tabs)/profile.tsx @@ -15,6 +15,8 @@ import { spacing, } from '@/design/tokens'; import { useSession } from '@/providers/SessionProvider'; +import { useState } from 'react'; +import { InlineError } from '@/components/feedback/InlineError'; type Badge = { id: string; @@ -139,6 +141,21 @@ 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 signOutPressed = async () => { + setSigningOut(true); + + try { + await signOut(); + } catch (error) { + setSigningOut(false); + const message = error instanceof Error ? error.message : 'Sign out failed. Please try again.'; + setError(message); + } + + }; return ( @@ -258,7 +275,8 @@ export default function ProfileRoute() { [ s.signOutBtn, pressed && s.signOutBtnPressed, @@ -267,6 +285,8 @@ export default function ProfileRoute() { Sign out + {error && } + From 36a10a4a734d49d489ffa4a2723a711f3a8f8c49 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Tue, 6 Oct 2026 16:56:44 -0700 Subject: [PATCH 05/18] feat(Authentication): Added sign in to sign-in.tsx with InlineError and Spinner when loading --- app/(public)/sign-in.tsx | 46 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/app/(public)/sign-in.tsx b/app/(public)/sign-in.tsx index f3c3dfe..3dab9d2 100644 --- a/app/(public)/sign-in.tsx +++ b/app/(public)/sign-in.tsx @@ -26,17 +26,23 @@ import { radii, spacing, } from '@/design/tokens'; + import { useSession } from '@/providers/SessionProvider'; +import { Spinner } from '@/components/ui/Spinner'; +import { InlineError } from '@/components/feedback/InlineError'; 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 [btnScale] = useState(() => new Animated.Value(1)); const pressIn = () => @@ -54,6 +60,34 @@ export default function SignInRoute() { bounciness: 4, }).start(); + const signInPressed = async () => { + setError('') + setSigningIn(true); + + if (!username || !password) { + setSigningIn(false); + + // Show invalid-credentials message + setError('Please enter your username and password'); + + return; + } + + try { + await signIn(username, password); + } catch (error) { + setSigningIn(false); + const message = + error instanceof Error + ? error.message + : 'Sign in failed. Please try again.'; + + setError(message); + } + + // SessionProvider will update screen. + }; + return ( {/* ── Patterned Background ──────────────── */} @@ -142,12 +176,20 @@ export default function SignInRoute() { accessibilityRole="button" onPressIn={pressIn} onPressOut={pressOut} + onPress={signInPressed} + disabled={signingIn} style={s.enterBtn} > - Sign In → + {signingIn ? ( + + ) : ( + Sign In → + )} + {error && } + New here? From 5308639fe05cce466d691e60d9823f86ab1bb525 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Tue, 6 Oct 2026 17:04:59 -0700 Subject: [PATCH 06/18] feat(Authentication): Added expiredSessoin to SessionProvider.tsx. Function used when users session expires. Removed fake AuthGateway --- src/providers/SessionProvider.tsx | 79 +++++++++++++++++-------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index 670ade7..5312e15 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -15,15 +15,17 @@ import { type UserRole, } from '@/state/session'; -import { tokenStore } from '@/features/auth/secureStore'; +import { tokenStore } from '@/services/storage/TokenStore'; import { useQueryClient } from '@tanstack/react-query'; +import { authGateway } from '@/services/auth/AuthGateway'; type SessionContextValue = { session: SessionState; mockSignInAs: (role: UserRole) => void; signIn: (username: string, password: string) => Promise; signOut: () => Promise; + expiredSession: () => Promise; restoreSession: () => Promise; }; @@ -40,28 +42,7 @@ const mockUsers: Record = { }, }; -// FAKE GATEWAY -interface AuthGateway { - login: (username: string, password: string) => Promise; - validate: (token: string) => Promise; -} - -const authGateway: AuthGateway = { - async login(username: string, password: string): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - return 'token123'; - }, - async validate(token: string): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - return true; - }, -}; const SessionContext = createContext(null); @@ -75,8 +56,9 @@ export function SessionProvider({ children }: PropsWithChildren) { useEffect(() => { restoreSession(); }, []); - + const restoreRef = useRef(false); + async function restoreSession(): Promise { // This prevents a user from pressing on the retry button multiple times. // Which will cause this function to run multiple times. @@ -86,7 +68,7 @@ export function SessionProvider({ children }: PropsWithChildren) { restoreRef.current = true; - dispatch({ type: 'launchRestore'}) + dispatch({ type: 'launchRestore' }); await new Promise((resolve) => { setTimeout(resolve, 2000); }); @@ -97,25 +79,19 @@ export function SessionProvider({ children }: PropsWithChildren) { if (token) { const validToken = await authGateway.validate(token); - // for now mock role - const role = 'student'; - if (validToken) { + // for now mock role + const role = 'student'; dispatch({ type: 'authenticated', user: mockUsers[role], }); } else { - // token is invalid. Make an expired token state - - // Delete the expired token - await tokenStore.deleteToken(); - - // For now just make state anonymous - dispatch({ type: 'bootstrapCompleted' }); + // token is invalid. Session state is expired + await expiredSession(); } } else { - // There is no saveed token so state is anonymous + // There is no saved token / previous session so state is anonymous dispatch({ type: 'anonymous' }); } } catch (error) { @@ -142,13 +118,43 @@ export function SessionProvider({ children }: PropsWithChildren) { }); } + // This function will get called if there is a 401 response meaning the token is expired. + // Or if the token expired when the app was first launched + // use ref incase multiple 401 responses happens and expiredSession gets called multiple times. + + const expiringRef = useRef(false); + async function expiredSession() { + if (expiringRef.current) { + return; + } + + expiringRef.current = true; + + // This ensures if deleteToken fails cancelQueries still gets called + try { + await Promise.allSettled([ + tokenStore.deleteToken(), + queryClient.cancelQueries(), + ]); + + queryClient.clear(); + + dispatch({ + type: 'expired', + expiredMessage: 'Your session has expired. Please sign in again.', + }); + } finally { + expiringRef.current = false; + } + } + async function signIn(username: string, password: string): Promise { try { const token = await authGateway.login(username, password); if (!token) { // Empty token recieved - throw new Error('Invalid token. Please try again.'); + throw new Error('Invalid credentials. Please try again.'); } // Validate token @@ -203,6 +209,7 @@ export function SessionProvider({ children }: PropsWithChildren) { mockSignInAs, signIn, signOut, + expiredSession, restoreSession, }} > From f46bedf52a88c7d6522a9c5297836354d0a7e533 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Wed, 7 Oct 2026 19:19:36 -0700 Subject: [PATCH 07/18] Added error state status state with sign out + retry in root layo ut --- app/_layout.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 60995a0..2bf76bc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,8 @@ import { QueryProvider } from '@/providers/QueryProvider'; import '@/config/env'; +import LaunchError from '@/features/auth/components/launch-error'; + 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 ( - + From 2a9079c2a1b1bcd2a01d923d779d6b952f291474 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 18:45:24 -0700 Subject: [PATCH 08/18] feat(Authentication): Added test --- test/authentication.test.tsx | 274 +++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 test/authentication.test.tsx diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx new file mode 100644 index 0000000..1e0362b --- /dev/null +++ b/test/authentication.test.tsx @@ -0,0 +1,274 @@ +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 { AppError } from '@/services/api/apiError'; +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'; +import { studentToken } from './fixtures/studentToken'; + +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} + + + + ); +} From b261dbb5380aacc74d05d80991aa69bf8bf901d7 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 20:28:56 -0700 Subject: [PATCH 13/18] fix(Authentication): Added fakeAuthgateway for dev only testing --- app/(public)/sign-in.tsx | 64 +++++----- src/providers/SessionProvider.tsx | 199 +++++++++++++++++++----------- src/types/authGateway.ts | 7 ++ 3 files changed, 171 insertions(+), 99 deletions(-) create mode 100644 src/types/authGateway.ts diff --git a/app/(public)/sign-in.tsx b/app/(public)/sign-in.tsx index ce7f81f..bdb7d36 100644 --- a/app/(public)/sign-in.tsx +++ b/app/(public)/sign-in.tsx @@ -140,7 +140,7 @@ export default function SignInRoute() { 👤 setUsernameFocused(false)} @@ -216,34 +216,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/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index 5312e15..b0bdf32 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -11,7 +11,6 @@ import { initialSessionState, sessionReducer, type SessionState, - type SessionUser, type UserRole, } from '@/state/session'; @@ -19,31 +18,47 @@ import { tokenStore } from '@/services/storage/TokenStore'; import { useQueryClient } from '@tanstack/react-query'; import { authGateway } from '@/services/auth/AuthGateway'; +import { AppError } from '@/services/api/apiError'; +import { decodeJWT } from '@/services/auth/decodeJWT'; +import type { AuthGateway } from '@/types/authGateway'; type SessionContextValue = { session: SessionState; - mockSignInAs: (role: UserRole) => 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', +export const studentToken = + 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Im1vY2stc3R1ZGVudCIsImRpc3BsYXlOYW1lIjoiTW9jayBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; + +export const mentorToken = + 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Im1vY2stbWVudG9yIiwiZGlzcGxheU5hbWUiOiJNb2NrIE1lbnRvciIsInJvbGUiOiJtZW50b3IifQ.fake'; + +const fakeAuthGateway: AuthGateway = { + async login(username) { + if (username === 'student') return studentToken; + if (username === 'mentor') return mentorToken; + throw new AppError('UNKNOWN', 'Unknown test account.'); }, - mentor: { - id: 'mock-mentor', - displayName: 'Mock Mentor', - role: 'mentor', + 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) { + return token === studentToken || token === mentorToken; }, }; - - const SessionContext = createContext(null); export function SessionProvider({ children }: PropsWithChildren) { @@ -52,11 +67,6 @@ export function SessionProvider({ children }: PropsWithChildren) { // Used to clear cache when signing out. const queryClient = useQueryClient(); - // On launch check if a user has a valid session - useEffect(() => { - restoreSession(); - }, []); - const restoreRef = useRef(false); async function restoreSession(): Promise { @@ -68,23 +78,37 @@ export function SessionProvider({ children }: PropsWithChildren) { restoreRef.current = true; - dispatch({ type: 'launchRestore' }); - await new Promise((resolve) => { - setTimeout(resolve, 2000); - }); + dispatch({ type: 'restore' }); + try { const token = await tokenStore.getToken(); - // A saved token exist, now validate it. if (token) { - const validToken = await authGateway.validate(token); + // For dev testing + const isFakeToken = + __DEV__ && (token === studentToken || token === mentorToken); + const validToken = isFakeToken + ? await fakeAuthGateway.validate(token) + : await authGateway.validate(token); if (validToken) { - // for now mock role - const role = 'student'; + const decoded = decodeJWT(token); + + if ( + typeof decoded.id !== 'string' || + typeof decoded.displayName !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); + } + dispatch({ type: 'authenticated', - user: mockUsers[role], + user: { + id: decoded.id, + displayName: decoded.displayName, + role: decoded.role, + }, }); } else { // token is invalid. Session state is expired @@ -97,31 +121,51 @@ export function SessionProvider({ children }: PropsWithChildren) { } 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. - const message = - error instanceof Error - ? error.message - : 'Failed to restore session. Please try again.'; - - dispatch({ - type: 'launchError', - errorMessage: message, - }); + 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; } } - function mockSignInAs(role: UserRole) { + async function mockSignInAs(role: UserRole) { + if (!__DEV__) return; + + const token = await fakeAuthGateway.login(role, ''); + + const decoded = decodeJWT(token); + + if ( + typeof decoded.id !== 'string' || + typeof decoded.displayName !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); + } + + await tokenStore.setToken(token); + dispatch({ type: 'authenticated', - user: mockUsers[role], + user: { + id: decoded.id, + displayName: decoded.displayName, + role: decoded.role, + }, }); } - // This function will get called if there is a 401 response meaning the token is expired. - // Or if the token expired when the app was first launched - // use ref incase multiple 401 responses happens and expiredSession gets called multiple times. - const expiringRef = useRef(false); async function expiredSession() { if (expiringRef.current) { @@ -132,17 +176,21 @@ export function SessionProvider({ children }: PropsWithChildren) { // This ensures if deleteToken fails cancelQueries still gets called try { - await Promise.allSettled([ - tokenStore.deleteToken(), - queryClient.cancelQueries(), - ]); - + 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; } @@ -154,54 +202,63 @@ export function SessionProvider({ children }: PropsWithChildren) { if (!token) { // Empty token recieved - throw new Error('Invalid credentials. Please try again.'); + throw new AppError( + 'INVALID_RESPONSE', + 'The server returned an invalid response.', + ); } - // Validate token - const valid = await authGateway.validate(token); + const decoded = decodeJWT(token); - if (!valid) { - throw new Error('Invalid token. Please sign in again.'); + if ( + typeof decoded.id !== 'string' || + typeof decoded.displayName !== 'string' || + (decoded.role !== 'student' && decoded.role !== 'mentor') + ) { + throw new Error('Missing user claims'); } await tokenStore.setToken(token); - // For now assume role until we're able to decode the token and get users role - const role = 'student'; - dispatch({ type: 'authenticated', - user: mockUsers[role], + user: { + id: decoded.id, + displayName: decoded.displayName, + role: decoded.role, + }, }); } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Sign in failed. Please try again.'; - throw Error(message); + if (error instanceof AppError) { + throw error; + } else { + throw new AppError('UNKNOWN', 'Sign in failed. Please try again.'); + } } } async function signOut() { try { - // delete the saved token - await tokenStore.deleteToken(); - - // cancel any on going queries and clear cache. await queryClient.cancelQueries(); queryClient.clear(); - // make the users sign out and their status anonymous + await authGateway.logout(); + dispatch({ type: 'signedOut' }); } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Sign out failed. Please try again.'; - throw Error(message); + 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(); + }, []); + return ( Promise; + logout: () => Promise; + validate: (token: string) => Promise; +} + +export type LoginResponse = { token: string }; \ No newline at end of file From 53e40128356750b48117d3340ade8f78de4beadb Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 20:31:41 -0700 Subject: [PATCH 14/18] test(Authentication): Added authentication testing and modified existing testing --- test/SessionProvider.test.tsx | 4 ++-- test/authentication.test.tsx | 2 +- test/fixtures/renderAnonymous.ts | 14 ++++++++++++++ test/fixtures/studentToken.ts | 2 ++ test/navigation.test.tsx | 12 +----------- 5 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/renderAnonymous.ts create mode 100644 test/fixtures/studentToken.ts diff --git a/test/SessionProvider.test.tsx b/test/SessionProvider.test.tsx index 43a1c3a..8b43396 100644 --- a/test/SessionProvider.test.tsx +++ b/test/SessionProvider.test.tsx @@ -61,7 +61,7 @@ describe('SessionProvider', () => { }), ); - expect(screen.getByText('authenticated')).toBeOnTheScreen(); + expect(await screen.findByText('authenticated')).toBeOnTheScreen(); expect(screen.getByText('student')).toBeOnTheScreen(); @@ -91,6 +91,6 @@ describe('SessionProvider', () => { }), ); - expect(screen.getByText('mentor')).toBeOnTheScreen(); + expect(await screen.findByText('mentor')).toBeOnTheScreen(); }); }); diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx index 1e0362b..22b9bb5 100644 --- a/test/authentication.test.tsx +++ b/test/authentication.test.tsx @@ -226,7 +226,7 @@ describe('authentication', () => { await screen.findByText('anonymous'); fireEvent.press(screen.getByRole('button', { name: 'Mock student' })); - expect(screen.getByText('authenticated')).toBeOnTheScreen(); + expect(await screen.findByText('authenticated')).toBeOnTheScreen(); queryClient.setQueryData(privateKey, { name: 'Test Student' }); expect(queryClient.getQueryData(privateKey)).toBeDefined(); fireEvent.press(screen.getByRole('button', { name: 'Sign out' })); diff --git a/test/fixtures/renderAnonymous.ts b/test/fixtures/renderAnonymous.ts new file mode 100644 index 0000000..327697f --- /dev/null +++ b/test/fixtures/renderAnonymous.ts @@ -0,0 +1,14 @@ +import { renderRouter, screen, waitFor } from 'expo-router/testing-library'; +import { tokenStore } from '@/services/storage/TokenStore'; + +export async function renderAnonymous(initialUrl = '/') { + jest.spyOn(tokenStore, 'getToken').mockResolvedValue(null); + + const renderedRouter = renderRouter('./app', { initialUrl }); + + await waitFor(() => { + expect(screen.getByText('Welcome back')).toBeOnTheScreen(); + }); + + return renderedRouter; +} diff --git a/test/fixtures/studentToken.ts b/test/fixtures/studentToken.ts new file mode 100644 index 0000000..c77377d --- /dev/null +++ b/test/fixtures/studentToken.ts @@ -0,0 +1,2 @@ +export const studentToken = + 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6IjEyMyIsImRpc3BsYXlOYW1lIjoiVGVzdCBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; diff --git a/test/navigation.test.tsx b/test/navigation.test.tsx index 6b9bd83..3a9cc70 100644 --- a/test/navigation.test.tsx +++ b/test/navigation.test.tsx @@ -7,17 +7,7 @@ import { waitFor, } from 'expo-router/testing-library'; -async function renderAnonymous(initialUrl = '/') { - const renderedRouter = renderRouter('./app', { - initialUrl, - }); - - await waitFor(() => { - expect(screen.getByText('Welcome back')).toBeOnTheScreen(); - }); - - return renderedRouter; -} +import { renderAnonymous } from './fixtures/renderAnonymous'; describe('session-aware navigation', () => { it('redirects an anonymous user away from a protected route', async () => { From e6c9c1bdeda967edac1886f8bb60c6435d1cf160 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 20:34:15 -0700 Subject: [PATCH 15/18] feat(Authentication): AuthGateway connected to apiClient, JWT decoding, and TokenStore --- src/services/auth/AuthGateway.ts | 56 ++++++++++++++++++++++++++++++ src/services/auth/decodeJWT.ts | 20 +++++++++++ src/services/storage/TokenStore.ts | 34 ++++++++++++++++++ src/state/session.ts | 37 ++++++++++++-------- 4 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 src/services/auth/AuthGateway.ts create mode 100644 src/services/auth/decodeJWT.ts create mode 100644 src/services/storage/TokenStore.ts diff --git a/src/services/auth/AuthGateway.ts b/src/services/auth/AuthGateway.ts new file mode 100644 index 0000000..ae4e4a0 --- /dev/null +++ b/src/services/auth/AuthGateway.ts @@ -0,0 +1,56 @@ +import { apiClient } from '@/services/api/apiClient'; +import { AppError } from '@/services/api/apiError'; +import type { ApiClientInterface } from '@/types/apiClient.types'; +import { tokenStore } from '../storage/TokenStore'; +import { AuthGateway, LoginResponse } from '@/types/authGateway'; + + + +export function createAuthGateway(client: ApiClientInterface): 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(apiClient); 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..10026b3 --- /dev/null +++ b/src/services/storage/TokenStore.ts @@ -0,0 +1,34 @@ +import * as SecureStore from 'expo-secure-store'; +import { AppError } from '../api/apiError'; + +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 8e1f95f..6e59973 100644 --- a/src/state/session.ts +++ b/src/state/session.ts @@ -1,4 +1,4 @@ -import { readonly } from "zod"; +import { readonly } from 'zod'; export type UserRole = 'student' | 'mentor'; @@ -19,11 +19,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 = | { @@ -40,13 +43,16 @@ export type SessionAction = readonly type: 'anonymous'; } | { - readonly type: 'launchError'; + readonly type: 'error'; readonly errorMessage: string; } | { - readonly type: 'launchRestore'; + readonly type: 'restore'; + } + | { + readonly type: 'expired'; + readonly expiredMessage: string; } - export const initialSessionState: SessionState = { status: 'bootstrapping', @@ -66,15 +72,18 @@ export function sessionReducer( }; case 'signedOut': return state.status === 'anonymous' ? state : { status: 'anonymous' }; - case 'launchError': - return { - status: 'error', - errorMessage: action.errorMessage, - }; case 'anonymous': return { status: 'anonymous' }; - case 'launchRestore': - return { status: 'bootstrapping'} + 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; From a1207de46aecfb3162116192d3bfa22920364b01 Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 21:45:25 -0700 Subject: [PATCH 16/18] fix(Authentication): deleted secureStore and studentToken --- src/features/auth/secureStore.ts | 33 -------------------------------- test/authentication.test.tsx | 4 +++- test/fixtures/studentToken.ts | 2 -- 3 files changed, 3 insertions(+), 36 deletions(-) delete mode 100644 src/features/auth/secureStore.ts delete mode 100644 test/fixtures/studentToken.ts diff --git a/src/features/auth/secureStore.ts b/src/features/auth/secureStore.ts deleted file mode 100644 index 575d90e..0000000 --- a/src/features/auth/secureStore.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as SecureStore from 'expo-secure-store'; - -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 Error("Failed to get token please retry."); - } - }, - async setToken(token: string) { - try { - await SecureStore.setItemAsync(TOKEN_KEY, token); - } catch { - throw Error("Failed to set token please try again.") - } - }, - async deleteToken() { - try { - await SecureStore.deleteItemAsync(TOKEN_KEY); - } catch { - throw Error("Failed to delete token please try again.") - } - } -} \ No newline at end of file diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx index 22b9bb5..8f309fd 100644 --- a/test/authentication.test.tsx +++ b/test/authentication.test.tsx @@ -16,7 +16,9 @@ import { tokenStore } from '@/services/storage/TokenStore'; import { createQueryClient } from '@/providers/QueryProvider'; import { SessionProvider, useSession } from '@/providers/SessionProvider'; import { renderAnonymous } from './fixtures/renderAnonymous'; -import { studentToken } from './fixtures/studentToken'; + +const studentToken = + 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6IjEyMyIsImRpc3BsYXlOYW1lIjoiVGVzdCBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; function submitSignIn(username: string, password: string) { fireEvent.changeText(screen.getByTestId('username-input'), username); diff --git a/test/fixtures/studentToken.ts b/test/fixtures/studentToken.ts deleted file mode 100644 index c77377d..0000000 --- a/test/fixtures/studentToken.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const studentToken = - 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6IjEyMyIsImRpc3BsYXlOYW1lIjoiVGVzdCBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; From 9b9e3999f75cca5070f14abceb60bc4efc5ec2ab Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sat, 10 Oct 2026 22:41:47 -0700 Subject: [PATCH 17/18] fix(Authentication): Fixed SessionUser to work with backedn and formating --- src/features/auth/components/error-screen.tsx | 2 +- src/providers/SessionProvider.tsx | 98 ++++++++++--------- src/services/auth/AuthGateway.ts | 4 +- src/state/session.ts | 8 +- src/types/authGateway.ts | 2 +- test/authentication.test.tsx | 2 +- test/session.test.ts | 2 +- 7 files changed, 59 insertions(+), 59 deletions(-) diff --git a/src/features/auth/components/error-screen.tsx b/src/features/auth/components/error-screen.tsx index 288e736..71a75d4 100644 --- a/src/features/auth/components/error-screen.tsx +++ b/src/features/auth/components/error-screen.tsx @@ -13,7 +13,7 @@ export default function ErrorScreen({ return ( <> - + ); diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index b0bdf32..fbb7d1c 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -1,6 +1,7 @@ import { createContext, type PropsWithChildren, + useCallback, useContext, useEffect, useReducer, @@ -32,10 +33,10 @@ type SessionContextValue = { }; export const studentToken = - 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Im1vY2stc3R1ZGVudCIsImRpc3BsYXlOYW1lIjoiTW9jayBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stc3R1ZGVudCIsImZpcnN0TmFtZSI6Ik1vY2siLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake'; export const mentorToken = - 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Im1vY2stbWVudG9yIiwiZGlzcGxheU5hbWUiOiJNb2NrIE1lbnRvciIsInJvbGUiOiJtZW50b3IifQ.fake'; + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stbWVudG9yIiwiZmlyc3ROYW1lIjoiTW9jayIsImxhc3ROYW1lIjoiTWVudG9yIiwicm9sZSI6Im1lbnRvciJ9.fake'; const fakeAuthGateway: AuthGateway = { async login(username) { @@ -67,9 +68,39 @@ export function SessionProvider({ children }: PropsWithChildren) { // 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); - async function restoreSession(): Promise { + 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) { @@ -95,8 +126,9 @@ export function SessionProvider({ children }: PropsWithChildren) { const decoded = decodeJWT(token); if ( - typeof decoded.id !== 'string' || - typeof decoded.displayName !== 'string' || + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || (decoded.role !== 'student' && decoded.role !== 'mentor') ) { throw new Error('Missing user claims'); @@ -105,8 +137,8 @@ export function SessionProvider({ children }: PropsWithChildren) { dispatch({ type: 'authenticated', user: { - id: decoded.id, - displayName: decoded.displayName, + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, role: decoded.role, }, }); @@ -137,7 +169,7 @@ export function SessionProvider({ children }: PropsWithChildren) { } finally { restoreRef.current = false; } - } + }, [expiredSession]); async function mockSignInAs(role: UserRole) { if (!__DEV__) return; @@ -147,8 +179,9 @@ export function SessionProvider({ children }: PropsWithChildren) { const decoded = decodeJWT(token); if ( - typeof decoded.id !== 'string' || - typeof decoded.displayName !== 'string' || + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || (decoded.role !== 'student' && decoded.role !== 'mentor') ) { throw new Error('Missing user claims'); @@ -159,43 +192,13 @@ export function SessionProvider({ children }: PropsWithChildren) { dispatch({ type: 'authenticated', user: { - id: decoded.id, - displayName: decoded.displayName, + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, role: decoded.role, }, }); } - const expiringRef = useRef(false); - async function expiredSession() { - 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; - } - } - async function signIn(username: string, password: string): Promise { try { const token = await authGateway.login(username, password); @@ -211,8 +214,9 @@ export function SessionProvider({ children }: PropsWithChildren) { const decoded = decodeJWT(token); if ( - typeof decoded.id !== 'string' || - typeof decoded.displayName !== 'string' || + typeof decoded.firstName !== 'string' || + typeof decoded.lastName !== 'string' || + typeof decoded.username !== 'string' || (decoded.role !== 'student' && decoded.role !== 'mentor') ) { throw new Error('Missing user claims'); @@ -223,8 +227,8 @@ export function SessionProvider({ children }: PropsWithChildren) { dispatch({ type: 'authenticated', user: { - id: decoded.id, - displayName: decoded.displayName, + username: decoded.username, + displayName: decoded.firstName + ' ' + decoded.lastName, role: decoded.role, }, }); @@ -257,7 +261,7 @@ export function SessionProvider({ children }: PropsWithChildren) { // On launch check if a user has a valid session useEffect(() => { restoreSession(); - }, []); + }, [restoreSession]); return ( Promise; } -export type LoginResponse = { token: string }; \ No newline at end of file +export type LoginResponse = { token: string }; diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx index 8f309fd..fa808f0 100644 --- a/test/authentication.test.tsx +++ b/test/authentication.test.tsx @@ -18,7 +18,7 @@ import { SessionProvider, useSession } from '@/providers/SessionProvider'; import { renderAnonymous } from './fixtures/renderAnonymous'; const studentToken = - 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6IjEyMyIsImRpc3BsYXlOYW1lIjoiVGVzdCBTdHVkZW50Iiwicm9sZSI6InN0dWRlbnQifQ.fake'; + 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6InRlc3Qtc3R1ZGVudCIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake'; function submitSignIn(username: string, password: string) { fireEvent.changeText(screen.getByTestId('username-input'), username); diff --git a/test/session.test.ts b/test/session.test.ts index f600561..2d8669a 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -5,7 +5,7 @@ import { } from '@/state/session'; const student: SessionUser = { - id: 'test-student', + username: 'test-student', displayName: 'Test Student', role: 'student', }; From ef3f039fc16356620bfb3c331296c0b54e05edbd Mon Sep 17 00:00:00 2001 From: diego31anaya Date: Sun, 11 Oct 2026 10:41:45 -0700 Subject: [PATCH 18/18] fix(Authentication): Added fakeApiClient to AuthGateway.ts and temporary AppErro to TokenStore.ts until API is merged. Other files updated their imports to these 2 for now. --- app/(app)/(tabs)/profile.tsx | 3 +- app/(public)/sign-in.tsx | 3 +- src/providers/SessionProvider.tsx | 16 +++++----- src/services/auth/AuthGateway.ts | 49 ++++++++++++++++++++++++++---- src/services/storage/TokenStore.ts | 23 +++++++++++++- test/authentication.test.tsx | 3 +- 6 files changed, 78 insertions(+), 19 deletions(-) diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx index 18116ec..c008fc7 100644 --- a/app/(app)/(tabs)/profile.tsx +++ b/app/(app)/(tabs)/profile.tsx @@ -17,9 +17,10 @@ import { import { useSession } from '@/providers/SessionProvider'; import { useState } from 'react'; import { InlineError } from '@/components/feedback/InlineError'; -import { AppError } from '@/services/api/apiError'; 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; diff --git a/app/(public)/sign-in.tsx b/app/(public)/sign-in.tsx index bdb7d36..a5584aa 100644 --- a/app/(public)/sign-in.tsx +++ b/app/(public)/sign-in.tsx @@ -32,7 +32,8 @@ import { Spinner } from '@/components/ui/Spinner'; import { InlineError } from '@/components/feedback/InlineError'; import { ConnectionBanner } from '@/components/feedback/ConnectionBanner'; import { isNonEmptyString } from '@/utils/isNonEmptyString'; -import { AppError } from '@/services/api/apiError'; +// Temporary AppError import until API is merged +import { AppError } from '@/services/storage/TokenStore'; const chessPiecePattern = require('@/assets/images/chess-piece-pattern.png'); diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index fbb7d1c..100a672 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -15,11 +15,15 @@ import { type UserRole, } from '@/state/session'; -import { tokenStore } from '@/services/storage/TokenStore'; +// Temporary AppError import until API is merged +import { AppError, tokenStore } from '@/services/storage/TokenStore'; import { useQueryClient } from '@tanstack/react-query'; -import { authGateway } from '@/services/auth/AuthGateway'; -import { AppError } from '@/services/api/apiError'; +import { + authGateway, + studentToken, + mentorToken, +} from '@/services/auth/AuthGateway'; import { decodeJWT } from '@/services/auth/decodeJWT'; import type { AuthGateway } from '@/types/authGateway'; @@ -32,12 +36,6 @@ type SessionContextValue = { restoreSession: () => Promise; }; -export const studentToken = - 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stc3R1ZGVudCIsImZpcnN0TmFtZSI6Ik1vY2siLCJsYXN0TmFtZSI6IlN0dWRlbnQiLCJyb2xlIjoic3R1ZGVudCJ9.fake'; - -export const mentorToken = - 'eyJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6Im1vY2stbWVudG9yIiwiZmlyc3ROYW1lIjoiTW9jayIsImxhc3ROYW1lIjoiTWVudG9yIiwicm9sZSI6Im1lbnRvciJ9.fake'; - const fakeAuthGateway: AuthGateway = { async login(username) { if (username === 'student') return studentToken; diff --git a/src/services/auth/AuthGateway.ts b/src/services/auth/AuthGateway.ts index beaf196..4858a16 100644 --- a/src/services/auth/AuthGateway.ts +++ b/src/services/auth/AuthGateway.ts @@ -1,10 +1,47 @@ -import { apiClient } from '@/services/api/apiClient'; -import { AppError } from '@/services/api/apiError'; -import type { ApiClientInterface } from '@/types/apiClient.types'; -import { tokenStore } from '../storage/TokenStore'; +// Temporary AppError import until API is merged +import { AppError, tokenStore } from '../storage/TokenStore'; import { AuthGateway, LoginResponse } from '@/types/authGateway'; -export function createAuthGateway(client: ApiClientInterface): 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 { @@ -51,4 +88,4 @@ export function createAuthGateway(client: ApiClientInterface): AuthGateway { }; } -export const authGateway = createAuthGateway(apiClient); +export const authGateway = createAuthGateway(fakeApiClient); diff --git a/src/services/storage/TokenStore.ts b/src/services/storage/TokenStore.ts index 10026b3..5bc1ef0 100644 --- a/src/services/storage/TokenStore.ts +++ b/src/services/storage/TokenStore.ts @@ -1,5 +1,26 @@ import * as SecureStore from 'expo-secure-store'; -import { AppError } from '../api/apiError'; + +// 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'; diff --git a/test/authentication.test.tsx b/test/authentication.test.tsx index fa808f0..f9dd67a 100644 --- a/test/authentication.test.tsx +++ b/test/authentication.test.tsx @@ -10,12 +10,13 @@ import { render } from '@testing-library/react-native'; import { QueryClientProvider } from '@tanstack/react-query'; import { Button, Text, View } from 'react-native'; -import { AppError } from '@/services/api/apiError'; 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';