mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 21:59:40 +02:00
Add ability to resend verification email when login fails with "Email not verified" error. Implemented across all 14 apps using Mana Core Auth. Changes: - Add POST /api/v1/auth/resend-verification endpoint to mana-core-auth - Add resendVerificationEmail method to shared-auth client - Update LoginPage component with resend UI and translations - Add resendVerificationEmail to all app auth stores - Add translations for de, en, fr, es, it - Add PlantaLogo to shared-branding - Migrate planta login to shared LoginPage component
624 lines
17 KiB
TypeScript
624 lines
17 KiB
TypeScript
import type {
|
|
AuthServiceConfig,
|
|
AuthEndpoints,
|
|
AuthResult,
|
|
TokenRefreshResult,
|
|
UserData,
|
|
StorageKeys,
|
|
CreditBalance,
|
|
B2BInfo,
|
|
} from '../types';
|
|
import { getStorageAdapter } from '../adapters/storage';
|
|
import { getDeviceAdapter } from '../adapters/device';
|
|
import {
|
|
decodeToken,
|
|
isTokenValidLocally,
|
|
getUserFromToken,
|
|
getB2BInfo as getB2BInfoFromToken,
|
|
shouldDisableRevenueCat as checkRevenueCat,
|
|
isB2BUser as checkB2BUser,
|
|
getAppSettings as getAppSettingsFromToken,
|
|
} from './jwtUtils';
|
|
|
|
/**
|
|
* Default storage keys
|
|
*/
|
|
const DEFAULT_STORAGE_KEYS: StorageKeys = {
|
|
APP_TOKEN: '@auth/appToken',
|
|
REFRESH_TOKEN: '@auth/refreshToken',
|
|
USER_EMAIL: '@auth/userEmail',
|
|
};
|
|
|
|
/**
|
|
* Default API endpoints - Updated for Mana Core Auth
|
|
*/
|
|
const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
|
signIn: '/api/v1/auth/login',
|
|
signUp: '/api/v1/auth/register',
|
|
signOut: '/api/v1/auth/logout',
|
|
refresh: '/api/v1/auth/refresh',
|
|
validate: '/api/v1/auth/validate',
|
|
forgotPassword: '/api/v1/auth/forgot-password',
|
|
resetPassword: '/api/v1/auth/reset-password',
|
|
resendVerification: '/api/v1/auth/resend-verification',
|
|
googleSignIn: '/api/v1/auth/google-signin',
|
|
appleSignIn: '/api/v1/auth/apple-signin',
|
|
credits: '/api/v1/credits/balance',
|
|
};
|
|
|
|
/**
|
|
* Create an authentication service with the given configuration
|
|
*/
|
|
export function createAuthService(config: AuthServiceConfig) {
|
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
const storageKeys: StorageKeys = { ...DEFAULT_STORAGE_KEYS, ...config.storageKeys };
|
|
const endpoints: AuthEndpoints = { ...DEFAULT_ENDPOINTS, ...config.endpoints };
|
|
|
|
// Callback for token refresh events
|
|
let onTokenRefreshCallback: ((userData: UserData) => void) | null = null;
|
|
|
|
const service = {
|
|
/**
|
|
* Sign in with email and password
|
|
*/
|
|
async signIn(email: string, password: string): Promise<AuthResult> {
|
|
try {
|
|
const storage = getStorageAdapter();
|
|
const deviceAdapter = getDeviceAdapter();
|
|
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
|
|
|
const response = await fetch(`${baseUrl}${endpoints.signIn}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
deviceId: deviceInfo?.deviceId,
|
|
deviceName: deviceInfo?.deviceName,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
return service.handleAuthError(response.status, errorData);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
|
|
const refreshToken = data.refreshToken;
|
|
|
|
await Promise.all([
|
|
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
|
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
|
storage.setItem(storageKeys.USER_EMAIL, email),
|
|
]);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error signing in:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sign up with email and password
|
|
* @param email User email
|
|
* @param password User password
|
|
* @param referralCode Optional referral code for bonus credits
|
|
* @param sourceAppUrl Optional URL of the app where the user is registering
|
|
*/
|
|
async signUp(
|
|
email: string,
|
|
password: string,
|
|
referralCode?: string,
|
|
sourceAppUrl?: string
|
|
): Promise<AuthResult> {
|
|
try {
|
|
const body: Record<string, string> = { email, password };
|
|
if (referralCode) {
|
|
body.referralCode = referralCode;
|
|
}
|
|
if (sourceAppUrl) {
|
|
body.sourceAppUrl = sourceAppUrl;
|
|
}
|
|
|
|
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
|
|
if (response.status === 409) {
|
|
return { success: false, error: 'Email already in use' };
|
|
} else if (response.status === 400) {
|
|
return { success: false, error: errorData.message || 'Invalid email or password' };
|
|
}
|
|
|
|
return { success: false, error: errorData.message || 'Sign up failed' };
|
|
}
|
|
|
|
// Consume response to avoid unhandled promise
|
|
await response.json();
|
|
|
|
// Mana Core Auth returns user data immediately on registration
|
|
// User needs to sign in separately to get tokens
|
|
return { success: true, needsVerification: false };
|
|
} catch (error) {
|
|
console.error('Error signing up:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during sign up',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sign out the current user
|
|
*/
|
|
async signOut(): Promise<void> {
|
|
try {
|
|
const storage = getStorageAdapter();
|
|
const refreshToken = await storage.getItem<string>(storageKeys.REFRESH_TOKEN);
|
|
|
|
if (refreshToken) {
|
|
await fetch(`${baseUrl}${endpoints.signOut}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken }),
|
|
}).catch((err) => console.error('Error logging out on server:', err));
|
|
}
|
|
|
|
await service.clearAuthStorage();
|
|
} catch (error) {
|
|
console.error('Error signing out:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Send password reset email
|
|
* @param email - User's email address
|
|
* @param redirectTo - Optional URL to redirect after password reset (current app origin)
|
|
*/
|
|
async forgotPassword(email: string, redirectTo?: string): Promise<AuthResult> {
|
|
try {
|
|
const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, redirectTo }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
|
|
if (errorData.message?.includes('rate limit')) {
|
|
return { success: false, error: 'Too many attempts. Please wait before trying again.' };
|
|
}
|
|
|
|
return { success: false, error: errorData.message || 'Password reset failed' };
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error sending password reset email:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reset password with token
|
|
*/
|
|
async resetPassword(token: string, newPassword: string): Promise<AuthResult> {
|
|
try {
|
|
const response = await fetch(`${baseUrl}${endpoints.resetPassword}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, newPassword }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
|
|
if (errorData.message?.includes('expired')) {
|
|
return { success: false, error: 'Reset link has expired. Please request a new one.' };
|
|
}
|
|
|
|
if (errorData.message?.includes('invalid')) {
|
|
return { success: false, error: 'Invalid reset link. Please request a new one.' };
|
|
}
|
|
|
|
return { success: false, error: errorData.message || 'Password reset failed' };
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error resetting password:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Resend verification email
|
|
* @param email - User's email address
|
|
* @param sourceAppUrl - Optional URL to redirect after verification (current app origin)
|
|
*/
|
|
async resendVerificationEmail(email: string, sourceAppUrl?: string): Promise<AuthResult> {
|
|
try {
|
|
const response = await fetch(`${baseUrl}${endpoints.resendVerification}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, sourceAppUrl }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
return {
|
|
success: false,
|
|
error: errorData.message || 'Failed to resend verification email',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error resending verification email:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refresh the authentication tokens
|
|
*/
|
|
async refreshTokens(currentRefreshToken: string): Promise<TokenRefreshResult> {
|
|
const storage = getStorageAdapter();
|
|
const deviceAdapter = getDeviceAdapter();
|
|
|
|
// Check for device ID mismatch
|
|
const storedDeviceId = await deviceAdapter.getStoredDeviceId();
|
|
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
|
|
|
if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) {
|
|
throw new Error('Device ID has changed. Please sign in again.');
|
|
}
|
|
|
|
const response = await fetch(`${baseUrl}${endpoints.refresh}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken: currentRefreshToken }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
|
|
if (response.status === 401 && errorData.message === 'Invalid refresh token') {
|
|
throw new Error('Session expired. Please sign in again.');
|
|
}
|
|
|
|
throw new Error(errorData.message || 'Failed to refresh tokens');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const appToken = data.accessToken; // Mana Core Auth uses 'accessToken'
|
|
const refreshToken = data.refreshToken;
|
|
|
|
if (!appToken || !refreshToken) {
|
|
throw new Error('Invalid response from token refresh - missing tokens');
|
|
}
|
|
|
|
// Store new tokens
|
|
await storage.setItem(storageKeys.APP_TOKEN, appToken);
|
|
await storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken);
|
|
|
|
// Extract user data from new token
|
|
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
|
|
const userData = getUserFromToken(appToken, storedEmail || undefined);
|
|
|
|
// Notify callback if registered
|
|
if (userData && onTokenRefreshCallback) {
|
|
onTokenRefreshCallback(userData);
|
|
}
|
|
|
|
return { appToken, refreshToken, userData };
|
|
},
|
|
|
|
/**
|
|
* Sign in with Google
|
|
*/
|
|
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
|
return service.signInWithSocial(idToken, endpoints.googleSignIn);
|
|
},
|
|
|
|
/**
|
|
* Sign in with Apple
|
|
*/
|
|
async signInWithApple(identityToken: string): Promise<AuthResult> {
|
|
return service.signInWithSocial(identityToken, endpoints.appleSignIn);
|
|
},
|
|
|
|
/**
|
|
* Internal: Sign in with social provider
|
|
*/
|
|
async signInWithSocial(token: string, endpoint: string): Promise<AuthResult> {
|
|
try {
|
|
const storage = getStorageAdapter();
|
|
const deviceAdapter = getDeviceAdapter();
|
|
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
|
|
|
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, deviceInfo }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
return { success: false, error: errorData.message || 'Social sign in failed' };
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
const { appToken, refreshToken } = responseData;
|
|
|
|
// Extract email from response or token
|
|
let email = responseData.email;
|
|
if (!email && appToken) {
|
|
const userData = getUserFromToken(appToken);
|
|
email = userData?.email;
|
|
}
|
|
|
|
// Store tokens
|
|
const storagePromises = [
|
|
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
|
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
|
];
|
|
|
|
if (email) {
|
|
storagePromises.push(storage.setItem(storageKeys.USER_EMAIL, email));
|
|
}
|
|
|
|
await Promise.all(storagePromises);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error with social sign in:', error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error during social sign in',
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the current app token
|
|
*/
|
|
async getAppToken(): Promise<string | null> {
|
|
try {
|
|
const storage = getStorageAdapter();
|
|
return await storage.getItem<string>(storageKeys.APP_TOKEN);
|
|
} catch (error) {
|
|
console.error('Error getting app token:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the current refresh token
|
|
*/
|
|
async getRefreshToken(): Promise<string | null> {
|
|
try {
|
|
const storage = getStorageAdapter();
|
|
return await storage.getItem<string>(storageKeys.REFRESH_TOKEN);
|
|
} catch (error) {
|
|
console.debug('Error getting refresh token:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update stored tokens
|
|
*/
|
|
async updateTokens(appToken: string, refreshToken: string): Promise<void> {
|
|
const storage = getStorageAdapter();
|
|
await Promise.all([
|
|
storage.setItem(storageKeys.APP_TOKEN, appToken),
|
|
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
|
|
]);
|
|
|
|
// Notify callback
|
|
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
|
|
const userData = getUserFromToken(appToken, storedEmail || undefined);
|
|
if (userData && onTokenRefreshCallback) {
|
|
onTokenRefreshCallback(userData);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get user from current token
|
|
*/
|
|
async getUserFromToken(): Promise<UserData | null> {
|
|
const storage = getStorageAdapter();
|
|
const appToken = await storage.getItem<string>(storageKeys.APP_TOKEN);
|
|
if (!appToken) return null;
|
|
|
|
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
|
|
return getUserFromToken(appToken, storedEmail || undefined);
|
|
},
|
|
|
|
/**
|
|
* Clear all authentication data
|
|
*/
|
|
async clearAuthStorage(): Promise<void> {
|
|
const storage = getStorageAdapter();
|
|
await Promise.all(Object.values(storageKeys).map((key) => storage.removeItem(key)));
|
|
},
|
|
|
|
/**
|
|
* Check if user is authenticated
|
|
*/
|
|
async isAuthenticated(): Promise<boolean> {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return false;
|
|
return isTokenValidLocally(appToken);
|
|
},
|
|
|
|
/**
|
|
* Check if token is valid locally
|
|
*/
|
|
isTokenValidLocally(token: string): boolean {
|
|
return isTokenValidLocally(token);
|
|
},
|
|
|
|
/**
|
|
* Decode token
|
|
*/
|
|
decodeToken(token: string) {
|
|
return decodeToken(token);
|
|
},
|
|
|
|
/**
|
|
* Get user credits
|
|
*/
|
|
async getUserCredits(): Promise<CreditBalance | null> {
|
|
try {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return null;
|
|
|
|
const response = await fetch(`${baseUrl}${endpoints.credits}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${appToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch user credits');
|
|
}
|
|
|
|
const data = await response.json();
|
|
return {
|
|
credits: (data.balance || 0) + (data.freeCreditsRemaining || 0),
|
|
maxCreditLimit: data.maxCreditLimit || 1000,
|
|
userId: data.userId || 'unknown',
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching user credits:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if user is B2B
|
|
*/
|
|
async isB2BUser(): Promise<boolean> {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return false;
|
|
return checkB2BUser(appToken);
|
|
},
|
|
|
|
/**
|
|
* Get B2B information
|
|
*/
|
|
async getB2BInfo(): Promise<B2BInfo | null> {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return null;
|
|
return getB2BInfoFromToken(appToken);
|
|
},
|
|
|
|
/**
|
|
* Check if RevenueCat should be disabled
|
|
*/
|
|
async shouldDisableRevenueCat(): Promise<boolean> {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return false;
|
|
return checkRevenueCat(appToken);
|
|
},
|
|
|
|
/**
|
|
* Get app settings from token
|
|
*/
|
|
async getAppSettings(): Promise<Record<string, unknown> | null> {
|
|
const appToken = await service.getAppToken();
|
|
if (!appToken) return null;
|
|
return getAppSettingsFromToken(appToken);
|
|
},
|
|
|
|
/**
|
|
* Set callback for token refresh events
|
|
*/
|
|
set onTokenRefresh(callback: ((userData: UserData) => void) | null) {
|
|
onTokenRefreshCallback = callback;
|
|
},
|
|
|
|
/**
|
|
* Get callback for token refresh events
|
|
*/
|
|
get onTokenRefresh(): ((userData: UserData) => void) | null {
|
|
return onTokenRefreshCallback;
|
|
},
|
|
|
|
/**
|
|
* Handle authentication errors
|
|
*/
|
|
handleAuthError(status: number, errorData: Record<string, unknown>): AuthResult {
|
|
if (status === 401) {
|
|
const isFirebaseUserNeedsReset =
|
|
String(errorData.message).includes('Firebase user detected') ||
|
|
String(errorData.message).includes('password reset required') ||
|
|
errorData.code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED';
|
|
|
|
if (isFirebaseUserNeedsReset) {
|
|
return { success: false, error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED' };
|
|
}
|
|
|
|
const isEmailNotConfirmed =
|
|
String(errorData.message).includes('Email not confirmed') ||
|
|
String(errorData.message).includes('Email not verified') ||
|
|
errorData.code === 'EMAIL_NOT_VERIFIED';
|
|
|
|
if (isEmailNotConfirmed) {
|
|
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
|
|
}
|
|
|
|
return { success: false, error: 'INVALID_CREDENTIALS' };
|
|
} else if (status === 403) {
|
|
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
|
|
}
|
|
|
|
return { success: false, error: String(errorData.message) || 'Authentication failed' };
|
|
},
|
|
|
|
/**
|
|
* Get the base URL
|
|
*/
|
|
getBaseUrl(): string {
|
|
return baseUrl;
|
|
},
|
|
|
|
/**
|
|
* Get storage keys
|
|
*/
|
|
getStorageKeys(): StorageKeys {
|
|
return storageKeys;
|
|
},
|
|
};
|
|
|
|
return service;
|
|
}
|
|
|
|
/**
|
|
* Type for the auth service instance
|
|
*/
|
|
export type AuthService = ReturnType<typeof createAuthService>;
|