mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 18:59:40 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
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
|
|
*/
|
|
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 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 };
|
|
} catch (error) {
|
|
logError('Sign in error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sign up with email, password, and username
|
|
*/
|
|
signUp: async (
|
|
email: string,
|
|
password: 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');
|
|
}
|
|
|
|
const data: SignUpResponse = 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 };
|
|
} catch (error) {
|
|
logError('Sign up error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
} 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);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh access token using refresh token
|
|
* Updated signature to match TokenManager requirements
|
|
*/
|
|
refreshTokens: async (
|
|
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
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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)));
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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);
|
|
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 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 };
|
|
} catch (error) {
|
|
logError('Google sign-in error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 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 };
|
|
} catch (error) {
|
|
logError('Apple sign-in error:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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,
|
|
};
|