Skip to content

Commit 0b16313

Browse files
committed
Make biometric login togglable
1 parent c4e6f15 commit 0b16313

File tree

4 files changed

+146
-17
lines changed

4 files changed

+146
-17
lines changed

eas.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"android": {
2727
"buildType": "apk"
2828
},
29-
"channel": "production"
29+
"channel": "production",
30+
"autoIncrement": true
3031
}
3132
},
3233
"submit": {

src/core/AppContent.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
22
import { Ionicons } from "@expo/vector-icons";
3+
import AsyncStorage from "@react-native-async-storage/async-storage";
34
import {
45
NavigationContainer,
56
LinkingOptions,
@@ -127,32 +128,60 @@ export default function AppContent({
127128
const navigationRef = useRef<NavigationContainerRef<TabParamList>>(null);
128129
const hcb = useClient();
129130

130-
// Reset Stripe Terminal initialization state on app start
131131
useEffect(() => {
132132
resetStripeTerminalInitialization();
133+
setTokenFetchAttempts(0);
134+
setLastTokenFetch(0);
135+
setCachedToken(null);
136+
setTokenExpiry(0);
133137
}, []);
134138

135139
const [lastTokenFetch, setLastTokenFetch] = useState<number>(0);
136140
const [tokenFetchAttempts, setTokenFetchAttempts] = useState<number>(0);
141+
const [cachedToken, setCachedToken] = useState<string | null>(null);
142+
const [tokenExpiry, setTokenExpiry] = useState<number>(0);
137143
const TOKEN_FETCH_COOLDOWN = 5000;
138144
const MAX_TOKEN_FETCH_ATTEMPTS = 3;
145+
const TOKEN_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
139146

140147
const fetchTokenProvider = async (): Promise<string> => {
141148
const now = Date.now();
142149

150+
// Return cached token if it's still valid
151+
if (cachedToken && now < tokenExpiry) {
152+
console.log("Using cached Stripe Terminal connection token");
153+
return cachedToken;
154+
}
155+
156+
// Check if we should actually fetch the token
157+
// Only fetch if the user is authenticated and has access token
158+
if (!tokens?.accessToken) {
159+
console.log("No access token available, skipping Stripe Terminal token fetch");
160+
throw new Error("Authentication required for Stripe Terminal connection");
161+
}
162+
163+
// Check rate limiting
143164
if (now - lastTokenFetch < TOKEN_FETCH_COOLDOWN) {
165+
const waitTime = Math.ceil((TOKEN_FETCH_COOLDOWN - (now - lastTokenFetch)) / 1000);
166+
console.warn(`Rate limited: Please wait ${waitTime} seconds before retrying`);
144167
throw new Error(
145-
`Rate limited: Please wait ${Math.ceil((TOKEN_FETCH_COOLDOWN - (now - lastTokenFetch)) / 1000)} seconds before retrying`,
168+
`Rate limited: Please wait ${waitTime} seconds before retrying`,
146169
);
147170
}
148171

149172
if (tokenFetchAttempts >= MAX_TOKEN_FETCH_ATTEMPTS) {
173+
console.error(`Maximum token fetch attempts (${MAX_TOKEN_FETCH_ATTEMPTS}) exceeded`);
174+
setTimeout(() => {
175+
setTokenFetchAttempts(0);
176+
setLastTokenFetch(0);
177+
}, 60000);
150178
throw new Error(
151-
`Maximum token fetch attempts (${MAX_TOKEN_FETCH_ATTEMPTS}) exceeded. Please restart the app.`,
179+
`Maximum token fetch attempts (${MAX_TOKEN_FETCH_ATTEMPTS}) exceeded. Please wait before retrying.`,
152180
);
153181
}
154182

155183
try {
184+
console.log("Fetching new Stripe Terminal connection token...");
156185
setLastTokenFetch(now);
157186
setTokenFetchAttempts((prev) => prev + 1);
158187

@@ -164,9 +193,16 @@ export default function AppContent({
164193
};
165194
};
166195

196+
const newToken = token.terminal_connection_token.secret;
197+
const newExpiry = now + TOKEN_CACHE_DURATION;
198+
199+
// Cache the token
200+
setCachedToken(newToken);
201+
setTokenExpiry(newExpiry);
167202
setTokenFetchAttempts(0);
168203

169-
return token.terminal_connection_token.secret;
204+
console.log("Successfully fetched and cached Stripe Terminal connection token");
205+
return newToken;
170206
} catch (error) {
171207
console.error("Token fetch failed:", error);
172208

@@ -180,6 +216,7 @@ export default function AppContent({
180216
TOKEN_FETCH_COOLDOWN * Math.pow(2, tokenFetchAttempts),
181217
30000,
182218
); // Max 30 seconds
219+
console.warn(`Rate limited (429). Please wait ${Math.ceil(backoffTime / 1000)} seconds before retrying.`);
183220
throw new Error(
184221
`Rate limited (429). Please wait ${Math.ceil(backoffTime / 1000)} seconds before retrying.`,
185222
);
@@ -229,13 +266,22 @@ export default function AppContent({
229266
setStatusBar();
230267
const checkAuth = async () => {
231268
if (tokens?.accessToken) {
232-
if ((await process.env.EXPO_PUBLIC_APP_VARIANT) === "development") {
233-
// bypass auth for development
234-
setIsAuthenticated(true);
235-
setAppIsReady(true);
236-
return;
237-
}
269+
// if ((await process.env.EXPO_PUBLIC_APP_VARIANT) === "development") {
270+
// // bypass auth for development
271+
// setIsAuthenticated(true);
272+
// setAppIsReady(true);
273+
// return;
274+
// }
238275
try {
276+
const biometricsRequired = await AsyncStorage.getItem("biometrics_required");
277+
278+
if (biometricsRequired !== "true") {
279+
console.log("Biometric authentication not required, bypassing...");
280+
setIsAuthenticated(true);
281+
setAppIsReady(true);
282+
return;
283+
}
284+
239285
// Check if biometric authentication is available
240286
const hasHardware = await LocalAuthentication.hasHardwareAsync();
241287
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
@@ -299,6 +345,10 @@ export default function AppContent({
299345

300346
useEffect(() => {
301347
const handleUpdates = async () => {
348+
if (process.env.EXPO_PUBLIC_APP_VARIANT === "development") {
349+
setFinishedUpdateCheck(true);
350+
return;
351+
}
302352
try {
303353
const availableUpdate = await Updates.checkForUpdateAsync();
304354

src/lib/useStripeTerminalInit.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ let globalInitializationState = {
2323
supportsTapToPay: false,
2424
error: null as Error | null,
2525
};
26+
let hasLoggedWaiting = false;
2627

2728
// Reset function to clear global state on app restart
2829
export function resetStripeTerminalInitialization() {
2930
console.log("Resetting Stripe Terminal initialization state");
3031
globalInitializationPromise = null;
32+
hasLoggedWaiting = false;
3133
globalInitializationState = {
3234
isInitialized: false,
3335
supportsTapToPay: false,
@@ -56,12 +58,18 @@ export function useStripeTerminalInit(
5658

5759
const initializeTerminal = useCallback(async (): Promise<boolean> => {
5860
if (globalInitializationPromise) {
59-
console.log("Waiting for existing Stripe Terminal initialization...");
61+
if (!hasLoggedWaiting) {
62+
console.log("Waiting for existing Stripe Terminal initialization...");
63+
hasLoggedWaiting = true;
64+
}
6065
return await globalInitializationPromise;
6166
}
6267

6368
if (globalInitializationState.isInitialized) {
64-
console.log("Stripe Terminal already initialized, skipping...");
69+
// Only log this once per app session
70+
if (!hasLoggedWaiting) {
71+
console.log("Stripe Terminal already initialized, skipping...");
72+
}
6573
return true;
6674
}
6775

@@ -143,6 +151,7 @@ export function useStripeTerminalInit(
143151
const retry = () => {
144152
console.log("Retrying Stripe Terminal initialization...");
145153
initializationAttempted.current = false;
154+
hasLoggedWaiting = false;
146155
setError(null);
147156
setIsInitialized(false);
148157
setSupportsTapToPay(false);

src/pages/settings/Settings.tsx

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { useTheme } from "@react-navigation/native";
44
import { NativeStackScreenProps } from "@react-navigation/native-stack";
55
import Constants from "expo-constants";
66
import * as Device from "expo-device";
7+
import * as LocalAuthentication from "expo-local-authentication";
78
import * as SystemUI from "expo-system-ui";
8-
import { useContext, useEffect, useRef } from "react";
9+
import { useContext, useEffect, useRef, useState } from "react";
910
import {
1011
Linking,
1112
Text,
@@ -15,6 +16,7 @@ import {
1516
Animated,
1617
useColorScheme,
1718
Platform,
19+
Switch,
1820
} from "react-native";
1921
import useSWR from "swr";
2022

@@ -32,6 +34,7 @@ const TOS_URL = "https://hcb.hackclub.com/tos";
3234
const PRIVACY_URL = "https://hcb.hackclub.com/privacy";
3335

3436
const THEME_KEY = "app_theme";
37+
const BIOMETRICS_KEY = "biometrics_required";
3538

3639
const themeOptions = [
3740
{
@@ -69,6 +72,8 @@ export default function SettingsPage({ navigation }: Props) {
6972
const animation = useRef(new Animated.Value(0)).current;
7073
const scheme = useColorScheme();
7174
const isDark = useIsDark();
75+
const [biometricsRequired, setBiometricsRequired] = useState(false);
76+
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
7277

7378
useEffect(() => {
7479
(async () => {
@@ -81,9 +86,16 @@ export default function SettingsPage({ navigation }: Props) {
8186
) {
8287
setTheme(storedTheme);
8388
}
89+
90+
const storedBiometrics = await AsyncStorage.getItem(BIOMETRICS_KEY);
91+
setBiometricsRequired(storedBiometrics === "true");
92+
93+
const hasHardware = await LocalAuthentication.hasHardwareAsync();
94+
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
95+
setBiometricsAvailable(hasHardware && isEnrolled);
8496
} catch (error) {
85-
logError("Error loading theme in settings", error, {
86-
context: { action: "settings_theme_load" },
97+
logError("Error loading settings", error, {
98+
context: { action: "settings_load" },
8799
});
88100
}
89101
})();
@@ -113,11 +125,25 @@ export default function SettingsPage({ navigation }: Props) {
113125
}
114126
};
115127

128+
const handleBiometricsToggle = async (value: boolean) => {
129+
try {
130+
setBiometricsRequired(value);
131+
await AsyncStorage.setItem(BIOMETRICS_KEY, value.toString());
132+
} catch (error) {
133+
logError("Error saving biometrics setting", error, {
134+
context: { action: "biometrics_toggle", value },
135+
});
136+
// Revert the state if saving fails
137+
setBiometricsRequired(!value);
138+
}
139+
};
140+
116141
const handleSignOut = async () => {
117142
resetTheme();
118143
try {
119144
await AsyncStorage.multiRemove([
120145
THEME_KEY,
146+
BIOMETRICS_KEY,
121147
"organizationOrder",
122148
"canceledCardsShown",
123149
"ttpDidOnboarding",
@@ -265,7 +291,7 @@ export default function SettingsPage({ navigation }: Props) {
265291
</View>
266292
</View>
267293

268-
{/* App Icon & Tutorials Section */}
294+
{/* App Settings Section */}
269295
<Text
270296
style={{
271297
fontSize: 20,
@@ -284,6 +310,49 @@ export default function SettingsPage({ navigation }: Props) {
284310
marginBottom: 24,
285311
}}
286312
>
313+
{biometricsAvailable && (
314+
<>
315+
<View
316+
style={{
317+
flexDirection: "row",
318+
alignItems: "center",
319+
padding: 18,
320+
justifyContent: "space-between",
321+
}}
322+
>
323+
<View style={{ flexDirection: "row", alignItems: "center", flex: 1 }}>
324+
<Ionicons
325+
name="finger-print"
326+
size={22}
327+
color={palette.muted}
328+
style={{ marginRight: 12 }}
329+
/>
330+
<View style={{ flex: 1 }}>
331+
<Text style={{ color: colors.text, fontSize: 16 }}>
332+
Require Biometrics
333+
</Text>
334+
<Text style={{ color: palette.muted, fontSize: 14, marginTop: 2 }}>
335+
Use Face ID or Touch ID to unlock the app
336+
</Text>
337+
</View>
338+
</View>
339+
<Switch
340+
value={biometricsRequired}
341+
onValueChange={handleBiometricsToggle}
342+
trackColor={{ false: palette.muted, true: colors.primary }}
343+
thumbColor={biometricsRequired ? "#fff" : "#f4f3f4"}
344+
/>
345+
</View>
346+
<View
347+
style={{
348+
height: 1,
349+
backgroundColor: dividerColor,
350+
marginLeft: 20,
351+
marginRight: 20,
352+
}}
353+
/>
354+
</>
355+
)}
287356
<Pressable
288357
style={{ flexDirection: "row", alignItems: "center", padding: 18 }}
289358
onPress={() => navigation.navigate("AppIconSelector")}

0 commit comments

Comments
 (0)