Skip to content

Commit 8b57ecc

Browse files
committed
fix cors policy and add login rate limiting messages
1 parent 9691422 commit 8b57ecc

5 files changed

Lines changed: 208 additions & 28 deletions

File tree

app/(tabs)/index.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { colours, radii } from '@/src/theme';
2020
import type { Commission, Notification } from '@/src/types';
2121

2222
export default function HomeScreen() {
23-
const { token, user, warning } = useSession();
23+
const { token, user } = useSession();
2424
const [commissions, setCommissions] = useState<Commission[]>([]);
2525
const [notifications, setNotifications] = useState<Notification[]>([]);
2626
const [error, setError] = useState('');
@@ -71,15 +71,6 @@ export default function HomeScreen() {
7171
</View>
7272
<Avatar name={user?.displayName ?? 'Ruffl'} />
7373
</View>
74-
{warning ? (
75-
<Card tone="coral">
76-
<View style={styles.row}>
77-
<Ionicons color={colours.danger} name="warning-outline" size={22} />
78-
<Text style={[textStyles.label, styles.flex]}>A message from Ruffl support</Text>
79-
</View>
80-
<Text style={textStyles.body}>{warning}</Text>
81-
</Card>
82-
) : null}
8374
{error ? <ErrorNotice message={error} /> : null}
8475
<Card tone="moss">
8576
<View style={styles.heroTop}>

app/_layout.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { Ionicons } from '@expo/vector-icons';
12
import { router, Stack } from 'expo-router';
23
import { StatusBar } from 'expo-status-bar';
34
import { useEffect } from 'react';
5+
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
46

57
import { SessionProvider, useSession } from '@/src/context/session';
68
import { colours } from '@/src/theme';
@@ -14,7 +16,7 @@ export default function RootLayout() {
1416
}
1517

1618
function Navigation() {
17-
const { restriction } = useSession();
19+
const { dismissWarning, restriction, warning } = useSession();
1820

1921
useEffect(() => {
2022
if (restriction) router.replace('/suspended');
@@ -41,6 +43,74 @@ function Navigation() {
4143
<Stack.Screen name="messages/[id]" options={{ title: 'Conversation' }} />
4244
<Stack.Screen name="tools" options={{ title: 'Maker calculator' }} />
4345
</Stack>
46+
<Modal
47+
animationType="fade"
48+
onRequestClose={() => void dismissWarning()}
49+
transparent
50+
visible={Boolean(warning)}>
51+
<View style={styles.warningOverlay}>
52+
<View style={styles.warningCard}>
53+
<View style={styles.warningIcon}>
54+
<Ionicons color={colours.danger} name="warning-outline" size={26} />
55+
</View>
56+
<Text style={styles.warningEyebrow}>Message from Ruffl support</Text>
57+
<Text style={styles.warningTitle}>Account warning</Text>
58+
<Text style={styles.warningMessage}>{warning?.message}</Text>
59+
<Pressable
60+
accessibilityRole="button"
61+
onPress={() => void dismissWarning()}
62+
style={({ pressed }) => [styles.warningButton, pressed && styles.warningButtonPressed]}>
63+
<Text style={styles.warningButtonText}>I understand</Text>
64+
</Pressable>
65+
</View>
66+
</View>
67+
</Modal>
4468
</>
4569
);
4670
}
71+
72+
const styles = StyleSheet.create({
73+
warningOverlay: {
74+
alignItems: 'center',
75+
backgroundColor: 'rgba(29, 42, 36, 0.55)',
76+
flex: 1,
77+
justifyContent: 'center',
78+
padding: 24,
79+
},
80+
warningCard: {
81+
backgroundColor: colours.surface,
82+
borderRadius: 24,
83+
gap: 10,
84+
maxWidth: 440,
85+
padding: 22,
86+
width: '100%',
87+
},
88+
warningIcon: {
89+
alignItems: 'center',
90+
backgroundColor: colours.coralSoft,
91+
borderRadius: 24,
92+
height: 48,
93+
justifyContent: 'center',
94+
marginBottom: 4,
95+
width: 48,
96+
},
97+
warningEyebrow: {
98+
color: colours.coral,
99+
fontSize: 11,
100+
fontWeight: '900',
101+
letterSpacing: 1.2,
102+
textTransform: 'uppercase',
103+
},
104+
warningTitle: { color: colours.ink, fontSize: 23, fontWeight: '900' },
105+
warningMessage: { color: colours.ink, fontSize: 15, lineHeight: 22 },
106+
warningButton: {
107+
alignItems: 'center',
108+
backgroundColor: colours.moss,
109+
borderRadius: 14,
110+
justifyContent: 'center',
111+
marginTop: 8,
112+
minHeight: 48,
113+
},
114+
warningButtonPressed: { opacity: 0.7 },
115+
warningButtonText: { color: colours.white, fontSize: 15, fontWeight: '800' },
116+
});

src/api/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,10 @@ export const api = {
145145
),
146146
notifications: (token: string) =>
147147
request<{ notifications: Notification[] }>('/notifications', {}, token),
148+
readWarning: (token: string, warningId: string) =>
149+
request<{ warning: { id: string; message: string; read: boolean } }>(
150+
`/warnings/${warningId}/read`,
151+
{ method: 'POST', body: JSON.stringify({}) },
152+
token,
153+
),
148154
};

src/context/session.tsx

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useMemo,
99
useState,
1010
} from 'react';
11+
import { AppState } from 'react-native';
1112

1213
import { api, ApiError, setAccountRestrictionHandler } from '../api/client';
1314
import type { User } from '../types';
@@ -18,7 +19,7 @@ interface SessionValue {
1819
token: string | null;
1920
user: User | null;
2021
loading: boolean;
21-
warning: string | null;
22+
warning: { id: string; message: string } | null;
2223
restriction: { code: string; message: string } | null;
2324
signIn: (email: string, password: string) => Promise<void>;
2425
signUp: (input: {
@@ -28,6 +29,7 @@ interface SessionValue {
2829
role: 'commissioner' | 'maker';
2930
}) => Promise<void>;
3031
signOut: () => Promise<void>;
32+
dismissWarning: () => Promise<void>;
3133
dismissRestriction: () => void;
3234
refresh: () => Promise<void>;
3335
}
@@ -37,7 +39,7 @@ const SessionContext = createContext<SessionValue | null>(null);
3739
export function SessionProvider({ children }: PropsWithChildren) {
3840
const [token, setToken] = useState<string | null>(null);
3941
const [user, setUser] = useState<User | null>(null);
40-
const [warning, setWarning] = useState<string | null>(null);
42+
const [warning, setWarning] = useState<{ id: string; message: string } | null>(null);
4143
const [restriction, setRestriction] = useState<{ code: string; message: string } | null>(null);
4244
const [loading, setLoading] = useState(true);
4345

@@ -54,47 +56,91 @@ export function SessionProvider({ children }: PropsWithChildren) {
5456
void SecureStore.deleteItemAsync(tokenKey);
5557
setToken(null);
5658
setUser(null);
59+
setWarning(null);
5760
setRestriction({ code: error.code, message: error.message });
5861
});
5962
return () => setAccountRestrictionHandler(null);
6063
}, []);
6164

62-
const restoreSession = useCallback(async () => {
63-
const saved = await SecureStore.getItemAsync(tokenKey);
64-
if (!saved) {
65-
setLoading(false);
66-
return;
67-
}
68-
65+
const checkSession = useCallback(async (sessionToken: string, clearOnFailure = false) => {
6966
try {
70-
const result = await api.me(saved);
71-
setToken(saved);
67+
const result = await api.me(sessionToken);
68+
setToken(sessionToken);
7269
setUser(result.user);
73-
setWarning(result.warnings[0]?.message ?? null);
70+
setWarning(result.warnings[0] ?? null);
7471
} catch (error) {
7572
if (error instanceof ApiError && ['ACCOUNT_SUSPENDED', 'ACCOUNT_DELETED'].includes(error.code)) {
7673
setRestriction({ code: error.code, message: error.message });
7774
await SecureStore.deleteItemAsync(tokenKey);
7875
setToken(null);
7976
setUser(null);
80-
} else {
77+
setWarning(null);
78+
} else if (clearOnFailure) {
8179
await clearSession();
8280
}
83-
} finally {
84-
setLoading(false);
8581
}
8682
}, [clearSession]);
8783

84+
const restoreSession = useCallback(async () => {
85+
const saved = await SecureStore.getItemAsync(tokenKey);
86+
if (saved) {
87+
await checkSession(saved, true);
88+
}
89+
setLoading(false);
90+
}, [checkSession]);
91+
8892
useEffect(() => {
8993
void restoreSession();
9094
}, [restoreSession]);
9195

96+
useEffect(() => {
97+
if (!token) return;
98+
99+
let requestInFlight = false;
100+
const checkCurrentSession = async () => {
101+
if (requestInFlight) return;
102+
requestInFlight = true;
103+
try {
104+
await checkSession(token);
105+
} finally {
106+
requestInFlight = false;
107+
}
108+
};
109+
const interval = setInterval(() => void checkCurrentSession(), 3_000);
110+
const subscription = AppState.addEventListener('change', (state) => {
111+
if (state === 'active') void checkCurrentSession();
112+
});
113+
114+
return () => {
115+
clearInterval(interval);
116+
subscription.remove();
117+
};
118+
}, [checkSession, token]);
119+
92120
const finishAuthentication = useCallback(async (result: { token: string; user: User }) => {
93121
await SecureStore.setItemAsync(tokenKey, result.token);
94122
setToken(result.token);
95123
setUser(result.user);
96124
setWarning(null);
97-
}, []);
125+
setRestriction(null);
126+
await checkSession(result.token);
127+
}, [checkSession]);
128+
129+
const dismissWarning = useCallback(async () => {
130+
if (!token || !warning) return;
131+
const currentWarning = warning;
132+
setWarning(null);
133+
try {
134+
await api.readWarning(token, currentWarning.id);
135+
} catch (error) {
136+
if (
137+
!(error instanceof ApiError) ||
138+
!['ACCOUNT_SUSPENDED', 'ACCOUNT_DELETED'].includes(error.code)
139+
) {
140+
setWarning(currentWarning);
141+
}
142+
}
143+
}, [token, warning]);
98144

99145
const value = useMemo<SessionValue>(
100146
() => ({
@@ -106,10 +152,21 @@ export function SessionProvider({ children }: PropsWithChildren) {
106152
signIn: async (email, password) => finishAuthentication(await api.login(email, password)),
107153
signUp: async (input) => finishAuthentication(await api.signup(input)),
108154
signOut: clearSession,
155+
dismissWarning,
109156
dismissRestriction: () => setRestriction(null),
110157
refresh: restoreSession,
111158
}),
112-
[clearSession, finishAuthentication, loading, restriction, restoreSession, token, user, warning],
159+
[
160+
clearSession,
161+
dismissWarning,
162+
finishAuthentication,
163+
loading,
164+
restriction,
165+
restoreSession,
166+
token,
167+
user,
168+
warning,
169+
],
113170
);
114171

115172
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

test/api-client.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { api, setAccountRestrictionHandler } from '../src/api/client';
4+
5+
describe('API error messages', () => {
6+
afterEach(() => {
7+
vi.unstubAllGlobals();
8+
setAccountRestrictionHandler(null);
9+
});
10+
11+
it('keeps the backend rate-limit message for the login screen', async () => {
12+
vi.stubGlobal(
13+
'fetch',
14+
vi.fn().mockResolvedValue(
15+
new Response(
16+
JSON.stringify({
17+
code: 'RATE_LIMITED',
18+
message: 'Too many requests. Try again in 10 minutes.',
19+
}),
20+
{ status: 429 },
21+
),
22+
),
23+
);
24+
25+
await expect(api.login('user@example.com', 'WrongPassword1!')).rejects.toMatchObject({
26+
code: 'RATE_LIMITED',
27+
message: 'Too many requests. Try again in 10 minutes.',
28+
status: 429,
29+
});
30+
});
31+
32+
it('notifies the session layer when an active token becomes suspended', async () => {
33+
const restrictionHandler = vi.fn();
34+
setAccountRestrictionHandler(restrictionHandler);
35+
vi.stubGlobal(
36+
'fetch',
37+
vi.fn().mockResolvedValue(
38+
new Response(
39+
JSON.stringify({
40+
code: 'ACCOUNT_SUSPENDED',
41+
message: 'Account suspended until 27 July 2026.',
42+
}),
43+
{ status: 403 },
44+
),
45+
),
46+
);
47+
48+
await expect(api.me('existing-token')).rejects.toMatchObject({
49+
code: 'ACCOUNT_SUSPENDED',
50+
status: 403,
51+
});
52+
expect(restrictionHandler).toHaveBeenCalledWith(
53+
expect.objectContaining({ code: 'ACCOUNT_SUSPENDED' }),
54+
);
55+
});
56+
});

0 commit comments

Comments
 (0)