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:
Till-JS 2025-11-24 21:09:20 +01:00
parent 725db638ea
commit ef70a1af0b
198 changed files with 11113 additions and 3656 deletions

View file

@ -0,0 +1,81 @@
import type { DeviceManagerAdapter, DeviceInfo } from '../types';
let deviceAdapter: DeviceManagerAdapter | null = null;
/**
* Set the device manager adapter for the auth service
*/
export function setDeviceAdapter(adapter: DeviceManagerAdapter): void {
deviceAdapter = adapter;
}
/**
* Get the current device adapter
*/
export function getDeviceAdapter(): DeviceManagerAdapter {
if (!deviceAdapter) {
throw new Error(
'Device adapter not initialized. Call setDeviceAdapter() before using auth services.'
);
}
return deviceAdapter;
}
/**
* Check if device adapter is initialized
*/
export function isDeviceInitialized(): boolean {
return deviceAdapter !== null;
}
/**
* Create a web-based device manager adapter
*/
export function createWebDeviceAdapter(): DeviceManagerAdapter {
// Generate a persistent device ID for web
const getOrCreateDeviceId = (): string => {
const storageKey = '@manacore/deviceId';
let deviceId = localStorage.getItem(storageKey);
if (!deviceId) {
deviceId = crypto.randomUUID();
localStorage.setItem(storageKey, deviceId);
}
return deviceId;
};
return {
async getDeviceInfo(): Promise<DeviceInfo> {
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
let deviceType = 'web';
// Try to extract browser name
if (userAgent.includes('Chrome')) {
deviceName = 'Chrome Browser';
} else if (userAgent.includes('Safari')) {
deviceName = 'Safari Browser';
} else if (userAgent.includes('Firefox')) {
deviceName = 'Firefox Browser';
} else if (userAgent.includes('Edge')) {
deviceName = 'Edge Browser';
}
// Detect device type
if (/Mobi|Android/i.test(userAgent)) {
deviceType = 'mobile_web';
} else if (/Tablet|iPad/i.test(userAgent)) {
deviceType = 'tablet_web';
}
return {
deviceId: getOrCreateDeviceId(),
deviceName,
deviceType,
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
return localStorage.getItem('@manacore/deviceId');
},
};
}

View file

@ -0,0 +1,55 @@
import type { NetworkAdapter } from '../types';
let networkAdapter: NetworkAdapter | null = null;
/**
* Set the network adapter for the auth service
*/
export function setNetworkAdapter(adapter: NetworkAdapter): void {
networkAdapter = adapter;
}
/**
* Get the current network adapter
*/
export function getNetworkAdapter(): NetworkAdapter | null {
return networkAdapter;
}
/**
* Check if device is connected to the network
*/
export async function isDeviceConnected(): Promise<boolean> {
if (!networkAdapter) {
// Default to true if no adapter is set
return true;
}
return networkAdapter.isDeviceConnected();
}
/**
* Check if device has a stable connection
*/
export async function hasStableConnection(): Promise<boolean> {
if (!networkAdapter || !networkAdapter.hasStableConnection) {
// Default to basic connectivity check
return isDeviceConnected();
}
return networkAdapter.hasStableConnection();
}
/**
* Create a web-based network adapter
*/
export function createWebNetworkAdapter(): NetworkAdapter {
return {
async isDeviceConnected(): Promise<boolean> {
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
// For web, we just check online status
// More sophisticated checks could be added
return navigator.onLine;
},
};
}

View file

@ -0,0 +1,89 @@
import type { StorageAdapter } from '../types';
/**
* Storage adapter that must be implemented by the consuming app.
*
* For React Native (Expo):
* - Use expo-secure-store for sensitive data
* - Use @react-native-async-storage/async-storage for non-sensitive data
*
* For Web:
* - Use localStorage or sessionStorage
* - Consider using encrypted storage for sensitive data
*/
let storageAdapter: StorageAdapter | null = null;
/**
* Set the storage adapter for the auth service
*/
export function setStorageAdapter(adapter: StorageAdapter): void {
storageAdapter = adapter;
}
/**
* Get the current storage adapter
*/
export function getStorageAdapter(): StorageAdapter {
if (!storageAdapter) {
throw new Error(
'Storage adapter not initialized. Call setStorageAdapter() before using auth services.'
);
}
return storageAdapter;
}
/**
* Check if storage adapter is initialized
*/
export function isStorageInitialized(): boolean {
return storageAdapter !== null;
}
/**
* Create a localStorage-based storage adapter (for web)
*/
export function createLocalStorageAdapter(): StorageAdapter {
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = localStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
},
};
}
/**
* Create an in-memory storage adapter (for testing)
*/
export function createMemoryStorageAdapter(): StorageAdapter {
const storage = new Map<string, string>();
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = storage.get(key);
if (value === undefined) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
storage.set(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
storage.delete(key);
},
};
}

View 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>;

View file

@ -0,0 +1,160 @@
import type { DecodedToken, UserData } from '../types';
/**
* Decode a JWT token payload
*/
export function decodeToken(token: string): DecodedToken | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padding = base64.length % 4;
const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
// Decode base64 - atob is available in browsers, Node.js 16+, and React Native
const payload: DecodedToken = JSON.parse(atob(paddedBase64));
return payload;
} catch (error) {
console.error('Error decoding JWT token:', error);
return null;
}
}
/**
* Check if a token is valid locally (not expired)
*/
export function isTokenValidLocally(token: string, bufferSeconds: number = 10): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return false;
}
const bufferTime = bufferSeconds * 1000;
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
return currentTime < expiryTime - bufferTime;
} catch (error) {
console.debug('Error validating token locally:', error);
return false;
}
}
/**
* Check if a token is expired
*/
export function isTokenExpired(token: string): boolean {
return !isTokenValidLocally(token, 0);
}
/**
* Extract user data from a JWT token
*/
export function getUserFromToken(token: string, storedEmail?: string): UserData | null {
try {
const payload = decodeToken(token);
if (!payload) {
return null;
}
// Get email from various sources
let email = payload.email || '';
if (!email && payload.user_metadata?.email) {
email = payload.user_metadata.email;
}
if (!email && storedEmail) {
email = storedEmail;
}
return {
id: payload.sub,
email: email || 'user@example.com',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error extracting user from token:', error);
return null;
}
}
/**
* Get token expiration time in milliseconds
*/
export function getTokenExpirationTime(token: string): number | null {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return null;
}
return payload.exp * 1000;
}
/**
* Get time until token expiration in milliseconds
*/
export function getTimeUntilExpiration(token: string): number {
const expirationTime = getTokenExpirationTime(token);
if (!expirationTime) {
return 0;
}
return Math.max(0, expirationTime - Date.now());
}
/**
* Check if user is B2B based on JWT claims
*/
export function isB2BUser(token: string): boolean {
const payload = decodeToken(token);
if (!payload) {
return false;
}
// Handle different types for is_b2b
return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
}
/**
* Get B2B information from JWT claims
*/
export function getB2BInfo(token: string): {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
} | null {
const payload = decodeToken(token);
if (!payload?.app_settings?.b2b) {
return null;
}
const b2bSettings = payload.app_settings.b2b;
return {
disableRevenueCat: !!b2bSettings.disableRevenueCat,
organizationId: b2bSettings.organizationId,
plan: b2bSettings.plan,
role: b2bSettings.role,
};
}
/**
* Check if RevenueCat should be disabled for this token
*/
export function shouldDisableRevenueCat(token: string): boolean {
const b2bInfo = getB2BInfo(token);
return b2bInfo?.disableRevenueCat ?? false;
}
/**
* Get app settings from JWT claims
*/
export function getAppSettings(token: string): Record<string, unknown> | null {
const payload = decodeToken(token);
return payload?.app_settings || null;
}

View file

@ -0,0 +1,464 @@
import type {
TokenState,
TokenStateObserver,
QueuedRequest,
InternalTokenRefreshResult,
} from '../types';
import { TokenState as TokenStateEnum } from '../types';
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
import type { AuthService } from './authService';
/**
* Configuration for the token manager
*/
export interface TokenManagerConfig {
maxQueueSize?: number;
queueTimeoutMs?: number;
maxRefreshAttempts?: number;
refreshCooldownMs?: number;
}
/**
* Create a token manager instance
*/
export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
// Configuration
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
// State
let state: TokenState = TokenStateEnum.IDLE;
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
let requestQueue: QueuedRequest[] = [];
const observers = new Set<TokenStateObserver>();
let refreshAttempts = 0;
let lastRefreshTime = 0;
// Internal functions
function notifyObservers(newState: TokenState, token?: string): void {
observers.forEach((observer) => {
try {
observer(newState, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
function setState(newState: TokenState, token?: string): void {
if (state !== newState) {
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
state = newState;
notifyObservers(newState, token);
}
}
function removeFromQueue(requestId: string): void {
const index = requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
requestQueue.splice(index, 1);
}
}
function 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())
);
return isNetworkError && !isAuthError;
}
async function handleRefreshFailure(): Promise<void> {
console.debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
try {
console.debug('TokenManager: Starting token refresh');
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Device offline, skipping refresh');
const currentToken = await authService.getAppToken();
if (currentToken) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
}
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
const isStable = await hasStableConnection();
if (!isStable) {
console.debug('TokenManager: Connection not stable yet, will 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 } = refreshResult;
console.debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
console.debug('TokenManager: Token refresh failed:', error);
const isRecoverable = isRecoverableError(error);
if (!isRecoverable) {
await handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000];
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
if (retryDelays[attempt] > 0) {
console.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 performTokenRefresh();
if (result.success) {
return result;
}
// Non-retryable errors
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error?.includes('Device ID has changed')
) {
return result;
}
if (result.error === 'offline') {
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
if (result.error === 'unstable_connection') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
lastError = new Error(result.error || 'Token refresh failed');
if (attempt === retryDelays.length - 1) break;
} catch (error) {
lastError = error;
const isRecoverable = isRecoverableError(error);
if (!isRecoverable || attempt === retryDelays.length - 1) {
break;
}
}
}
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
async function processQueuedRequests(token: string): Promise<void> {
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
try {
const response = await retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
async function rejectQueuedRequests(error: string): Promise<void> {
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
async function retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
// Public API
const manager = {
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
observers.add(observer);
return () => observers.delete(observer);
},
/**
* Get current token state
*/
getState(): TokenState {
return state;
},
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
setState(TokenStateEnum.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
console.debug('TokenManager: No token available, skipping refresh');
setState(TokenStateEnum.EXPIRED);
return null;
}
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Token expired while offline');
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
if (refreshResult.shouldPreserveAuth) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
return null;
},
/**
* Handle 401 response
*/
async handle401Response(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
return manager.queueRequest(input, init);
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
},
/**
* Queue a request during token refresh
*/
async queueRequest(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
return new Promise((resolve, reject) => {
if (requestQueue.length >= 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(),
};
requestQueue.push(queueItem);
setTimeout(() => {
removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, QUEUE_TIMEOUT_MS);
});
},
/**
* Refresh the authentication token
*/
async refreshToken(): Promise<InternalTokenRefreshResult> {
const now = Date.now();
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
await handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (refreshPromise) {
return refreshPromise;
}
setState(TokenStateEnum.REFRESHING);
lastRefreshTime = now;
refreshPromise = performTokenRefreshWithRetry();
try {
const result = await refreshPromise;
if (result.success) {
refreshAttempts = 0;
setState(TokenStateEnum.VALID, result.token);
await processQueuedRequests(result.token!);
} else {
refreshAttempts++;
setState(TokenStateEnum.EXPIRED);
await rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
refreshPromise = null;
}
},
/**
* Reset the token manager state
*/
reset(): void {
state = TokenStateEnum.IDLE;
refreshPromise = null;
refreshAttempts = 0;
lastRefreshTime = 0;
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
},
/**
* Clear tokens and reset state
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
manager.reset();
} catch (error) {
console.debug('Error clearing tokens:', error);
manager.reset();
}
},
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: requestQueue.length,
state,
refreshAttempts,
};
},
/**
* Check initial token state
*/
async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
setState(TokenStateEnum.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
setState(TokenStateEnum.VALID, token);
} else {
setState(TokenStateEnum.EXPIRED);
}
} catch (error) {
console.debug('Error checking initial token state:', error);
setState(TokenStateEnum.EXPIRED);
}
},
};
// Initialize
manager.checkInitialState();
return manager;
}
/**
* Type for the token manager instance
*/
export type TokenManager = ReturnType<typeof createTokenManager>;

View file

@ -0,0 +1,99 @@
// Types
export * from './types';
// Core utilities
import { createAuthService as _createAuthService } from './core/authService';
export { createAuthService } from './core/authService';
export type { AuthService } from './core/authService';
import { createTokenManager as _createTokenManager } from './core/tokenManager';
export { createTokenManager } from './core/tokenManager';
export type { TokenManager, TokenManagerConfig } from './core/tokenManager';
export {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
getTokenExpirationTime,
getTimeUntilExpiration,
isB2BUser,
getB2BInfo,
shouldDisableRevenueCat,
getAppSettings,
} from './core/jwtUtils';
// Storage adapter
import {
setStorageAdapter as _setStorageAdapter,
createLocalStorageAdapter as _createLocalStorageAdapter,
} from './adapters/storage';
export {
setStorageAdapter,
getStorageAdapter,
isStorageInitialized,
createLocalStorageAdapter,
createMemoryStorageAdapter,
} from './adapters/storage';
// Device adapter
import {
setDeviceAdapter as _setDeviceAdapter,
createWebDeviceAdapter as _createWebDeviceAdapter,
} from './adapters/device';
export {
setDeviceAdapter,
getDeviceAdapter,
isDeviceInitialized,
createWebDeviceAdapter,
} from './adapters/device';
// Network adapter
import {
setNetworkAdapter as _setNetworkAdapter,
createWebNetworkAdapter as _createWebNetworkAdapter,
} from './adapters/network';
export {
setNetworkAdapter,
getNetworkAdapter,
isDeviceConnected,
hasStableConnection,
createWebNetworkAdapter,
} from './adapters/network';
// Fetch interceptor
import { setupFetchInterceptor as _setupFetchInterceptor } from './interceptors/fetchInterceptor';
export {
setupFetchInterceptor,
setupTokenObservers,
getInterceptorStatus,
} from './interceptors/fetchInterceptor';
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
/**
* Initialize auth service with all adapters for web
*
* @example
* ```typescript
* import { initializeWebAuth } from '@manacore/shared-auth';
*
* const { authService, tokenManager } = initializeWebAuth({
* baseUrl: 'https://api.example.com',
* });
* ```
*/
export function initializeWebAuth(config: { baseUrl: string; storageKeys?: Partial<import('./types').StorageKeys> }) {
// Set up adapters
_setStorageAdapter(_createLocalStorageAdapter());
_setDeviceAdapter(_createWebDeviceAdapter());
_setNetworkAdapter(_createWebNetworkAdapter());
// Create services
const authService = _createAuthService(config);
const tokenManager = _createTokenManager(authService);
// Set up interceptor
_setupFetchInterceptor(authService, tokenManager);
return { authService, tokenManager };
}

View file

@ -0,0 +1,220 @@
import type { TokenManager } from '../core/tokenManager';
import type { AuthService } from '../core/authService';
import { TokenState } from '../types';
/**
* Configuration for the fetch interceptor
*/
export interface FetchInterceptorConfig {
/**
* Patterns to skip (won't be intercepted)
*/
skipPatterns?: string[];
/**
* Backend URL to match (only intercept requests to this URL)
*/
backendUrl?: string;
}
/**
* Default patterns to skip
*/
const DEFAULT_SKIP_PATTERNS = [
// Auth endpoints
'/auth/signin',
'/auth/signup',
'/auth/refresh',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/verify',
'/auth/logout',
// Public endpoints
'/health',
'/ping',
'/status',
'/version',
'/public/',
// Storage endpoints
'.supabase.co/storage/',
'/storage/v1/',
// External APIs
'googleapis.com',
'firebase.com',
'firebaseapp.com',
'replicate.com',
'openai.com',
'anthropic.com',
];
/**
* Setup a global fetch interceptor for automatic token handling
*/
export function setupFetchInterceptor(
authService: AuthService,
tokenManager: TokenManager,
config?: FetchInterceptorConfig
): void {
if (typeof globalThis === 'undefined' || !globalThis.fetch) {
console.warn('FetchInterceptor: globalThis.fetch not available');
return;
}
const originalFetch = globalThis.fetch;
const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])];
const backendUrl = config?.backendUrl || authService.getBaseUrl();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = extractUrl(input);
// Skip intercepting if URL doesn't match criteria
if (shouldSkipInterception(url, skipPatterns, backendUrl)) {
return originalFetch(input, init);
}
console.debug('Fetch interceptor: Intercepting URL:', url);
try {
// Make request with current token
const response = await makeRequestWithToken(originalFetch, authService, input, init);
// Handle 401 responses
if (response.status === 401) {
const responseData = await response.clone().json().catch(() => ({}));
console.debug('Fetch interceptor: Received 401 response:', responseData);
if (isTokenExpiredResponse(responseData)) {
console.debug('Fetch interceptor: Token expired, delegating to TokenManager');
return tokenManager.handle401Response(input, init);
}
}
return response;
} catch (error) {
console.debug('Error in global fetch interceptor:', error);
return originalFetch(input, init);
}
}) as typeof fetch;
}
/**
* Setup token state observers for integrations (e.g., Supabase)
*/
export function setupTokenObservers(
tokenManager: TokenManager,
onValid?: (token: string) => void | Promise<void>,
onExpired?: () => void | Promise<void>
): () => void {
return tokenManager.subscribe(async (state, token) => {
try {
if (state === TokenState.VALID && token && onValid) {
await onValid(token);
} else if (state === TokenState.EXPIRED && onExpired) {
await onExpired();
}
} catch (error) {
console.debug('Error in token observer:', error);
}
});
}
/**
* Extract URL from various input types
*/
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') {
return input;
} else if (input instanceof URL) {
return input.toString();
} else if (input instanceof Request) {
return input.url;
}
return '';
}
/**
* Check if request should skip interception
*/
function shouldSkipInterception(
url: string,
skipPatterns: string[],
backendUrl: string
): boolean {
if (!url) return true;
const lowerUrl = url.toLowerCase();
// Check skip patterns
if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) {
return true;
}
// Check if URL matches backend
const backendDomain = backendUrl
.replace(/https?:\/\//, '')
.replace(/:\d+$/, '')
.toLowerCase();
if (!lowerUrl.includes(backendDomain)) {
return true;
}
return false;
}
/**
* Make a request with the current token
*/
async function makeRequestWithToken(
originalFetch: typeof fetch,
authService: AuthService,
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token = await authService.getAppToken();
const requestInit: RequestInit = {
method: init?.method || 'GET',
...init,
};
if (token) {
const headers = new Headers(requestInit.headers || {});
headers.set('Authorization', `Bearer ${token}`);
requestInit.headers = headers;
}
return originalFetch(input, requestInit);
}
/**
* Check if response indicates token expiration
*/
function isTokenExpiredResponse(responseData: Record<string, unknown>): boolean {
const error = responseData.error as Record<string, unknown> | undefined;
const errorMessage = String(error?.message || responseData.message || responseData.error || '');
const errorCode = String(responseData.code || error?.code || '');
return (
errorMessage === 'JWT expired' ||
errorCode === 'PGRST301' ||
errorMessage === 'Unauthorized'
);
}
/**
* Get interceptor status for debugging
*/
export function getInterceptorStatus(
authService: AuthService,
tokenManager: TokenManager
): {
isSetup: boolean;
backendUrl: string;
tokenManager: { size: number; state: string; refreshAttempts: number };
} {
return {
isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined,
backendUrl: authService.getBaseUrl(),
tokenManager: tokenManager.getQueueStatus(),
};
}

View file

@ -0,0 +1,178 @@
/**
* Storage keys for authentication data
*/
export interface StorageKeys {
APP_TOKEN: string;
REFRESH_TOKEN: string;
USER_EMAIL: string;
}
/**
* Device information for multi-device support
*/
export interface DeviceInfo {
deviceId: string;
deviceName: string;
deviceType: string;
platform?: string;
}
/**
* Decoded JWT token payload
*/
export interface DecodedToken {
sub: string;
email?: string;
role?: string;
exp: number;
iat: number;
aud?: string;
app_id?: string;
is_b2b?: boolean | string | number;
subscription_plan_id?: string;
user_metadata?: {
email?: string;
};
app_settings?: {
b2b?: {
disableRevenueCat?: boolean;
organizationId?: string;
plan?: string;
role?: string;
};
};
}
/**
* User data extracted from token
*/
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication result from sign in/up
*/
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
}
/**
* Token refresh result
*/
export interface TokenRefreshResult {
appToken: string;
refreshToken: string;
userData?: UserData | null;
}
/**
* Token state for the token manager
*/
export enum TokenState {
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
EXPIRED_OFFLINE = 'expired_offline',
VALID = 'valid',
}
/**
* Token state observer callback
*/
export type TokenStateObserver = (state: TokenState, token?: string) => void;
/**
* Queued request item during token refresh
*/
export interface QueuedRequest {
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
}
/**
* Internal token refresh result
*/
export interface InternalTokenRefreshResult {
success: boolean;
token?: string;
error?: string;
shouldPreserveAuth?: boolean;
shouldRetry?: boolean;
}
/**
* Configuration for the auth service
*/
export interface AuthServiceConfig {
baseUrl: string;
storageKeys?: Partial<StorageKeys>;
endpoints?: Partial<AuthEndpoints>;
}
/**
* Auth API endpoints
*/
export interface AuthEndpoints {
signIn: string;
signUp: string;
signOut: string;
refresh: string;
validate: string;
forgotPassword: string;
googleSignIn: string;
appleSignIn: string;
credits: string;
}
/**
* Storage adapter interface
*/
export interface StorageAdapter {
getItem<T = string>(key: string): Promise<T | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
/**
* Device manager adapter interface
*/
export interface DeviceManagerAdapter {
getDeviceInfo(): Promise<DeviceInfo>;
getStoredDeviceId(): Promise<string | null>;
}
/**
* Network utilities adapter interface
*/
export interface NetworkAdapter {
isDeviceConnected(): Promise<boolean>;
hasStableConnection?(): Promise<boolean>;
}
/**
* Credit balance response
*/
export interface CreditBalance {
credits: number;
maxCreditLimit: number;
userId: string;
}
/**
* B2B information from JWT claims
*/
export interface B2BInfo {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
}