refactor(manadeck-mobile): migrate from custom auth to @manacore/shared-auth

Replace 900+ lines of custom auth implementation (authService, tokenManager,
deviceManager, safeStorage) with ~280 lines wrapping @manacore/shared-auth.
Auth now goes through mana-core-auth directly instead of manadeck backend.

Backward-compatible API: all consumers (stores, apiClient, hooks) work
without changes thanks to wrapper maintaining the same export interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 12:55:28 +01:00
parent c59eba7285
commit 71277ba7aa
8 changed files with 835 additions and 3029 deletions

View file

@ -17,6 +17,7 @@
"web": "expo start --web"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@expo/ui": "~0.2.0-beta.6",
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",

View file

@ -1,11 +1,11 @@
/**
* API Client for ManaDeck Backend
* Replaces direct Supabase access with backend API calls
* Uses shared-auth TokenManager for automatic token handling
*/
import { authService } from './authService';
import { tokenManager } from './tokenManager';
const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3000';
const API_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3009';
interface ApiResponse<T> {
data: T | null;
@ -14,32 +14,11 @@ interface ApiResponse<T> {
class ApiClient {
private async getAuthHeaders(): Promise<Record<string, string>> {
const token = await authService.getAppToken();
const token = await tokenManager.getValidToken();
if (!token) {
throw new Error('Not authenticated');
}
// Check if token is valid, try to refresh if not
if (!authService.isTokenValidLocally(token)) {
const refreshToken = await authService.getRefreshToken();
if (refreshToken) {
try {
await authService.refreshTokens(refreshToken);
const newToken = await authService.getAppToken();
if (newToken) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${newToken}`,
};
}
} catch (error) {
console.error('Failed to refresh token:', error);
throw new Error('Session expired. Please sign in again.');
}
}
throw new Error('Session expired. Please sign in again.');
}
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
@ -87,8 +66,7 @@ class ApiClient {
// ============ Decks ============
async getDecks() {
const response = await this.request<{ decks: any[]; count: number }>('/api/decks');
return response;
return this.request<{ decks: any[]; count: number }>('/api/decks');
}
async getDeck(id: string) {

View file

@ -1,87 +1,115 @@
import { Platform } from 'react-native';
import type {
ManaUser,
AuthTokens,
SignInResponse,
SignUpResponse,
JwtPayload,
DeviceInfo,
} from '../types/auth';
import { DeviceManager } from '../utils/deviceManager';
import { safeStorage } from '../utils/safeStorage';
import { debug, info, warn, error as logError } from '../utils/logger';
// Get backend URL from environment with fallback
const BASE_API_URL =
process.env.EXPO_PUBLIC_API_URL || 'https://manadeck-backend-111768794939.europe-west3.run.app';
// Handle localhost for Android emulator
const getApiUrl = () => {
const url = BASE_API_URL;
if (Platform.OS === 'android' && (url.includes('localhost') || url.includes('127.0.0.1'))) {
return url.replace(/localhost|127\.0\.0\.1/, '10.0.2.2');
}
return url;
};
const API_URL = getApiUrl().replace(/\/$/, ''); // Remove trailing slash
info(`Using backend URL (${Platform.OS}):`, API_URL);
// Storage keys for auth tokens
const STORAGE_KEYS = {
APP_TOKEN: '@mana/appToken',
REFRESH_TOKEN: '@mana/refreshToken',
USER_EMAIL: '@mana/userEmail',
} as const;
/**
* Mana Core Authentication Service
* Handles all authentication operations through the NestJS backend
* Uses @manacore/shared-auth for unified auth across all apps
*/
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
isTokenValidLocally as sharedIsTokenValidLocally,
getUserFromToken as sharedGetUserFromToken,
decodeToken as sharedDecodeToken,
} from '@manacore/shared-auth';
import type { ManaUser, JwtPayload } from '../types/auth';
// Mana Core Auth URL
const AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// --- Adapters ---
const secureStoreAdapter = {
async getItem<T>(key: string): Promise<T | null> {
try {
const value = await SecureStore.getItemAsync(key);
return value ? JSON.parse(value) : null;
} catch {
return null;
}
},
async setItem(key: string, value: unknown): Promise<void> {
await SecureStore.setItemAsync(key, JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
},
};
const deviceAdapter = (() => {
let deviceId: string | null = null;
return {
async getDeviceInfo() {
if (!deviceId) {
deviceId = await SecureStore.getItemAsync('@device/id');
if (!deviceId) {
deviceId = `rn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await SecureStore.setItemAsync('@device/id', deviceId);
}
}
return {
deviceId,
deviceName: `${Platform.OS} Device`,
platform: 'react-native',
deviceType: Platform.OS,
};
},
async getStoredDeviceId() {
return deviceId || (await SecureStore.getItemAsync('@device/id'));
},
};
})();
const networkAdapter = {
async isDeviceConnected() {
return true;
},
async hasStableConnection() {
return true;
},
};
// --- Initialize shared-auth ---
setStorageAdapter(secureStoreAdapter);
setDeviceAdapter(deviceAdapter);
setNetworkAdapter(networkAdapter);
const _sharedAuth = createAuthService({ baseUrl: AUTH_URL });
const _sharedTokenManager = createTokenManager(_sharedAuth);
// --- Helpers ---
function toManaUser(
userData: { id: string; email: string; role?: string } | null
): ManaUser | null {
if (!userData) return null;
return {
id: userData.id,
email: userData.email,
role: userData.role || 'user',
name: userData.email?.split('@')[0] || 'User',
};
}
// --- Auth Service (backward-compatible API) ---
export const authService = {
/**
* Sign in with email and password
*/
signIn: async (
email: string,
password: string
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
try {
// Get device information
const deviceInfo = await DeviceManager.getDeviceInfo();
const response = await fetch(`${API_URL}/v1/auth/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
deviceInfo,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Authentication failed');
const result = await _sharedAuth.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Authentication failed' };
}
const data: SignInResponse = await response.json();
const { appToken, refreshToken } = data;
// Store tokens securely
await Promise.all([
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, email),
]);
// Extract user from JWT token
const user = authService.getUserFromToken(appToken);
return { success: true, user: user || undefined };
const userData = await _sharedAuth.getUserFromToken();
return { success: true, user: toManaUser(userData) || undefined };
} catch (error) {
logError('Sign in error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
@ -89,50 +117,25 @@ export const authService = {
}
},
/**
* Sign up with email, password, and username
*/
signUp: async (
email: string,
password: string,
username: string
_username?: string
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
try {
// Get device information
const deviceInfo = await DeviceManager.getDeviceInfo();
const response = await fetch(`${API_URL}/v1/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
username,
deviceInfo,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Sign up failed');
// TODO: username is not supported by mana-core-auth signUp - add profile update after registration
const result = await _sharedAuth.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Sign up failed' };
}
const data: SignUpResponse = await response.json();
const { appToken, refreshToken } = data;
if (result.needsVerification) {
return { success: true, error: 'Please verify your email before signing in.' };
}
// Store tokens securely
await Promise.all([
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, email),
]);
// Extract user from JWT token
const user = authService.getUserFromToken(appToken);
return { success: true, user: user || undefined };
// Auto sign-in after successful signup
return authService.signIn(email, password);
} catch (error) {
logError('Sign up error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
@ -140,371 +143,97 @@ export const authService = {
}
},
/**
* Sign out the current user
*/
signOut: async (): Promise<void> => {
try {
const refreshToken = await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
if (refreshToken) {
// Notify backend of logout
await fetch(`${API_URL}/v1/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
}).catch((err) => logError('Error logging out on server:', err));
}
// Clear all auth data and device ID
await authService.clearAuthStorage();
// Also clear device ID so a new one is generated on next login
await DeviceManager.clearDeviceId();
await _sharedAuth.signOut();
} catch (error) {
logError('Error signing out:', error);
// Still clear local storage even if server request fails
await authService.clearAuthStorage();
try {
await DeviceManager.clearDeviceId();
} catch (e) {
logError('Error clearing device ID:', e);
}
console.error('Sign out error:', error);
}
},
/**
* Refresh access token using refresh token
* Updated signature to match TokenManager requirements
*/
resetPassword: async (email: string): Promise<{ success: boolean; error?: string }> => {
try {
const result = await _sharedAuth.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Failed to send reset email' };
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
getAppToken: async (): Promise<string | null> => {
return await _sharedAuth.getAppToken();
},
getRefreshToken: async (): Promise<string | null> => {
return await _sharedAuth.getRefreshToken();
},
refreshTokens: async (
currentRefreshToken: string
_currentRefreshToken: string
): Promise<{
appToken: string;
refreshToken: string;
userData?: { id: string; email: string; role: string } | null;
}> => {
try {
// Check if device ID has changed (which would invalidate the refresh token)
const storedDeviceId = await DeviceManager.getStoredDeviceId();
const deviceInfo = await DeviceManager.getDeviceInfo();
if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) {
logError('Device ID mismatch detected during token refresh', {
stored: storedDeviceId,
current: deviceInfo.deviceId,
});
throw new Error('Device ID has changed. Please sign in again.');
}
// Debug log device info
debug('Device info for token refresh:', deviceInfo);
debug('Refreshing tokens');
const response = await fetch(`${API_URL}/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
debug('Token refresh failed with status:', response.status, errorData);
// If refresh fails with 401, it might be due to old tokens without device info
if (response.status === 401 && errorData.message === 'Invalid refresh token') {
debug('Refresh token invalid - likely due to old token without device info');
throw new Error('Session expired. Please sign in again.');
}
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
debug('Token refresh response:', responseData);
// Note: If grace period was used, backend returns the previously generated token
// This is normal and expected - the frontend should always save whatever token is returned
const { appToken, refreshToken, deviceId, grace_period_used, grace_expires_at } =
responseData;
// Log grace period usage if present
if (grace_period_used) {
debug('Token refresh used grace period - returning previously rotated token');
debug('Grace period expires at:', grace_expires_at);
} else if (grace_expires_at) {
debug('Token refreshed with new grace period until:', grace_expires_at);
}
debug(
'Extracted tokens - appToken:',
!!appToken,
'refreshToken:',
!!refreshToken,
'deviceId:',
deviceId
);
// Validate that we received tokens
if (!appToken || !refreshToken) {
logError('Missing tokens in refresh response:', {
appToken: !!appToken,
refreshToken: !!refreshToken,
});
throw new Error('Invalid response from token refresh - missing tokens');
}
debug('Tokens refreshed successfully');
// Store new tokens in local storage
await safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken);
await safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
// Extract user data from new token
const user = authService.getUserFromToken(appToken);
const userData = user ? { id: user.id, email: user.email, role: user.role } : null;
return { appToken, refreshToken, userData };
} catch (error) {
debug('Token refresh error:', error);
throw error; // TokenManager will handle error classification
}
// shared-auth handles refresh internally - just trigger it
await _sharedAuth.refreshTokens();
const appToken = (await _sharedAuth.getAppToken()) || '';
const refreshToken = (await _sharedAuth.getRefreshToken()) || '';
const user = appToken ? sharedGetUserFromToken(appToken) : null;
return {
appToken,
refreshToken,
userData: user ? { id: user.id, email: user.email, role: user.role || 'user' } : null,
};
},
/**
* Legacy refreshToken method (for backward compatibility)
*/
refreshToken: async (): Promise<AuthTokens | null> => {
try {
const currentRefreshToken = await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
if (!currentRefreshToken) {
throw new Error('No refresh token available');
}
const result = await authService.refreshTokens(currentRefreshToken);
return { appToken: result.appToken, refreshToken: result.refreshToken };
} catch (error) {
logError('Token refresh error:', error);
await authService.clearAuthStorage();
return null;
}
},
/**
* Reset password via email
*/
resetPassword: async (email: string): Promise<{ success: boolean; error?: string }> => {
try {
const response = await fetch(`${API_URL}/v1/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to send reset email');
}
return { success: true };
} catch (error) {
logError('Password reset error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
/**
* Get current app token
*/
getAppToken: async (): Promise<string | null> => {
return await safeStorage.getItem<string>(STORAGE_KEYS.APP_TOKEN);
},
/**
* Get current refresh token
*/
getRefreshToken: async (): Promise<string | null> => {
return await safeStorage.getItem<string>(STORAGE_KEYS.REFRESH_TOKEN);
},
/**
* Clear all auth storage
*/
clearAuthStorage: async (): Promise<void> => {
// Clear Manadeck auth keys
await Promise.all(Object.values(STORAGE_KEYS).map((key) => safeStorage.removeItem(key)));
// Also clear any legacy Memoro/Mana auth keys if they exist
const legacyKeys = [
'appToken',
'refreshToken',
'manaToken',
'deviceId',
'@auth/appToken',
'@auth/refreshToken',
'@auth/userEmail',
];
await Promise.all(legacyKeys.map((key) => safeStorage.removeItem(key)));
await _sharedAuth.signOut();
},
/**
* Check if user is authenticated (has valid token)
*/
isAuthenticated: async (): Promise<boolean> => {
const appToken = await safeStorage.getItem<string>(STORAGE_KEYS.APP_TOKEN);
if (!appToken) return false;
// Check if token is expired
try {
const payload = authService.decodeToken(appToken);
if (!payload) return false;
// Check expiration (with 30 second buffer)
return Date.now() < payload.exp * 1000 - 30000;
} catch {
return false;
}
return await _sharedAuth.isAuthenticated();
},
/**
* Decode JWT token payload
*/
decodeToken: (token: string): JwtPayload | null => {
try {
const base64Url = token.split('.')[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
return payload as JwtPayload;
} catch (error) {
logError('Token decode error:', error);
return null;
}
return sharedDecodeToken(token) as JwtPayload | null;
},
/**
* Extract user information from JWT token
*/
getUserFromToken: (token: string): ManaUser | null => {
try {
const payload = authService.decodeToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role,
name: payload.email?.split('@')[0] || 'User',
organizationId: payload.app_settings?.b2b?.organizationId,
metadata: payload.app_settings,
};
} catch (error) {
logError('Error extracting user from token:', error);
return null;
}
const userData = sharedGetUserFromToken(token);
return toManaUser(userData);
},
isTokenValidLocally: (token: string): boolean => {
return sharedIsTokenValidLocally(token);
},
/**
* Get user credits balance
*/
getCredits: async (): Promise<number | null> => {
try {
const token = await authService.getAppToken();
if (!token) return null;
const response = await fetch(`${API_URL}/v1/auth/credits`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch credits');
}
const data = await response.json();
return data.credits || 0;
} catch (error) {
logError('Error fetching credits:', error);
const credits = await _sharedAuth.getUserCredits();
return credits?.credits ?? null;
} catch {
return null;
}
},
/**
* Check if token is valid locally (without HTTP calls)
* Used by TokenManager to avoid unnecessary network requests
* @param token JWT token to validate
* @returns True if token exists and is not expired
*/
isTokenValidLocally: (token: string): boolean => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
// Check if token is expired (with 10 second buffer for safety)
const bufferTime = 10 * 1000; // 10 seconds in milliseconds
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
return currentTime < expiryTime - bufferTime;
} catch (e) {
console.debug('Error validating token locally:', e);
return false;
}
},
/**
* Sign in with Google
*/
signInWithGoogle: async (
idToken: string
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
try {
// Get device information
const deviceInfo = await DeviceManager.getDeviceInfo();
const response = await fetch(`${API_URL}/v1/auth/google-signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: idToken,
deviceInfo,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Google sign-in failed');
const result = await _sharedAuth.signInWithGoogle(idToken);
if (!result.success) {
return { success: false, error: result.error || 'Google sign-in failed' };
}
const data: SignInResponse = await response.json();
const { appToken, refreshToken } = data;
// Store tokens securely
await Promise.all([
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
]);
// Extract user from JWT token
const user = authService.getUserFromToken(appToken);
if (user?.email) {
await safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, user.email);
}
return { success: true, user: user || undefined };
const userData = await _sharedAuth.getUserFromToken();
return { success: true, user: toManaUser(userData) || undefined };
} catch (error) {
logError('Google sign-in error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
@ -512,48 +241,17 @@ export const authService = {
}
},
/**
* Sign in with Apple
*/
signInWithApple: async (
identityToken: string
): Promise<{ success: boolean; user?: ManaUser; error?: string }> => {
try {
// Get device information
const deviceInfo = await DeviceManager.getDeviceInfo();
const response = await fetch(`${API_URL}/v1/auth/apple-signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: identityToken,
deviceInfo,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Apple sign-in failed');
const result = await _sharedAuth.signInWithApple(identityToken);
if (!result.success) {
return { success: false, error: result.error || 'Apple sign-in failed' };
}
const data: SignInResponse = await response.json();
const { appToken, refreshToken } = data;
// Store tokens securely
await Promise.all([
safeStorage.setItem(STORAGE_KEYS.APP_TOKEN, appToken),
safeStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken),
]);
// Extract user from JWT token
const user = authService.getUserFromToken(appToken);
if (user?.email) {
await safeStorage.setItem(STORAGE_KEYS.USER_EMAIL, user.email);
}
return { success: true, user: user || undefined };
const userData = await _sharedAuth.getUserFromToken();
return { success: true, user: toManaUser(userData) || undefined };
} catch (error) {
logError('Apple sign-in error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
@ -561,9 +259,25 @@ export const authService = {
}
},
/**
* Event callback for token refresh
* Set this from AuthContext to receive user data updates after token refresh
*/
onTokenRefresh: null as ((userData: { id: string; email: string; role: string }) => void) | null,
};
// Wire up token refresh callback
_sharedAuth.onTokenRefresh = async () => {
if (authService.onTokenRefresh) {
const token = await _sharedAuth.getAppToken();
if (token) {
const user = sharedGetUserFromToken(token);
if (user) {
authService.onTokenRefresh({
id: user.id,
email: user.email,
role: user.role || 'user',
});
}
}
}
};
// Export shared-auth instances for direct use
export { _sharedAuth, _sharedTokenManager };

View file

@ -1,620 +1,51 @@
import { authService } from './authService';
import { debug, info, warn, error as logError } from '../utils/logger';
// Token state management
export enum TokenState {
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
VALID = 'valid',
}
// Request queue item
interface QueuedRequest {
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
}
// Token refresh result
interface TokenRefreshResult {
success: boolean;
token?: string;
error?: string;
}
// Observer for token state changes
type TokenStateObserver = (state: TokenState, token?: string) => void;
/**
* Centralized token manager to handle all authentication token operations
* and eliminate race conditions in token refresh
* Token Manager - wraps @manacore/shared-auth TokenManager
* Maintains backward-compatible API for existing consumers
*/
class TokenManager {
private state: TokenState = TokenState.IDLE;
private refreshPromise: Promise<TokenRefreshResult> | null = null;
private requestQueue: QueuedRequest[] = [];
private observers: Set<TokenStateObserver> = new Set();
// Configuration
private readonly MAX_QUEUE_SIZE = 50;
private readonly QUEUE_TIMEOUT_MS = 30000; // 30 seconds
private readonly MAX_REFRESH_ATTEMPTS = 3;
private refreshAttempts = 0;
private lastRefreshTime = 0;
private readonly REFRESH_COOLDOWN_MS = 5000; // 5 second cooldown
import { _sharedTokenManager } from './authService';
export { TokenState } from '@manacore/shared-auth';
private static instance: TokenManager;
type TokenStateObserver = (state: string, token?: string | null) => void;
private constructor() {
// Start with initial state check
this.checkInitialState();
}
static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
export const tokenManager = {
/**
* Get a valid access token, automatically refreshing if needed
*/
getValidToken: (): Promise<string | null> => {
return _sharedTokenManager.getValidToken();
},
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
subscribe: (callback: TokenStateObserver) => {
return _sharedTokenManager.subscribe(callback);
},
/**
* Notify all observers of state changes
* Clear all tokens (triggers sign-out)
*/
private notifyObservers(state: TokenState, token?: string): void {
this.observers.forEach((observer) => {
try {
observer(state, token);
} catch (err) {
debug('Error in token state observer:', err);
}
});
}
clearTokens: async (): Promise<void> => {
const { authService } = await import('./authService');
await authService.signOut();
},
/**
* Set the current token state
* Handle a 401 response by refreshing the token and retrying the request
*/
private setState(newState: TokenState, token?: string): void {
if (this.state !== newState) {
debug(`TokenManager: State transition ${this.state} -> ${newState}`);
this.state = newState;
this.notifyObservers(newState, token);
}
}
/**
* Get current token state
*/
getState(): TokenState {
return this.state;
}
/**
* Check initial token state on startup
*/
private async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
this.setState(TokenState.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
this.setState(TokenState.VALID, token);
} else {
this.setState(TokenState.EXPIRED);
}
} catch (error) {
debug('Error checking initial token state:', error);
this.setState(TokenState.EXPIRED);
}
}
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
this.setState(TokenState.VALID, currentToken);
return currentToken;
handle401Response: async (
input: string | URL | Request,
init?: RequestInit
): Promise<Response> => {
const token = await _sharedTokenManager.getValidToken();
if (!token) {
throw new Error('Session expired. Please sign in again.');
}
// If there's no token at all (fresh install), don't attempt refresh
if (!currentToken) {
debug('TokenManager: No token available, skipping refresh (fresh install)');
this.setState(TokenState.EXPIRED);
return null;
}
// Token is expired or invalid, attempt refresh
debug('TokenManager: Current token invalid, attempting refresh...');
const refreshResult = await this.refreshToken();
if (refreshResult.success && refreshResult.token) {
debug('TokenManager: Token refresh successful in getValidToken');
return refreshResult.token;
} else {
debug('TokenManager: Token refresh failed in getValidToken:', refreshResult.error);
return null;
}
}
/**
* Handle 401 response by either refreshing token or queueing request
*/
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
// Check if we're already refreshing
if (this.state === TokenState.REFRESHING && this.refreshPromise) {
return this.queueRequest(input, init);
}
// Start token refresh
const refreshResult = await this.refreshToken();
if (refreshResult.success && refreshResult.token) {
// Retry the request with new token
return this.retryRequestWithToken(input, init, refreshResult.token);
} else {
// Check if we're offline before throwing error
if (refreshResult.error === 'offline') {
debug('TokenManager: Offline during 401 handling, throwing network error');
throw new Error('Network request failed: Device offline');
}
// Refresh failed, propagate error
throw new Error(refreshResult.error || 'Token refresh failed');
}
}
/**
* Queue a request during token refresh
*/
private async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
// Check queue size limit
if (this.requestQueue.length >= this.MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
this.requestQueue.push(queueItem);
// Set timeout for queued request
setTimeout(() => {
this.removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, this.QUEUE_TIMEOUT_MS);
debug(
`TokenManager: Queued request ${queueItem.id}, queue size: ${this.requestQueue.length}`
);
});
}
/**
* Remove request from queue by ID
*/
private removeFromQueue(requestId: string): void {
const index = this.requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
this.requestQueue.splice(index, 1);
}
}
/**
* Refresh the authentication token with progressive backoff retry logic
*/
private async refreshToken(): Promise<TokenRefreshResult> {
// Check cooldown to prevent rapid successive refresh attempts
const now = Date.now();
if (now - this.lastRefreshTime < this.REFRESH_COOLDOWN_MS) {
debug('TokenManager: Refresh cooldown active, skipping refresh');
return { success: false, error: 'Refresh cooldown active' };
}
// Check max attempts
if (this.refreshAttempts >= this.MAX_REFRESH_ATTEMPTS) {
debug('TokenManager: Max refresh attempts reached');
await this.handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
// If already refreshing, wait for existing promise
if (this.refreshPromise) {
debug('TokenManager: Waiting for existing refresh to complete');
return await this.refreshPromise;
}
this.setState(TokenState.REFRESHING);
this.lastRefreshTime = now;
// Use enhanced refresh with retry logic
this.refreshPromise = this.performTokenRefreshWithRetry();
try {
const result = await this.refreshPromise;
if (result.success) {
this.refreshAttempts = 0; // Reset on success
this.setState(TokenState.VALID, result.token);
await this.processQueuedRequests(result.token!);
} else {
this.refreshAttempts++;
this.setState(TokenState.EXPIRED);
await this.rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
this.refreshPromise = null;
}
}
/**
* Enhanced token refresh with progressive backoff for network issues
*/
private async performTokenRefreshWithRetry(): Promise<TokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000]; // Progressive backoff: 0ms, 1s, 2s, 5s
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
// Wait for retry delay (except first attempt)
if (retryDelays[attempt] > 0) {
debug(
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
);
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
}
const result = await this.performTokenRefresh();
if (result.success) {
if (attempt > 0) {
debug(`TokenManager: Token refresh succeeded on attempt ${attempt + 1}`);
}
return result;
}
// Handle specific server-side errors that shouldn't be retried
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error === 'invalid_token_state' ||
result.error === 'token_collision' ||
(result.error && result.error.includes('Device ID has changed'))
) {
debug('TokenManager: Non-retryable error:', result.error);
return result; // Don't retry permanent auth errors
}
// Handle offline state - don't count as failure
if (result.error === 'offline') {
debug('TokenManager: Device offline, preserving auth state');
return { success: false, error: 'offline' }; // Return without clearing tokens
}
// Handle unstable connection - should retry with longer delay
if (result.error === 'unstable_connection') {
debug('TokenManager: Connection unstable, will retry with longer delay');
// Use a longer delay for unstable connections
await new Promise((resolve) => setTimeout(resolve, 2000));
// Continue to next retry attempt
}
// Handle refresh_in_progress or rotation_in_progress with shorter delay
if (result.error === 'refresh_in_progress' || result.error === 'rotation_in_progress') {
debug('TokenManager: Token rotation in progress, waiting...');
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1s for other refresh
// Try one more time after waiting
const retryResult = await this.performTokenRefresh();
if (retryResult.success) {
return retryResult;
}
}
lastError = new Error(result.error || 'Token refresh failed');
// If this is the last attempt, return the error
if (attempt === retryDelays.length - 1) {
break;
}
} catch (error) {
lastError = error;
// Check if this is a recoverable network error
const isRecoverable = this.isRecoverableError(error);
if (!isRecoverable) {
debug('TokenManager: Non-recoverable error, stopping retries:', error);
break; // Don't retry non-network errors
}
debug(`TokenManager: Network error on attempt ${attempt + 1}, will retry:`, error);
// If this is the last attempt, break out
if (attempt === retryDelays.length - 1) {
break;
}
}
}
// All retries failed
debug('TokenManager: All retry attempts failed');
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
/**
* Perform the actual token refresh operation
*/
private async performTokenRefresh(): Promise<TokenRefreshResult> {
try {
debug('TokenManager: Starting token refresh');
// Check network status first - use stable connection check for critical operations
const { hasStableConnection, isDeviceConnected } = await import('~/utils/networkErrorUtils');
// First check basic connectivity
const isOnline = await isDeviceConnected();
if (!isOnline) {
debug('TokenManager: Device offline, skipping refresh');
// Return success with current token if it's not expired locally
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
return { success: true, token: currentToken };
}
return { success: false, error: 'offline' };
}
// For token refresh, ensure we have a stable connection
const isStable = await hasStableConnection();
if (!isStable) {
debug('TokenManager: Connection not stable yet, will retry');
// Return a specific error that indicates we should retry
return { success: false, error: 'unstable_connection' };
}
const refreshToken = await authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken, refreshToken: newRefreshToken, userData } = refreshResult;
if (!appToken || !newRefreshToken) {
throw new Error('Invalid tokens received from refresh');
}
// Note: authService.refreshTokens() already saves tokens to storage
// No need to call updateTokens() again - this was causing race conditions
// If we have user data from the refresh, notify via the callback
if (userData && authService.onTokenRefresh) {
debug('TokenManager: Notifying auth context with fresh user data');
authService.onTokenRefresh(userData);
}
debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
debug('TokenManager: Token refresh failed:', error);
// Determine if this is a recoverable error
const isRecoverable = this.isRecoverableError(error);
if (!isRecoverable) {
await this.handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
/**
* Check if an error is recoverable (network issues vs auth failures)
*/
private isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const networkErrors = [
'network',
'Network',
'fetch',
'connection',
'timeout',
'Failed to fetch',
'NetworkError',
'TypeError',
'ERR_NETWORK',
'ERR_INTERNET_DISCONNECTED',
'ECONNREFUSED',
'ENOTFOUND',
'ETIMEDOUT',
'Unable to resolve host',
'Request failed',
];
const authErrors = [
'401',
'403',
'Unauthorized',
'Forbidden',
'Invalid token',
'Token expired',
'jwt expired',
'jwt malformed',
];
const errorString = `${error.message} ${error.name}`.toLowerCase();
const isNetworkError = networkErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
const isAuthError = authErrors.some((keyword) => errorString.includes(keyword.toLowerCase()));
// Network errors are recoverable unless they also contain auth errors
return isNetworkError && !isAuthError;
}
/**
* Handle permanent refresh failure
*/
private async handleRefreshFailure(): Promise<void> {
debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
this.setState(TokenState.EXPIRED);
// Don't automatically redirect here - let the AuthContext handle logout
// The AuthContext will handle the logout flow properly
} catch (error) {
debug('Error in handleRefreshFailure:', error);
}
}
/**
* Check if we should attempt token refresh (has valid refresh token)
*/
async canAttemptRefresh(): Promise<boolean> {
try {
const refreshToken = await authService.getRefreshToken();
return !!refreshToken;
} catch (error) {
debug('Error checking refresh token availability:', error);
return false;
}
}
/**
* Process all queued requests with the new token
*/
private async processQueuedRequests(token: string): Promise<void> {
debug(`TokenManager: Processing ${this.requestQueue.length} queued requests`);
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
try {
const response = await this.retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
/**
* Reject all queued requests with error
*/
private async rejectQueuedRequests(error: string): Promise<void> {
debug(`TokenManager: Rejecting ${this.requestQueue.length} queued requests`);
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
/**
* Retry a request with a new token
*/
private async retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
/**
* Reset the token manager state (for testing or logout)
*/
reset(): void {
this.state = TokenState.IDLE;
this.refreshPromise = null;
this.refreshAttempts = 0;
this.lastRefreshTime = 0;
// Reject all queued requests
const requests = [...this.requestQueue];
this.requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
debug('TokenManager: Reset completed');
}
/**
* Clear tokens and reset state (for logout)
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
// Skip EXPIRED state transition during logout to prevent observer loops
// Go directly to reset which sets IDLE state
this.reset();
} catch (error) {
debug('Error clearing tokens:', error);
// On error, still reset to ensure clean state
this.reset();
}
}
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: this.requestQueue.length,
state: this.state,
refreshAttempts: this.refreshAttempts,
};
}
}
// Export singleton instance
export const tokenManager = TokenManager.getInstance();
export default tokenManager;
return fetch(input, { ...init, headers });
},
};

View file

@ -1,6 +1,5 @@
import { create } from 'zustand';
import { authService } from '../services/authService';
import { tokenManager, TokenState } from '../services/tokenManager';
import type { ManaUser } from '../types/auth';
interface AuthState {
@ -37,7 +36,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Register callback for token refresh to update user data
authService.onTokenRefresh = (userData) => {
console.debug('Token refreshed, updating user data in store');
const currentUser = get().user;
if (currentUser) {
set({
@ -51,45 +49,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
};
// Subscribe to TokenManager state changes
tokenManager.subscribe((state, token) => {
console.debug(`TokenManager state changed: ${state}`);
if (state === TokenState.EXPIRED) {
// Token expired, clear user
const currentUser = get().user;
if (currentUser) {
console.debug('Token expired, clearing user from store');
set({ user: null });
// Check if user is authenticated
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const appToken = await authService.getAppToken();
if (appToken) {
const user = authService.getUserFromToken(appToken);
if (user) {
set({ user, isInitialized: true });
return;
}
}
});
// Check if user is authenticated via Mana Core
const appToken = await authService.getAppToken();
if (appToken && authService.isTokenValidLocally(appToken)) {
// Token exists and is valid locally
const user = authService.getUserFromToken(appToken);
if (user) {
set({
user,
isInitialized: true,
});
} else {
set({ isInitialized: true });
}
} else if (appToken) {
// Token exists but might be expired - let TokenManager handle refresh
console.debug('Token exists but may be expired, will attempt refresh on first API call');
set({ isInitialized: true });
} else {
// No token at all
await authService.clearAuthStorage();
set({ isInitialized: true });
}
set({ isInitialized: true });
} catch (error) {
console.error('Auth initialization error:', error);
// Don't throw - continue with unauthenticated state
set({ error: null, isInitialized: true, user: null });
} finally {
set({ isLoading: false });
@ -106,9 +82,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(result.error || 'Failed to sign in');
}
set({
user: result.user || null,
});
set({ user: result.user || null });
} catch (error: any) {
set({ error: error.message || 'Failed to sign in' });
throw error;
@ -127,9 +101,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(result.error || 'Failed to sign up');
}
set({
user: result.user || null,
});
set({ user: result.user || null });
} catch (error: any) {
set({ error: error.message || 'Failed to sign up' });
throw error;
@ -148,9 +120,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(result.error || 'Google sign-in failed');
}
set({
user: result.user || null,
});
set({ user: result.user || null });
} catch (error: any) {
set({ error: error.message || 'Google sign-in failed' });
throw error;
@ -169,9 +139,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(result.error || 'Apple sign-in failed');
}
set({
user: result.user || null,
});
set({ user: result.user || null });
} catch (error: any) {
set({ error: error.message || 'Apple sign-in failed' });
throw error;
@ -186,12 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await authService.signOut();
// Clear TokenManager state
await tokenManager.clearTokens();
set({
user: null,
});
set({ user: null });
} catch (error: any) {
set({ error: error.message || 'Failed to sign out' });
throw error;
@ -224,11 +187,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const user = get().user;
if (!user) throw new Error('No user logged in');
// TODO: Implement profile update via backend API
// For now, profiles are managed via Mana Core Auth
console.warn('Profile update not yet implemented via backend API');
// TODO: Implement profile update via mana-core-auth API
console.warn('Profile update not yet implemented via mana-core-auth API');
// Update local user state with the new values
set({
user: {
...user,

View file

@ -1,163 +0,0 @@
import * as Device from 'expo-device';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import type { DeviceInfo } from '../types/auth';
// Storage wrapper that uses localStorage on web and SecureStore on native
const storage = {
async getItem(key: string): Promise<string | null> {
try {
if (Platform.OS === 'web') {
return localStorage.getItem(key);
}
return await SecureStore.getItemAsync(key);
} catch (error) {
console.error('Error reading from storage:', error);
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
try {
if (Platform.OS === 'web') {
localStorage.setItem(key, value);
} else {
await SecureStore.setItemAsync(key, value);
}
} catch (error) {
console.error('Error writing to storage:', error);
throw error;
}
},
async deleteItem(key: string): Promise<void> {
try {
if (Platform.OS === 'web') {
localStorage.removeItem(key);
} else {
await SecureStore.deleteItemAsync(key);
}
} catch (error) {
console.error('Error deleting from storage:', error);
}
},
};
export class DeviceManager {
private static DEVICE_ID_KEY = 'mana_device_id';
static async getDeviceInfo(): Promise<DeviceInfo> {
// Get or generate device ID
let deviceId = await storage.getItem(this.DEVICE_ID_KEY);
// Debug log for loaded device ID
if (deviceId) {
console.debug(`Loaded existing device ID: ${deviceId}`);
}
if (!deviceId) {
// Generate a new UUID
deviceId = await this.generateDeviceId();
// Try to store it persistently with retry logic
let stored = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await storage.setItem(this.DEVICE_ID_KEY, deviceId);
// Verify it was actually stored
const verifiedId = await storage.getItem(this.DEVICE_ID_KEY);
if (verifiedId === deviceId) {
console.debug(`Device ID stored successfully on attempt ${attempt}`);
stored = true;
break;
} else {
console.warn(`Device ID verification failed on attempt ${attempt}`);
}
} catch (error) {
console.error(`Failed to store device ID on attempt ${attempt}:`, error);
}
}
if (!stored) {
console.error('Failed to persist device ID after 3 attempts - using session-only ID');
// The ID will work for this session but may change on app restart
} else {
console.debug('New device ID created and stored successfully');
}
}
// Always validate we have a device ID
if (!deviceId) {
throw new Error('Unable to generate device identifier');
}
// Get device name
const deviceName = await this.getDeviceName();
const deviceInfo: DeviceInfo = {
deviceId,
deviceName,
deviceType: Platform.OS === 'web' ? 'web' : (Platform.OS as 'ios' | 'android'),
userAgent: this.getUserAgent(),
};
console.debug('Device info:', {
deviceId: deviceId.substring(0, 8) + '...',
deviceName,
deviceType: deviceInfo.deviceType,
});
return deviceInfo;
}
private static async getDeviceName(): Promise<string> {
if (Device.deviceName) {
return Device.deviceName;
}
// Fallback device names
const deviceModel = Device.modelName || 'Unknown';
const osName = Device.osName || Platform.OS;
return `${deviceModel} (${osName})`;
}
private static getUserAgent(): string {
// For Expo SDK 51+, use expoConfig instead of manifest
const appName = Constants.expoConfig?.name || Constants.manifest?.name || 'Manadeck';
const appVersion = Constants.expoConfig?.version || Constants.manifest?.version || '1.0.0';
const osName = Device.osName || Platform.OS;
const osVersion = Device.osVersion || 'Unknown';
const modelName = Device.modelName || 'Unknown Device';
return `${appName}/${appVersion} (${osName} ${osVersion}; ${modelName})`;
}
private static async generateDeviceId(): Promise<string> {
// Always generate a stable UUID instead of using unreliable platform-specific IDs
// Constants.deviceId is deprecated and can change between app launches/installs
return this.generateUUID();
}
private static generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
static async clearDeviceId(): Promise<void> {
await storage.deleteItem(this.DEVICE_ID_KEY);
}
static async getStoredDeviceId(): Promise<string | null> {
try {
return await storage.getItem(this.DEVICE_ID_KEY);
} catch (error) {
console.error('Error retrieving stored device ID:', error);
return null;
}
}
}

View file

@ -1,63 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* Type-safe wrapper for AsyncStorage with error handling
*/
export const safeStorage = {
/**
* Store a value in AsyncStorage
* @param key Storage key
* @param value Value to store (automatically converted to JSON)
*/
setItem: async <T>(key: string, value: T): Promise<void> => {
try {
// Skip saving if value is undefined
if (value === undefined) {
console.warn(`Attempted to save undefined value for key: ${key}. Skipping.`);
return;
}
const jsonValue = JSON.stringify(value);
await AsyncStorage.setItem(key, jsonValue);
} catch (e) {
console.error('Error saving data to storage:', e);
}
},
/**
* Retrieve a value from AsyncStorage
* @param key Storage key
* @returns The stored value or null if not found
*/
getItem: async <T>(key: string): Promise<T | null> => {
try {
const jsonValue = await AsyncStorage.getItem(key);
return jsonValue != null ? JSON.parse(jsonValue) : null;
} catch (e) {
console.error('Error reading data from storage:', e);
return null;
}
},
/**
* Remove a value from AsyncStorage
* @param key Storage key
*/
removeItem: async (key: string): Promise<void> => {
try {
await AsyncStorage.removeItem(key);
} catch (e) {
console.error('Error removing data from storage:', e);
}
},
/**
* Clear all values from AsyncStorage
*/
clear: async (): Promise<void> => {
try {
await AsyncStorage.clear();
} catch (e) {
console.error('Error clearing storage:', e);
}
},
};

2231
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff