prodobit
Version:
Open-core business application development platform
531 lines (472 loc) • 13.7 kB
text/typescript
// Authentication State Management Helpers
import type { TokenInfo, User } from "@prodobit/types";
import type { ProdobitClient } from "../client";
import type { AuthAction, AuthState } from "../types";
import { ProdobitError } from "../types";
/**
* Initial authentication state
*/
export const initialAuthState: AuthState = {
isAuthenticated: false,
isLoading: false,
isError: false,
user: null,
token: null,
error: null,
tenantId: null,
};
/**
* Authentication State Reducer
*/
export function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "AUTH_START":
return {
...state,
isLoading: true,
isError: false,
error: null,
};
case "AUTH_SUCCESS":
return {
...state,
isLoading: false,
isError: false,
isAuthenticated: true,
user: action.payload.user,
token: action.payload.token,
tenantId: action.payload.token.tenantId || null,
error: null,
};
case "AUTH_ERROR":
return {
...state,
isLoading: false,
isError: true,
isAuthenticated: false,
user: null,
token: null,
tenantId: null,
error:
typeof action.payload.error === "string"
? new Error(action.payload.error)
: action.payload.error,
};
case "AUTH_LOGOUT":
return {
...initialAuthState,
};
case "TOKEN_REFRESH":
return {
...state,
token: action.payload.token,
tenantId: action.payload.token.tenantId || state.tenantId,
error: (state.error as any)?.isAuthError?.() ? null : state.error, // Clear auth errors on successful refresh
};
case "SET_TENANT":
return {
...state,
tenantId: action.payload.tenantId,
};
case "CLEAR_ERROR":
return {
...state,
isError: false,
error: null,
};
default:
return state;
}
}
/**
* Authentication State Manager
* Provides a framework-agnostic way to manage authentication state
*/
export class AuthStateManager {
private state: AuthState = initialAuthState;
private listeners = new Set<(state: AuthState) => void>();
private client: ProdobitClient;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private isInitialized = false;
constructor(client: ProdobitClient) {
this.client = client;
// If we have a token in sessionStorage, start in loading state
const existingToken = this.client.getTokenInfo();
if (existingToken) {
this.state = {
...initialAuthState,
isLoading: true,
};
}
this.setupAutoRefresh();
}
/**
* Subscribe to auth state changes
*/
subscribe(listener: (state: AuthState) => void): () => void {
this.listeners.add(listener);
// Immediately call with current state
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
/**
* Get current auth state
*/
getState(): AuthState {
return this.state;
}
/**
* Update state and notify listeners
*/
private setState(action: AuthAction): void {
const newState = authReducer(this.state, action);
const hasChanged = newState !== this.state;
this.state = newState;
if (hasChanged) {
this.listeners.forEach((listener) => listener(this.state));
}
}
/**
* Initialize authentication from stored token
*/
async initialize(): Promise<void> {
// Prevent multiple initializations
if (this.isInitialized) {
return;
}
this.isInitialized = true;
// Try to get current user to check if we have valid session
try {
this.setState({ type: "AUTH_START" });
// Check if we have a valid token first
const currentToken = this.client.getTokenInfo();
const isTokenValid = this.client.isTokenValid();
// Only refresh if we don't have a valid token AND have refresh token
if (!currentToken || !isTokenValid) {
if (currentToken?.refreshToken) {
try {
console.log('Attempting token refresh during initialization');
await this.client.refreshToken();
} catch (error) {
console.log('Refresh failed during initialization:', error);
// If refresh fails, we're not authenticated
this.setState({ type: "AUTH_LOGOUT" });
return;
}
} else {
console.log('No refresh token available, clearing auth state');
// No refresh token, clear auth state
this.setState({ type: "AUTH_LOGOUT" });
return;
}
}
// Get current user info
const userResponse = await this.client.getCurrentUser();
if (userResponse.success && userResponse.data) {
this.setState({
type: "AUTH_SUCCESS",
payload: {
user: userResponse.data.user,
token: this.client.getTokenInfo()!,
},
});
} else {
throw ProdobitError.unauthorized("Failed to get user info");
}
} catch (error) {
console.log('Auth initialization failed:', error);
this.setState({
type: "AUTH_ERROR",
payload: {
error:
error instanceof ProdobitError
? error
: ProdobitError.serverError(
"Authentication initialization failed"
),
},
});
}
}
/**
* Login with OTP
*/
async loginWithOTP(
email: string,
tenantId?: string
): Promise<{ success: boolean; expiresAt?: string; error?: string }> {
try {
this.setState({ type: "AUTH_START" });
const response = await this.client.requestOTP({ email, tenantId });
if (response.success) {
return {
success: true,
expiresAt: response.expiresAt,
};
} else {
const error = ProdobitError.badRequest("Failed to send OTP");
this.setState({ type: "AUTH_ERROR", payload: { error } });
return {
success: false,
error: error.message,
};
}
} catch (error) {
const authError =
error instanceof ProdobitError
? error
: ProdobitError.serverError("OTP request failed");
this.setState({ type: "AUTH_ERROR", payload: { error: authError } });
return {
success: false,
error: authError.message,
};
}
}
/**
* Verify OTP and complete login
*/
async verifyOTP(
email: string,
code: string,
tenantId?: string
): Promise<{ success: boolean; user?: User; error?: string }> {
try {
this.setState({ type: "AUTH_START" });
const response = await this.client.verifyOTP({ email, code, tenantId });
if (response.success && response.data) {
const token = this.client.getTokenInfo();
if (token) {
this.setState({
type: "AUTH_SUCCESS",
payload: {
user: response.data.user,
token,
},
});
return {
success: true,
user: response.data.user,
};
}
}
const error = ProdobitError.unauthorized("OTP verification failed");
this.setState({ type: "AUTH_ERROR", payload: { error } });
return {
success: false,
error: error.message,
};
} catch (error) {
const authError =
error instanceof ProdobitError
? error
: ProdobitError.unauthorized("OTP verification failed");
this.setState({ type: "AUTH_ERROR", payload: { error: authError } });
return {
success: false,
error: authError.message,
};
}
}
/**
* Refresh authentication token
*/
async refreshToken(): Promise<void> {
try {
const response = await this.client.refreshToken();
if (response.success && response.data) {
const token = this.client.getTokenInfo();
if (token) {
this.setState({
type: "TOKEN_REFRESH",
payload: { token },
});
this.setupAutoRefresh(); // Reset refresh timer
}
} else {
throw ProdobitError.unauthorized("Token refresh failed");
}
} catch (error) {
const authError =
error instanceof ProdobitError
? error
: ProdobitError.unauthorized("Token refresh failed");
this.setState({ type: "AUTH_ERROR", payload: { error: authError } });
throw authError;
}
}
/**
* Logout user
*/
async logout(allDevices = false): Promise<void> {
try {
await this.client.logout({ allDevices });
} catch (error) {
// Log error but still clear local state
console.warn("Logout API call failed:", error);
} finally {
this.clearRefreshTimer();
this.setState({ type: "AUTH_LOGOUT" });
}
}
/**
* Set current tenant
*/
setTenant(tenantId: string): void {
this.setState({
type: "SET_TENANT",
payload: { tenantId },
});
}
/**
* Clear authentication error
*/
clearError(): void {
this.setState({ type: "CLEAR_ERROR" });
}
/**
* Setup automatic token refresh
*
* NOTE: Disabled in favor of BaseClient's per-request refresh mechanism
* BaseClient automatically refreshes tokens before each request when needed
*/
private setupAutoRefresh(): void {
// Disabled - BaseClient handles refresh automatically
this.clearRefreshTimer();
// If you want to re-enable timer-based refresh, uncomment below:
/*
const token = this.client.getTokenInfo();
if (!token) return;
// Refresh 5 minutes before expiration
const refreshTime = token.expiresAt.getTime() - Date.now() - 5 * 60 * 1000;
if (refreshTime > 0) {
this.refreshTimer = setTimeout(async () => {
try {
await this.refreshToken();
} catch (error) {
console.warn("Automatic token refresh failed:", error);
// If refresh fails, logout user to force re-authentication
this.setState({ type: "AUTH_LOGOUT" });
}
}, refreshTime);
}
*/
}
/**
* Clear refresh timer
*/
private clearRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Cleanup resources
*/
destroy(): void {
this.clearRefreshTimer();
this.listeners.clear();
}
}
/**
* Authentication helpers for different frameworks
*/
export const authHelpers = {
/**
* React hooks compatible state selector
*/
createStateSelector:
<T>(selector: (state: AuthState) => T) =>
(state: AuthState): T =>
selector(state),
/**
* Common state selectors
*/
selectors: {
isAuthenticated: (state: AuthState) => state.isAuthenticated,
isLoading: (state: AuthState) => state.isLoading,
isError: (state: AuthState) => state.isError,
user: (state: AuthState) => state.user,
token: (state: AuthState) => state.token,
error: (state: AuthState) => state.error,
tenantId: (state: AuthState) => state.tenantId,
// Derived state
isReady: (state: AuthState) => !state.isLoading && !state.isError,
hasUser: (state: AuthState) => state.isAuthenticated && !!state.user,
hasTenant: (state: AuthState) => !!state.tenantId,
// Error type checks
hasAuthError: (state: AuthState) => !!(state.error as any)?.isAuthError?.(),
hasNetworkError: (state: AuthState) =>
!!(state.error as any)?.isNetworkError?.(),
hasValidationError: (state: AuthState) =>
!!(state.error as any)?.isValidationError?.(),
},
/**
* Action creators for external state management
*/
actions: {
startAuth: (): AuthAction => ({ type: "AUTH_START" }),
authSuccess: (user: User, token: TokenInfo): AuthAction => ({
type: "AUTH_SUCCESS",
payload: { user, token },
}),
authError: (error: ProdobitError): AuthAction => ({
type: "AUTH_ERROR",
payload: { error },
}),
logout: (): AuthAction => ({ type: "AUTH_LOGOUT" }),
refreshToken: (token: TokenInfo): AuthAction => ({
type: "TOKEN_REFRESH",
payload: { token },
}),
setTenant: (tenantId: string): AuthAction => ({
type: "SET_TENANT",
payload: { tenantId },
}),
clearError: (): AuthAction => ({ type: "CLEAR_ERROR" }),
},
};
/**
* Token management utilities
*/
export const tokenUtils = {
/**
* Check if token is expiring soon
*/
isExpiringSoon: (token: TokenInfo, thresholdMinutes = 5): boolean => {
const threshold = thresholdMinutes * 60 * 1000;
return token.expiresAt.getTime() - Date.now() < threshold;
},
/**
* Get time until expiration
*/
getTimeUntilExpiration: (token: TokenInfo): number => {
return Math.max(0, token.expiresAt.getTime() - Date.now());
},
/**
* Format expiration time
*/
formatExpiration: (token: TokenInfo): string => {
const timeLeft = tokenUtils.getTimeUntilExpiration(token);
const minutes = Math.floor(timeLeft / (60 * 1000));
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
return `${minutes}m`;
},
/**
* Decode token payload (without verification)
*/
decodeTokenPayload: (token: string): any | null => {
try {
const payload = token.split(".")[1];
return JSON.parse(atob(payload));
} catch {
return null;
}
},
};