@croacroa/react-native-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import * as SecureStore from "expo-secure-store";
|
|
10
|
+
import { router } from "expo-router";
|
|
11
|
+
import { toast } from "@/utils/toast";
|
|
12
|
+
|
|
13
|
+
interface User {
|
|
14
|
+
id: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name: string;
|
|
17
|
+
avatar?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AuthTokens {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface AuthContextType {
|
|
27
|
+
user: User | null;
|
|
28
|
+
isAuthenticated: boolean;
|
|
29
|
+
isLoading: boolean;
|
|
30
|
+
signIn: (email: string, password: string) => Promise<void>;
|
|
31
|
+
signUp: (email: string, password: string, name: string) => Promise<void>;
|
|
32
|
+
signOut: () => Promise<void>;
|
|
33
|
+
updateUser: (user: Partial<User>) => void;
|
|
34
|
+
refreshSession: () => Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
38
|
+
|
|
39
|
+
const TOKEN_KEY = "auth_tokens";
|
|
40
|
+
const USER_KEY = "auth_user";
|
|
41
|
+
const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes before expiry
|
|
42
|
+
|
|
43
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
44
|
+
const [user, setUser] = useState<User | null>(null);
|
|
45
|
+
const [tokens, setTokens] = useState<AuthTokens | null>(null);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
47
|
+
|
|
48
|
+
// Load stored auth on mount
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
loadStoredAuth();
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
// Setup token refresh interval
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!tokens) return;
|
|
56
|
+
|
|
57
|
+
const checkAndRefresh = async () => {
|
|
58
|
+
const timeUntilExpiry = tokens.expiresAt - Date.now();
|
|
59
|
+
if (timeUntilExpiry < TOKEN_REFRESH_THRESHOLD) {
|
|
60
|
+
await refreshSession();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Check immediately and then every minute
|
|
65
|
+
checkAndRefresh();
|
|
66
|
+
const interval = setInterval(checkAndRefresh, 60 * 1000);
|
|
67
|
+
|
|
68
|
+
return () => clearInterval(interval);
|
|
69
|
+
}, [tokens?.expiresAt]);
|
|
70
|
+
|
|
71
|
+
const loadStoredAuth = async () => {
|
|
72
|
+
try {
|
|
73
|
+
const [storedTokens, storedUser] = await Promise.all([
|
|
74
|
+
SecureStore.getItemAsync(TOKEN_KEY),
|
|
75
|
+
SecureStore.getItemAsync(USER_KEY),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
if (storedTokens && storedUser) {
|
|
79
|
+
const parsedTokens: AuthTokens = JSON.parse(storedTokens);
|
|
80
|
+
|
|
81
|
+
// Check if tokens are expired
|
|
82
|
+
if (parsedTokens.expiresAt < Date.now()) {
|
|
83
|
+
// Try to refresh
|
|
84
|
+
const refreshed = await tryRefreshWithToken(parsedTokens.refreshToken);
|
|
85
|
+
if (!refreshed) {
|
|
86
|
+
await clearAuth();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
setTokens(parsedTokens);
|
|
91
|
+
setUser(JSON.parse(storedUser));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("Failed to load auth:", error);
|
|
96
|
+
await clearAuth();
|
|
97
|
+
} finally {
|
|
98
|
+
setIsLoading(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const tryRefreshWithToken = async (refreshToken: string): Promise<boolean> => {
|
|
103
|
+
try {
|
|
104
|
+
// TODO: Replace with your actual API call
|
|
105
|
+
// const response = await api.post('/auth/refresh', { refreshToken });
|
|
106
|
+
// const { accessToken, refreshToken: newRefreshToken, expiresIn, user } = response.data;
|
|
107
|
+
|
|
108
|
+
// Mock implementation
|
|
109
|
+
const mockTokens: AuthTokens = {
|
|
110
|
+
accessToken: "new_mock_access_token",
|
|
111
|
+
refreshToken: "new_mock_refresh_token",
|
|
112
|
+
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour
|
|
113
|
+
};
|
|
114
|
+
const mockUser: User = {
|
|
115
|
+
id: "1",
|
|
116
|
+
email: "user@example.com",
|
|
117
|
+
name: "User",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await saveAuth(mockTokens, mockUser);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error("Token refresh failed:", error);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const saveAuth = async (newTokens: AuthTokens, newUser: User) => {
|
|
129
|
+
await Promise.all([
|
|
130
|
+
SecureStore.setItemAsync(TOKEN_KEY, JSON.stringify(newTokens)),
|
|
131
|
+
SecureStore.setItemAsync(USER_KEY, JSON.stringify(newUser)),
|
|
132
|
+
]);
|
|
133
|
+
setTokens(newTokens);
|
|
134
|
+
setUser(newUser);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const clearAuth = async () => {
|
|
138
|
+
await Promise.all([
|
|
139
|
+
SecureStore.deleteItemAsync(TOKEN_KEY),
|
|
140
|
+
SecureStore.deleteItemAsync(USER_KEY),
|
|
141
|
+
]);
|
|
142
|
+
setTokens(null);
|
|
143
|
+
setUser(null);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const signIn = useCallback(async (email: string, password: string) => {
|
|
147
|
+
try {
|
|
148
|
+
// TODO: Replace with your actual API call
|
|
149
|
+
// const response = await api.post('/auth/login', { email, password });
|
|
150
|
+
// const { accessToken, refreshToken, expiresIn, user } = response.data;
|
|
151
|
+
|
|
152
|
+
// Mock implementation - replace with real API
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
154
|
+
|
|
155
|
+
// Simulate validation
|
|
156
|
+
if (email !== "test@example.com" && !email.includes("@")) {
|
|
157
|
+
throw new Error("Invalid credentials");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const mockTokens: AuthTokens = {
|
|
161
|
+
accessToken: "mock_access_token",
|
|
162
|
+
refreshToken: "mock_refresh_token",
|
|
163
|
+
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour
|
|
164
|
+
};
|
|
165
|
+
const mockUser: User = {
|
|
166
|
+
id: "1",
|
|
167
|
+
email,
|
|
168
|
+
name: email.split("@")[0],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
await saveAuth(mockTokens, mockUser);
|
|
172
|
+
toast.success("Welcome back!", `Signed in as ${mockUser.name}`);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
toast.error("Sign in failed", "Invalid email or password");
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
const signUp = useCallback(
|
|
180
|
+
async (email: string, password: string, name: string) => {
|
|
181
|
+
try {
|
|
182
|
+
// TODO: Replace with your actual API call
|
|
183
|
+
// const response = await api.post('/auth/register', { email, password, name });
|
|
184
|
+
// const { accessToken, refreshToken, expiresIn, user } = response.data;
|
|
185
|
+
|
|
186
|
+
// Mock implementation
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
188
|
+
|
|
189
|
+
const mockTokens: AuthTokens = {
|
|
190
|
+
accessToken: "mock_access_token",
|
|
191
|
+
refreshToken: "mock_refresh_token",
|
|
192
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
193
|
+
};
|
|
194
|
+
const mockUser: User = {
|
|
195
|
+
id: "1",
|
|
196
|
+
email,
|
|
197
|
+
name,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await saveAuth(mockTokens, mockUser);
|
|
201
|
+
toast.success("Account created!", `Welcome, ${name}`);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
toast.error("Sign up failed", "Could not create account");
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[]
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const signOut = useCallback(async () => {
|
|
211
|
+
try {
|
|
212
|
+
// TODO: Optionally call logout endpoint to invalidate refresh token
|
|
213
|
+
// await api.post('/auth/logout', { refreshToken: tokens?.refreshToken });
|
|
214
|
+
|
|
215
|
+
await clearAuth();
|
|
216
|
+
toast.info("Signed out");
|
|
217
|
+
router.replace("/(public)/login");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error("Sign out error:", error);
|
|
220
|
+
// Clear local state anyway
|
|
221
|
+
await clearAuth();
|
|
222
|
+
}
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
const updateUser = useCallback(
|
|
226
|
+
(updates: Partial<User>) => {
|
|
227
|
+
if (user) {
|
|
228
|
+
const updatedUser = { ...user, ...updates };
|
|
229
|
+
setUser(updatedUser);
|
|
230
|
+
SecureStore.setItemAsync(USER_KEY, JSON.stringify(updatedUser));
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[user]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const refreshSession = useCallback(async (): Promise<boolean> => {
|
|
237
|
+
if (!tokens?.refreshToken) return false;
|
|
238
|
+
return tryRefreshWithToken(tokens.refreshToken);
|
|
239
|
+
}, [tokens]);
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<AuthContext.Provider
|
|
243
|
+
value={{
|
|
244
|
+
user,
|
|
245
|
+
isAuthenticated: !!user && !!tokens,
|
|
246
|
+
isLoading,
|
|
247
|
+
signIn,
|
|
248
|
+
signUp,
|
|
249
|
+
signOut,
|
|
250
|
+
updateUser,
|
|
251
|
+
refreshSession,
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{children}
|
|
255
|
+
</AuthContext.Provider>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function useAuth() {
|
|
260
|
+
const context = useContext(AuthContext);
|
|
261
|
+
if (context === undefined) {
|
|
262
|
+
throw new Error("useAuth must be used within an AuthProvider");
|
|
263
|
+
}
|
|
264
|
+
return context;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the current access token for API calls
|
|
269
|
+
* Automatically handles token refresh if needed
|
|
270
|
+
*/
|
|
271
|
+
export async function getAuthToken(): Promise<string | null> {
|
|
272
|
+
try {
|
|
273
|
+
const stored = await SecureStore.getItemAsync(TOKEN_KEY);
|
|
274
|
+
if (!stored) return null;
|
|
275
|
+
|
|
276
|
+
const tokens: AuthTokens = JSON.parse(stored);
|
|
277
|
+
|
|
278
|
+
// Check if token is expired or about to expire
|
|
279
|
+
if (tokens.expiresAt < Date.now() + TOKEN_REFRESH_THRESHOLD) {
|
|
280
|
+
// Token needs refresh - this should be handled by the auth context
|
|
281
|
+
// For now, return the current token and let the API handle 401
|
|
282
|
+
console.warn("Token is expired or about to expire");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return tokens.accessToken;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error("Failed to get auth token:", error);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import * as LocalAuthentication from "expo-local-authentication";
|
|
3
|
+
import { storage } from "@/services/storage";
|
|
4
|
+
import { ENABLE_BIOMETRIC_AUTH } from "@/constants/config";
|
|
5
|
+
|
|
6
|
+
// Storage key for biometric preference
|
|
7
|
+
const BIOMETRIC_ENABLED_KEY = "biometric_auth_enabled";
|
|
8
|
+
|
|
9
|
+
export type BiometricType = "fingerprint" | "face" | "iris" | "none";
|
|
10
|
+
|
|
11
|
+
interface BiometricCapabilities {
|
|
12
|
+
/**
|
|
13
|
+
* Whether biometric authentication is available on this device
|
|
14
|
+
*/
|
|
15
|
+
isAvailable: boolean;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether biometrics are enrolled on the device
|
|
19
|
+
*/
|
|
20
|
+
isEnrolled: boolean;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The type of biometric authentication available
|
|
24
|
+
*/
|
|
25
|
+
biometricType: BiometricType;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Security level of the biometric hardware
|
|
29
|
+
*/
|
|
30
|
+
securityLevel: LocalAuthentication.SecurityLevel;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UseBiometricsReturn {
|
|
34
|
+
/**
|
|
35
|
+
* Device biometric capabilities
|
|
36
|
+
*/
|
|
37
|
+
capabilities: BiometricCapabilities;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether biometric auth is enabled by the user
|
|
41
|
+
*/
|
|
42
|
+
isEnabled: boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the hook is still loading
|
|
46
|
+
*/
|
|
47
|
+
isLoading: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Authenticate using biometrics
|
|
51
|
+
* @returns true if authentication succeeded
|
|
52
|
+
*/
|
|
53
|
+
authenticate: (options?: AuthenticateOptions) => Promise<boolean>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enable biometric authentication for the user
|
|
57
|
+
*/
|
|
58
|
+
enable: () => Promise<boolean>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Disable biometric authentication for the user
|
|
62
|
+
*/
|
|
63
|
+
disable: () => Promise<void>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Toggle biometric authentication
|
|
67
|
+
*/
|
|
68
|
+
toggle: () => Promise<boolean>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AuthenticateOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Message to display in the authentication prompt
|
|
74
|
+
*/
|
|
75
|
+
promptMessage?: string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Message for the fallback button (e.g., "Use passcode")
|
|
79
|
+
*/
|
|
80
|
+
fallbackLabel?: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether to allow device passcode as fallback
|
|
84
|
+
* @default true
|
|
85
|
+
*/
|
|
86
|
+
allowDeviceCredentials?: boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cancel button label
|
|
90
|
+
*/
|
|
91
|
+
cancelLabel?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the biometric type from the available types
|
|
96
|
+
*/
|
|
97
|
+
function getBiometricType(
|
|
98
|
+
types: LocalAuthentication.AuthenticationType[]
|
|
99
|
+
): BiometricType {
|
|
100
|
+
if (
|
|
101
|
+
types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
|
|
102
|
+
) {
|
|
103
|
+
return "face";
|
|
104
|
+
}
|
|
105
|
+
if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
|
|
106
|
+
return "fingerprint";
|
|
107
|
+
}
|
|
108
|
+
if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
|
|
109
|
+
return "iris";
|
|
110
|
+
}
|
|
111
|
+
return "none";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hook for handling biometric authentication
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* function LoginScreen() {
|
|
120
|
+
* const {
|
|
121
|
+
* capabilities,
|
|
122
|
+
* isEnabled,
|
|
123
|
+
* authenticate,
|
|
124
|
+
* enable,
|
|
125
|
+
* disable
|
|
126
|
+
* } = useBiometrics();
|
|
127
|
+
*
|
|
128
|
+
* const handleBiometricLogin = async () => {
|
|
129
|
+
* const success = await authenticate({
|
|
130
|
+
* promptMessage: 'Authenticate to sign in',
|
|
131
|
+
* });
|
|
132
|
+
*
|
|
133
|
+
* if (success) {
|
|
134
|
+
* // Proceed with login
|
|
135
|
+
* }
|
|
136
|
+
* };
|
|
137
|
+
*
|
|
138
|
+
* if (!capabilities.isAvailable) {
|
|
139
|
+
* return null;
|
|
140
|
+
* }
|
|
141
|
+
*
|
|
142
|
+
* return (
|
|
143
|
+
* <Button onPress={handleBiometricLogin}>
|
|
144
|
+
* Sign in with {capabilities.biometricType === 'face' ? 'Face ID' : 'Touch ID'}
|
|
145
|
+
* </Button>
|
|
146
|
+
* );
|
|
147
|
+
* }
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export function useBiometrics(): UseBiometricsReturn {
|
|
151
|
+
const [capabilities, setCapabilities] = useState<BiometricCapabilities>({
|
|
152
|
+
isAvailable: false,
|
|
153
|
+
isEnrolled: false,
|
|
154
|
+
biometricType: "none",
|
|
155
|
+
securityLevel: LocalAuthentication.SecurityLevel.NONE,
|
|
156
|
+
});
|
|
157
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
158
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
159
|
+
|
|
160
|
+
// Check biometric capabilities on mount
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
async function checkCapabilities() {
|
|
163
|
+
try {
|
|
164
|
+
// Check if feature flag is enabled
|
|
165
|
+
if (!ENABLE_BIOMETRIC_AUTH) {
|
|
166
|
+
setIsLoading(false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const [hasHardware, isEnrolled, supportedTypes, securityLevel] =
|
|
171
|
+
await Promise.all([
|
|
172
|
+
LocalAuthentication.hasHardwareAsync(),
|
|
173
|
+
LocalAuthentication.isEnrolledAsync(),
|
|
174
|
+
LocalAuthentication.supportedAuthenticationTypesAsync(),
|
|
175
|
+
LocalAuthentication.getEnrolledLevelAsync(),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
setCapabilities({
|
|
179
|
+
isAvailable: hasHardware,
|
|
180
|
+
isEnrolled,
|
|
181
|
+
biometricType: getBiometricType(supportedTypes),
|
|
182
|
+
securityLevel,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Load user preference
|
|
186
|
+
if (hasHardware && isEnrolled) {
|
|
187
|
+
const enabled = await storage.get<boolean>(BIOMETRIC_ENABLED_KEY);
|
|
188
|
+
setIsEnabled(enabled ?? false);
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error("Failed to check biometric capabilities:", error);
|
|
192
|
+
} finally {
|
|
193
|
+
setIsLoading(false);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
checkCapabilities();
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Authenticate using biometrics
|
|
202
|
+
*/
|
|
203
|
+
const authenticate = useCallback(
|
|
204
|
+
async (options: AuthenticateOptions = {}): Promise<boolean> => {
|
|
205
|
+
if (!capabilities.isAvailable || !capabilities.isEnrolled) {
|
|
206
|
+
console.warn("Biometric authentication not available");
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = await LocalAuthentication.authenticateAsync({
|
|
212
|
+
promptMessage: options.promptMessage || "Authenticate to continue",
|
|
213
|
+
fallbackLabel: options.fallbackLabel || "Use passcode",
|
|
214
|
+
cancelLabel: options.cancelLabel || "Cancel",
|
|
215
|
+
disableDeviceFallback: options.allowDeviceCredentials === false,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return result.success;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error("Biometric authentication error:", error);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
[capabilities]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Enable biometric authentication
|
|
229
|
+
* Requires successful authentication first
|
|
230
|
+
*/
|
|
231
|
+
const enable = useCallback(async (): Promise<boolean> => {
|
|
232
|
+
if (!capabilities.isAvailable || !capabilities.isEnrolled) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Require authentication before enabling
|
|
237
|
+
const authenticated = await authenticate({
|
|
238
|
+
promptMessage: "Authenticate to enable biometric login",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (authenticated) {
|
|
242
|
+
await storage.set(BIOMETRIC_ENABLED_KEY, true);
|
|
243
|
+
setIsEnabled(true);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return false;
|
|
248
|
+
}, [capabilities, authenticate]);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Disable biometric authentication
|
|
252
|
+
*/
|
|
253
|
+
const disable = useCallback(async (): Promise<void> => {
|
|
254
|
+
await storage.set(BIOMETRIC_ENABLED_KEY, false);
|
|
255
|
+
setIsEnabled(false);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Toggle biometric authentication
|
|
260
|
+
*/
|
|
261
|
+
const toggle = useCallback(async (): Promise<boolean> => {
|
|
262
|
+
if (isEnabled) {
|
|
263
|
+
await disable();
|
|
264
|
+
return false;
|
|
265
|
+
} else {
|
|
266
|
+
return enable();
|
|
267
|
+
}
|
|
268
|
+
}, [isEnabled, enable, disable]);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
capabilities,
|
|
272
|
+
isEnabled,
|
|
273
|
+
isLoading,
|
|
274
|
+
authenticate,
|
|
275
|
+
enable,
|
|
276
|
+
disable,
|
|
277
|
+
toggle,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get a human-readable name for the biometric type
|
|
283
|
+
*/
|
|
284
|
+
export function getBiometricName(type: BiometricType): string {
|
|
285
|
+
switch (type) {
|
|
286
|
+
case "face":
|
|
287
|
+
return "Face ID";
|
|
288
|
+
case "fingerprint":
|
|
289
|
+
return "Touch ID";
|
|
290
|
+
case "iris":
|
|
291
|
+
return "Iris Scan";
|
|
292
|
+
default:
|
|
293
|
+
return "Biometrics";
|
|
294
|
+
}
|
|
295
|
+
}
|