mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 19:46:41 +02:00
Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()
This commit is contained in:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
546
packages/shared-auth/src/core/authService.ts
Normal file
546
packages/shared-auth/src/core/authService.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
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
|
||||
*/
|
||||
const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
||||
signIn: '/auth/signin',
|
||||
signUp: '/auth/signup',
|
||||
signOut: '/auth/logout',
|
||||
refresh: '/auth/refresh',
|
||||
validate: '/auth/validate',
|
||||
forgotPassword: '/auth/forgot-password',
|
||||
googleSignIn: '/auth/google-signin',
|
||||
appleSignIn: '/auth/apple-signin',
|
||||
credits: '/auth/credits',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return service.handleAuthError(response.status, errorData);
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
|
||||
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
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const storage = getStorageAdapter();
|
||||
const deviceAdapter = getDeviceAdapter();
|
||||
const deviceInfo = await deviceAdapter.getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Check if email verification is required
|
||||
if (responseData.confirmationRequired) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (appToken && 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 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
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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, deviceInfo }),
|
||||
});
|
||||
|
||||
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 { appToken, refreshToken } = await response.json();
|
||||
|
||||
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.credits || 0,
|
||||
maxCreditLimit: data.max_credit_limit || 1000,
|
||||
userId: data.id || '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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue