mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 02:06:42 +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
464
packages/shared-auth/src/core/tokenManager.ts
Normal file
464
packages/shared-auth/src/core/tokenManager.ts
Normal 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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue