UNPKG

@acontplus/ng-auth

Version:

Comprehensive Angular authentication module with JWT token management, route guards, CSRF protection, URL redirection, session handling, and clean architecture patterns. Includes login components, auth interceptors, and DDD-based repositories.

961 lines (937 loc) 52.9 kB
import { ENVIRONMENT, AUTH_API, AUTH_TOKEN } from '@acontplus/ng-config'; export { AUTH_TOKEN } from '@acontplus/ng-config'; import * as i0 from '@angular/core'; import { inject, PLATFORM_ID, Injectable, NgZone, signal, input, computed, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core'; import { Router } from '@angular/router'; import { of, tap, catchError, throwError, firstValueFrom, map, from } from 'rxjs'; import { BaseUseCase, LoggingService } from '@acontplus/ng-infrastructure'; import * as i1 from '@angular/common'; import { isPlatformBrowser, DOCUMENT, CommonModule } from '@angular/common'; import { jwtDecode } from 'jwt-decode'; import { HttpClient, HttpContextToken } from '@angular/common/http'; import { catchError as catchError$1, switchMap } from 'rxjs/operators'; import * as i2 from '@angular/forms'; import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { MatCard, MatCardHeader, MatCardTitle, MatCardContent, MatCardFooter } from '@angular/material/card'; import { MatLabel, MatFormField } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { MatIcon } from '@angular/material/icon'; import { MatButton, MatAnchor } from '@angular/material/button'; import { MatCheckbox } from '@angular/material/checkbox'; class AuthRepository { } class AuthTokenRepositoryImpl { environment = inject(ENVIRONMENT); platformId = inject(PLATFORM_ID); saveTokens(tokens, rememberMe = false) { if (isPlatformBrowser(this.platformId)) { this.setToken(tokens.token, rememberMe); this.setRefreshToken(tokens.refreshToken, rememberMe); } } getToken() { if (!isPlatformBrowser(this.platformId)) { return null; } return (localStorage.getItem(this.environment.tokenKey) || sessionStorage.getItem(this.environment.tokenKey)); } getRefreshToken() { if (!isPlatformBrowser(this.platformId)) { return null; } return (localStorage.getItem(this.environment.refreshTokenKey) || sessionStorage.getItem(this.environment.refreshTokenKey)); } setToken(token, rememberMe = false) { if (!isPlatformBrowser(this.platformId)) { return; } if (rememberMe) { localStorage.setItem(this.environment.tokenKey, token); } else { sessionStorage.setItem(this.environment.tokenKey, token); } } setRefreshToken(refreshToken, rememberMe = false) { if (!isPlatformBrowser(this.platformId)) { return; } if (rememberMe) { localStorage.setItem(this.environment.refreshTokenKey, refreshToken); } else { sessionStorage.setItem(this.environment.refreshTokenKey, refreshToken); } } clearTokens() { if (!isPlatformBrowser(this.platformId)) { return; } localStorage.removeItem(this.environment.tokenKey); localStorage.removeItem(this.environment.refreshTokenKey); sessionStorage.removeItem(this.environment.tokenKey); sessionStorage.removeItem(this.environment.refreshTokenKey); } isAuthenticated() { const accessToken = this.getToken(); if (!accessToken) { return false; } try { const decodedAccessToken = jwtDecode(accessToken); const accessExpiration = Number(decodedAccessToken.exp); const currentTimeUTC = Math.floor(Date.now() / 1000); return accessExpiration > currentTimeUTC; } catch { return false; } } needsRefresh() { const accessToken = this.getToken(); if (!accessToken) { return false; } try { const decodedToken = jwtDecode(accessToken); const expiration = Number(decodedToken.exp); const currentTimeUTC = Math.floor(Date.now() / 1000); const timeUntilExpiry = expiration - currentTimeUTC; return timeUntilExpiry <= 300; // 5 minutes } catch { return false; } } getTokenPayload() { const token = this.getToken(); if (!token) return null; try { return jwtDecode(token); } catch { return null; } } /** * Determines if tokens are stored persistently (localStorage) vs session (sessionStorage) */ isRememberMeEnabled() { if (!isPlatformBrowser(this.platformId)) { return false; } // Check if tokens exist in localStorage (persistent storage) const tokenInLocalStorage = localStorage.getItem(this.environment.tokenKey); const refreshTokenInLocalStorage = localStorage.getItem(this.environment.refreshTokenKey); return !!(tokenInLocalStorage || refreshTokenInLocalStorage); } getUserData() { const token = this.getToken(); if (!token) { return null; } try { const decodedToken = jwtDecode(token); const email = decodedToken['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] ?? decodedToken['email'] ?? decodedToken['sub'] ?? decodedToken['user_id']; const displayName = decodedToken['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] ?? decodedToken['displayName'] ?? decodedToken['display_name'] ?? decodedToken['name'] ?? decodedToken['given_name']; const name = decodedToken['name'] ?? displayName; if (!email) { return null; } const userData = { email: email.toString(), displayName: displayName?.toString() ?? 'Unknown User', name: name?.toString(), roles: this.extractArrayField(decodedToken, ['roles', 'role']), permissions: this.extractArrayField(decodedToken, ['permissions', 'perms']), tenantId: decodedToken['tenantId']?.toString() ?? decodedToken['tenant_id']?.toString() ?? decodedToken['tenant']?.toString(), companyId: decodedToken['companyId']?.toString() ?? decodedToken['company_id']?.toString() ?? decodedToken['organizationId']?.toString() ?? decodedToken['org_id']?.toString(), locale: decodedToken['locale']?.toString(), timezone: decodedToken['timezone']?.toString() ?? decodedToken['tz']?.toString(), }; return userData; } catch { return null; } } /** * Extract array field from decoded token, trying multiple possible field names */ extractArrayField(decodedToken, fieldNames) { for (const fieldName of fieldNames) { const value = decodedToken[fieldName]; if (Array.isArray(value)) { return value.map(v => v.toString()); } if (typeof value === 'string') { // Handle comma-separated string values return value .split(',') .map(v => v.trim()) .filter(v => v.length > 0); } } return undefined; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthTokenRepositoryImpl, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthTokenRepositoryImpl, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthTokenRepositoryImpl, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/presentation/stores/auth-store.ts class AuthStore { authRepository = inject(AuthRepository); tokenRepository = inject(AuthTokenRepositoryImpl); router = inject(Router); ngZone = inject(NgZone); // Authentication state signals _isAuthenticated = signal(false, ...(ngDevMode ? [{ debugName: "_isAuthenticated" }] : [])); _isLoading = signal(false, ...(ngDevMode ? [{ debugName: "_isLoading" }] : [])); _user = signal(null, ...(ngDevMode ? [{ debugName: "_user" }] : [])); // Public readonly signals isAuthenticated = this._isAuthenticated.asReadonly(); isLoading = this._isLoading.asReadonly(); user = this._user.asReadonly(); // Private refresh token timeout (instead of interval) refreshTokenTimeout; refreshInProgress$; constructor() { this.initializeAuthentication(); } /** * Initialize authentication state on app startup */ initializeAuthentication() { this._isLoading.set(true); try { const isAuthenticated = this.tokenRepository.isAuthenticated(); this._isAuthenticated.set(isAuthenticated); if (isAuthenticated) { const userData = this.tokenRepository.getUserData(); this._user.set(userData); this.scheduleTokenRefresh(); } } catch { this.logout(); } finally { this._isLoading.set(false); } } /** * Schedule token refresh based on actual expiration time */ scheduleTokenRefresh() { const accessToken = this.tokenRepository.getToken(); if (!accessToken) { return; } try { const decodedToken = this.decodeToken(accessToken); const currentTime = Math.floor(Date.now() / 1000); const timeUntilExpiry = decodedToken.exp - currentTime; // Refresh 5 minutes before expiry (300 seconds) const refreshTime = Math.max((timeUntilExpiry - 300) * 1000, 1000); // Clear any existing timeout if (this.refreshTokenTimeout) { clearTimeout(this.refreshTokenTimeout); } this.ngZone.runOutsideAngular(() => { this.refreshTokenTimeout = window.setTimeout(() => { this.ngZone.run(() => { // Check if refresh is still needed before executing if (this.tokenRepository.needsRefresh()) { this.refreshToken().subscribe(); } }); }, refreshTime); }); } catch { // Silent fail - token might be invalid } } /** * Stop token refresh timer */ stopTokenRefreshTimer() { if (this.refreshTokenTimeout) { clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = undefined; } } /** * Refresh authentication tokens */ refreshToken() { // Return existing refresh request if one is in progress if (this.refreshInProgress$) { return this.refreshInProgress$; } const userData = this.tokenRepository.getUserData(); const refreshToken = this.tokenRepository.getRefreshToken(); if (!userData?.email || !refreshToken) { this.logout(); return of(null); } this.refreshInProgress$ = this.authRepository .refreshToken({ email: userData.email, refreshToken, }) .pipe(tap(tokens => { this.setAuthenticated(tokens); }), catchError(() => { this.logout(); return of(null); }), tap({ complete: () => { this.refreshInProgress$ = undefined; }, error: () => { this.refreshInProgress$ = undefined; }, })); return this.refreshInProgress$; } /** * Set authentication state after successful login */ setAuthenticated(tokens, rememberMe = false) { this.tokenRepository.saveTokens(tokens, rememberMe); this._isAuthenticated.set(true); const userData = this.tokenRepository.getUserData(); this._user.set(userData); this.scheduleTokenRefresh(); } /** * Logout user and clear all authentication data */ logout() { const user = this._user(); if (user) { // Call server-side logout first this.authRepository.logout(user.email, '').subscribe({ next: () => { // Server logout successful, clear client-side data this.performClientLogout(); }, error: () => { // Server logout failed, still clear client-side data for security this.performClientLogout(); }, }); } else { // No user data, just clear client-side data this.performClientLogout(); } } /** * Perform client-side logout operations */ performClientLogout() { this.stopTokenRefreshTimer(); this.tokenRepository.clearTokens(); this._isAuthenticated.set(false); this._user.set(null); // Navigate to login page this.router.navigate(['/auth']); } /** * Check if user is authenticated */ checkAuthentication() { const isAuthenticated = this.tokenRepository.isAuthenticated(); this._isAuthenticated.set(isAuthenticated); if (!isAuthenticated) { this.logout(); } return isAuthenticated; } /** * Decode JWT token */ decodeToken(token) { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent(atob(base64) .split('') .map(c => { return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`; }) .join('')); return JSON.parse(jsonPayload); } catch { throw new Error('Invalid token format'); } } /** * Cleanup on store destruction */ ngOnDestroy() { this.stopTokenRefreshTimer(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthStore, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthStore, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); /** * Service to manage URL redirection after authentication * Stores the intended URL when session is lost and redirects to it after successful login * SSR-compatible by checking platform before accessing sessionStorage */ class AuthUrlRedirect { REDIRECT_URL_KEY = 'acp_redirect_url'; EXCLUDED_ROUTES = [ '/login', '/auth', '/register', '/forgot-password', '/reset-password', ]; router = inject(Router); platformId = inject(PLATFORM_ID); document = inject(DOCUMENT); /** * Stores the current URL for later redirection * @param url - The URL to store (defaults to current URL) */ storeIntendedUrl(url) { // Only store in browser environment if (!this.isBrowser()) { return; } const urlToStore = url || this.router.url; // Don't store authentication-related routes if (this.isExcludedRoute(urlToStore)) { return; } // Don't store URLs with query parameters that might contain sensitive data const urlWithoutParams = urlToStore.split('?')[0]; this.getSessionStorage()?.setItem(this.REDIRECT_URL_KEY, urlWithoutParams); } /** * Gets the stored intended URL * @returns The stored URL or null if none exists */ getIntendedUrl() { if (!this.isBrowser()) { return null; } return this.getSessionStorage()?.getItem(this.REDIRECT_URL_KEY) || null; } /** * Redirects to the stored URL and clears it from storage * @param defaultRoute - The default route to navigate to if no URL is stored */ redirectToIntendedUrl(defaultRoute = '/') { const intendedUrl = this.getIntendedUrl(); if (intendedUrl && !this.isExcludedRoute(intendedUrl)) { this.clearIntendedUrl(); this.router.navigateByUrl(intendedUrl); } else { this.router.navigate([defaultRoute]); } } /** * Clears the stored intended URL */ clearIntendedUrl() { if (!this.isBrowser()) { return; } this.getSessionStorage()?.removeItem(this.REDIRECT_URL_KEY); } /** * Checks if a URL should be excluded from redirection * @param url - The URL to check * @returns True if the URL should be excluded */ isExcludedRoute(url) { return this.EXCLUDED_ROUTES.some(route => url.includes(route)); } /** * Stores the current URL if it's not an excluded route * Useful for guards and interceptors */ storeCurrentUrlIfAllowed() { const currentUrl = this.router.url; if (!this.isExcludedRoute(currentUrl)) { this.storeIntendedUrl(currentUrl); } } /** * Checks if we're running in a browser environment * @returns True if running in browser, false if SSR */ isBrowser() { return isPlatformBrowser(this.platformId); } /** * Safely gets sessionStorage reference * @returns sessionStorage object or null if not available */ getSessionStorage() { if (!this.isBrowser()) { return null; } try { return this.document.defaultView?.sessionStorage || null; } catch { // Handle cases where sessionStorage might be disabled return null; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthUrlRedirect, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthUrlRedirect, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthUrlRedirect, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/application/use-cases/login-use-case.ts class LoginUseCase extends BaseUseCase { authRepository = inject(AuthRepository); authStore = inject(AuthStore); router = inject(Router); urlRedirectService = inject(AuthUrlRedirect); execute(request) { return this.authRepository.login(request).pipe(tap(tokens => { // Set authentication state with rememberMe preference this.authStore.setAuthenticated(tokens, request.rememberMe ?? false); // Redirect to intended URL or default route this.urlRedirectService.redirectToIntendedUrl('/'); })); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LoginUseCase, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LoginUseCase, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LoginUseCase, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/application/use-cases/register-use-case.ts class RegisterUseCase extends BaseUseCase { authRepository = inject(AuthRepository); authStore = inject(AuthStore); router = inject(Router); execute(request) { return this.authRepository.register(request).pipe(tap(tokens => { // Set authentication state this.authStore.setAuthenticated(tokens); // Navigate to main page this.router.navigate(['/']); })); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RegisterUseCase, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RegisterUseCase, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RegisterUseCase, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/application/use-cases/refresh-token-use-case.ts class RefreshTokenUseCase extends BaseUseCase { authRepository = inject(AuthRepository); tokenRepository = inject(AuthTokenRepositoryImpl); authStore = inject(AuthStore); execute() { const userData = this.tokenRepository.getUserData(); const refreshToken = this.tokenRepository.getRefreshToken(); if (!userData?.email || !refreshToken || refreshToken.trim().length === 0) { const error = new Error('No refresh token or email available'); return throwError(() => error); } return this.authRepository .refreshToken({ email: userData.email, refreshToken, }) .pipe(tap(tokens => { // Preserve the rememberMe preference from the current token storage const rememberMe = this.tokenRepository.isRememberMeEnabled(); // Update authentication state this.authStore.setAuthenticated(tokens, rememberMe); }), catchError(error => { // Don't logout here, let the interceptor handle it return throwError(() => error); })); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RefreshTokenUseCase, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RefreshTokenUseCase, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: RefreshTokenUseCase, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/application/use-cases/logout-use-case.ts class LogoutUseCase extends BaseUseCase { authRepository = inject(AuthRepository); tokenRepository = inject(AuthTokenRepositoryImpl); authStore = inject(AuthStore); execute() { const userData = this.tokenRepository.getUserData(); const refreshToken = this.tokenRepository.getRefreshToken(); if (userData?.email && refreshToken && refreshToken.length > 0) { return this.authRepository .logout(userData.email, refreshToken) .pipe(tap(() => this.cleanup())); } this.cleanup(); return of(void 0); } cleanup() { // Use auth store for centralized logout this.authStore.logout(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LogoutUseCase, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LogoutUseCase, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: LogoutUseCase, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/application/use-cases/index.ts // src/lib/application/index.ts // src/lib/data/repositories/auth-http.base-repository.ts function getDeviceInfo() { return `${navigator.platform ?? 'Unknown'} - ${navigator.userAgent}`; } class AuthHttpRepository extends AuthRepository { http = inject(HttpClient); URL = `${AUTH_API.AUTH}`; login(request) { return this.http.post(`${this.URL}login`, request, { headers: { 'Device-Info': getDeviceInfo(), }, withCredentials: true, }); } register(request) { return this.http.post(`${this.URL}register`, request, { headers: { 'Device-Info': getDeviceInfo(), }, withCredentials: true, }); } refreshToken(request) { return this.http.post(`${this.URL}refresh`, request, { headers: { 'Device-Info': getDeviceInfo(), }, withCredentials: true, }); } logout(email, refreshToken) { return this.http.post(`${this.URL}logout`, { email, refreshToken: refreshToken || undefined }, { headers: {}, withCredentials: true, // Ensure cookies are sent }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthHttpRepository, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthHttpRepository, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: AuthHttpRepository, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // src/lib/data/repositories/index.ts // src/lib/data/index.ts // src/lib/domain/repositories/auth.ts // src/lib/domain/repositories/index.ts // src/lib/domain/repositories/index.ts const authGuard = (_route, state) => { const tokenRepository = inject(AuthTokenRepositoryImpl); const router = inject(Router); const urlRedirectService = inject(AuthUrlRedirect); const environment = inject(ENVIRONMENT); if (tokenRepository.isAuthenticated()) { return true; } // Store the current URL for redirection after login urlRedirectService.storeIntendedUrl(state.url); // Redirect to login page (configurable via environment) router.navigate([`/${environment.loginRoute}`]); return false; }; /** * Interceptor that handles authentication errors and manages URL redirection * Captures the current URL when a 401 error occurs and redirects to login */ const authRedirectInterceptor = (req, next) => { const router = inject(Router); const urlRedirectService = inject(AuthUrlRedirect); const tokenRepository = inject(AuthTokenRepositoryImpl); const environment = inject(ENVIRONMENT); return next(req).pipe(catchError$1((error) => { // Handle 401 Unauthorized errors if (error.status === 401) { // Only store and redirect if user was previously authenticated // This prevents redirect loops and handles session expiry scenarios if (tokenRepository.isAuthenticated()) { // Store the current URL for redirection after re-authentication urlRedirectService.storeCurrentUrlIfAllowed(); // Navigate to login page router.navigate([`/${environment.loginRoute}`]); } } // Re-throw the error so other error handlers can process it return throwError(() => error); })); }; // src/lib/services/csrf-api.ts class CsrfApi { http = inject(HttpClient); csrfToken = null; /** * Get CSRF token, fetching it if not available */ async getCsrfToken() { if (this.csrfToken) { return this.csrfToken; } try { this.csrfToken = await firstValueFrom(this.http .get('csrf-token') .pipe(map(response => response.csrfToken))); return this.csrfToken || ''; } catch { // If CSRF endpoint fails, return empty token // Server should handle missing CSRF tokens appropriately return ''; } } /** * Clear stored CSRF token (useful on logout) */ clearCsrfToken() { this.csrfToken = null; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: CsrfApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: CsrfApi, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: CsrfApi, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); // A token to use with HttpContext for skipping CSRF token addition on specific requests. const SKIP_CSRF = new HttpContextToken(() => false); /** * HTTP interceptor that automatically adds CSRF tokens to state-changing requests * Only applies to requests to the same origin to avoid leaking tokens to external APIs */ const csrfInterceptor = (req, next) => { const csrfService = inject(CsrfApi); // Check if CSRF should be skipped for this request const skipCsrf = req.context.get(SKIP_CSRF); if (skipCsrf) { return next(req); } // Only add CSRF token to state-changing requests (POST, PUT, PATCH, DELETE) const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase()); // Only add CSRF token to same-origin requests const isSameOrigin = isRequestToSameOrigin(req); if (isStateChangingMethod && isSameOrigin) { return from(csrfService.getCsrfToken()).pipe(switchMap(csrfToken => { const modifiedReq = req.clone({ setHeaders: { 'X-CSRF-Token': csrfToken, }, }); return next(modifiedReq); })); } // For non-state-changing requests or external requests, proceed without modification return next(req); }; /** * Checks if the request is going to the same origin as the current application */ function isRequestToSameOrigin(req) { try { const requestUrl = new URL(req.url, window.location.origin); return requestUrl.origin === window.location.origin; } catch { // If URL parsing fails, assume it's not same origin for security return false; } } const authProviders = [ { provide: AuthRepository, useClass: AuthHttpRepository, }, { provide: AUTH_TOKEN, useClass: AuthTokenRepositoryImpl, }, ]; // src/lib/providers/index.ts // src/lib/presentation/stores/index.ts // src/lib/presentation/components/login/login.ts class Login { title = input('Login', ...(ngDevMode ? [{ debugName: "title" }] : [])); showRegisterButton = input(true, ...(ngDevMode ? [{ debugName: "showRegisterButton" }] : [])); showRememberMe = input(true, ...(ngDevMode ? [{ debugName: "showRememberMe" }] : [])); // Additional form controls that can be passed from parent components additionalSigninControls = input({}, ...(ngDevMode ? [{ debugName: "additionalSigninControls" }] : [])); additionalSignupControls = input({}, ...(ngDevMode ? [{ debugName: "additionalSignupControls" }] : [])); // Additional field templates additionalSigninFields = input(null, ...(ngDevMode ? [{ debugName: "additionalSigninFields" }] : [])); additionalSignupFields = input(null, ...(ngDevMode ? [{ debugName: "additionalSignupFields" }] : [])); // Footer content template footerContent = input(null, ...(ngDevMode ? [{ debugName: "footerContent" }] : [])); // Computed signal to check if footer content exists hasFooterContent = computed(() => this.footerContent() !== null, ...(ngDevMode ? [{ debugName: "hasFooterContent" }] : [])); fb = inject(FormBuilder); authStore = inject(AuthStore); loginUseCase = inject(LoginUseCase); registerUseCase = inject(RegisterUseCase); loggingService = inject(LoggingService); // Angular 20+ signals isLoginMode = signal(true, ...(ngDevMode ? [{ debugName: "isLoginMode" }] : [])); isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : [])); errorMessage = signal(null, ...(ngDevMode ? [{ debugName: "errorMessage" }] : [])); signinForm; signupForm; constructor() { this.signinForm = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', Validators.required], rememberMe: [false], // Default to false (unchecked) }); this.signupForm = this.fb.group({ displayName: ['', Validators.required], email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(6)]], }); } ngOnInit() { // Handle rememberMe control based on showRememberMe input if (!this.showRememberMe()) { // Remove rememberMe control if not needed this.signinForm.removeControl('rememberMe'); } // Add additional controls to signin form Object.entries(this.additionalSigninControls()).forEach(([key, control]) => { this.signinForm.addControl(key, control); }); // Add additional controls to signup form Object.entries(this.additionalSignupControls()).forEach(([key, control]) => { this.signupForm.addControl(key, control); }); } switchMode() { this.isLoginMode.set(!this.isLoginMode()); this.errorMessage.set(null); } signIn() { if (this.signinForm.valid) { this.isLoading.set(true); this.errorMessage.set(null); // Prepare login request with rememberMe handling const loginRequest = { ...this.signinForm.value, // If showRememberMe is false, default rememberMe to false rememberMe: this.showRememberMe() ? (this.signinForm.value.rememberMe ?? false) : false, }; this.loginUseCase.execute(loginRequest).subscribe({ next: () => { this.isLoading.set(false); }, error: error => { this.isLoading.set(false); this.errorMessage.set('Error al iniciar sesión. Verifique sus credenciales.'); this.loggingService.error('Login failed', { error }); }, }); } } registerUser() { if (this.signupForm.valid) { this.isLoading.set(true); this.errorMessage.set(null); this.registerUseCase.execute(this.signupForm.value).subscribe({ next: () => { this.isLoading.set(false); this.signupForm.reset(); this.isLoginMode.set(true); }, error: error => { this.isLoading.set(false); this.errorMessage.set('Error al registrar usuario. Intente nuevamente.'); this.loggingService.error('Register error', { error }); }, }); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: Login, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: Login, isStandalone: true, selector: "acp-login", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, showRegisterButton: { classPropertyName: "showRegisterButton", publicName: "showRegisterButton", isSignal: true, isRequired: false, transformFunction: null }, showRememberMe: { classPropertyName: "showRememberMe", publicName: "showRememberMe", isSignal: true, isRequired: false, transformFunction: null }, additionalSigninControls: { classPropertyName: "additionalSigninControls", publicName: "additionalSigninControls", isSignal: true, isRequired: false, transformFunction: null }, additionalSignupControls: { classPropertyName: "additionalSignupControls", publicName: "additionalSignupControls", isSignal: true, isRequired: false, transformFunction: null }, additionalSigninFields: { classPropertyName: "additionalSigninFields", publicName: "additionalSigninFields", isSignal: true, isRequired: false, transformFunction: null }, additionalSignupFields: { classPropertyName: "additionalSignupFields", publicName: "additionalSignupFields", isSignal: true, isRequired: false, transformFunction: null }, footerContent: { classPropertyName: "footerContent", publicName: "footerContent", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<mat-card class=\"mat-elevation-z8 p-4 rounded\">\n <mat-card-header>\n <mat-card-title class=\"text-center\">{{ title() }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (isLoginMode()) {\n <form [formGroup]=\"signinForm\" (ngSubmit)=\"signIn()\" class=\"d-flex flex-column gap-3\">\n <mat-form-field class=\"w-100\">\n <mat-label>Usuario</mat-label>\n <input matInput type=\"text\" placeholder=\"Ingrese su usuario\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Contrase\u00F1a</mat-label>\n <input\n matInput\n type=\"password\"\n placeholder=\"Ingrese su contrase\u00F1a\"\n formControlName=\"password\"\n autocomplete=\"current-password\"\n />\n </mat-form-field>\n\n <!-- Remember Me checkbox - conditional -->\n @if (showRememberMe()) {\n <div class=\"d-flex align-items-center mt-2\">\n <mat-checkbox formControlName=\"rememberMe\"> Recordarme </mat-checkbox>\n </div>\n }\n\n <!-- Additional signin fields -->\n <ng-container *ngTemplateOutlet=\"additionalSigninFields()\"></ng-container>\n\n <div class=\"d-flex justify-content-center mt-3\">\n <button\n mat-raised-button\n color=\"primary\"\n [disabled]=\"!signinForm.valid || isLoading()\"\n type=\"submit\"\n class=\"w-100\"\n >\n @if (isLoading()) { Ingresando... } @else {\n <ng-container>Ingresar <mat-icon>login</mat-icon></ng-container>\n }\n </button>\n </div>\n\n <div class=\"text-center mt-2\">\n @if (showRegisterButton()) {\n <button mat-button type=\"button\" (click)=\"switchMode()\">\n \u00BFNo tienes cuenta? Reg\u00EDstrate\n </button>\n }\n </div>\n </form>\n } @else {\n <form [formGroup]=\"signupForm\" (ngSubmit)=\"registerUser()\" class=\"d-flex flex-column gap-3\">\n <mat-form-field class=\"w-100\">\n <mat-label>Nombre</mat-label>\n <input matInput type=\"text\" placeholder=\"Ingrese su nombre\" formControlName=\"displayName\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Email</mat-label>\n <input matInput type=\"email\" placeholder=\"Ingrese su email\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Contrase\u00F1a</mat-label>\n <input\n matInput\n type=\"password\"\n placeholder=\"Ingrese su contrase\u00F1a\"\n formControlName=\"password\"\n autocomplete=\"new-password\"\n />\n </mat-form-field>\n\n <!-- Additional signup fields -->\n <ng-container *ngTemplateOutlet=\"additionalSignupFields()\"></ng-container>\n\n <div class=\"d-flex justify-content-center mt-3\">\n <button\n mat-raised-button\n color=\"primary\"\n [disabled]=\"!signupForm.valid || isLoading()\"\n type=\"submit\"\n class=\"w-100\"\n >\n @if (isLoading()) { Registrando... } @else {\n <ng-container>Registrarse <mat-icon>person_add</mat-icon></ng-container>\n }\n </button>\n </div>\n\n <div class=\"text-center mt-2\">\n <button mat-button type=\"button\" (click)=\"switchMode()\">\n \u00BFYa tienes cuenta? Inicia sesi\u00F3n\n </button>\n </div>\n </form>\n } @if (errorMessage()) {\n <div class=\"alert alert-danger mt-3\" role=\"alert\">{{ errorMessage() }}</div>\n }\n </mat-card-content>\n @if (hasFooterContent()) {\n <mat-card-footer>\n <ng-container *ngTemplateOutlet=\"footerContent()\"></ng-container>\n </mat-card-footer>\n }\n</mat-card>\n", styles: [":host{display:flex;justify-content:center;align-items:center;min-height:100vh;width:100%;padding:16px;box-sizing:border-box}:host mat-card{width:100%;max-width:400px;border-radius:12px;box-shadow:0 8px 32px #0000001a}:host mat-card-header{text-align:center;margin-bottom:20px}:host mat-card-title{font-size:24px;font-weight:600;color:var(--mat-sys-primary, #6750a4)}:host mat-form-field{width:100%}:host mat-button{border-radius:8px}:host .w-100{width:100%}:host .d-flex{display:flex}:host .flex-column{flex-direction:column}:host .gap-3{gap:12px}:host .justify-content-center{justify-content:center}:host .align-items-center{align-items:center}:host .mt-3{margin-top:12px}:host .mt-2{margin-top:8px}:host .text-center{text-align:center}:host .p-4{padding:16px}:host .rounded{border-radius:12px}:host .m-t-10{margin-top:10px}:host .alert-danger{background-color:var(--mat-sys-error-container, #ffdad6);border-color:var(--mat-sys-error, #ba1a1a);color:var(--mat-sys-on-error-container, #410002);padding:12px;border-radius:4px;border:1px solid transparent}:host .row{display:flex;flex-wrap:wrap;margin:0 -15px}:host .col-xs-12,:host .col-sm-12,:host .col-md-12{flex:0 0 100%;max-width:100%;padding:0 15px}:host .social{display:flex;justify-content:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i2.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i2.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i2.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "directive", type: MatCardContent, selector: "mat-card-content" }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatCardFooter, selector: "mat-card-footer" }, { kind: "component", type: MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: Login, decorators: [{ type: Component, args: [{ selector: 'acp-login', imports: [ CommonModule, ReactiveFormsModule, MatCard, MatCardHeader, MatLabel, MatCardTitle, MatCardContent, MatFormField, MatInput, MatIcon, MatButton, MatCardFooter, MatAnchor, MatCheckbox, ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: "<mat-card class=\"mat-elevation-z8 p-4 rounded\">\n <mat-card-header>\n <mat-card-title class=\"text-center\">{{ title() }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (isLoginMode()) {\n <form [formGroup]=\"signinForm\" (ngSubmit)=\"signIn()\" class=\"d-flex flex-column gap-3\">\n <mat-form-field class=\"w-100\">\n <mat-label>Usuario</mat-label>\n <input matInput type=\"text\" placeholder=\"Ingrese su usuario\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Contrase\u00F1a</mat-label>\n <input\n matInput\n type=\"password\"\n placeholder=\"Ingrese su contrase\u00F1a\"\n formControlName=\"password\"\n autocomplete=\"current-password\"\n />\n </mat-form-field>\n\n <!-- Remember Me checkbox - conditional -->\n @if (showRememberMe()) {\n <div class=\"d-flex align-items-center mt-2\">\n <mat-checkbox formControlName=\"rememberMe\"> Recordarme </mat-checkbox>\n </div>\n }\n\n <!-- Additional signin fields -->\n <ng-container *ngTemplateOutlet=\"additionalSigninFields()\"></ng-container>\n\n <div class=\"d-flex justify-content-center mt-3\">\n <button\n mat-raised-button\n color=\"primary\"\n [disabled]=\"!signinForm.valid || isLoading()\"\n type=\"submit\"\n class=\"w-100\"\n >\n @if (isLoading()) { Ingresando... } @else {\n <ng-container>Ingresar <mat-icon>login</mat-icon></ng-container>\n }\n </button>\n </div>\n\n <div class=\"text-center mt-2\">\n @if (showRegisterButton()) {\n <button mat-button type=\"button\" (click)=\"switchMode()\">\n \u00BFNo tienes cuenta? Reg\u00EDstrate\n </button>\n }\n </div>\n </form>\n } @else {\n <form [formGroup]=\"signupForm\" (ngSubmit)=\"registerUser()\" class=\"d-flex flex-column gap-3\">\n <mat-form-field class=\"w-100\">\n <mat-label>Nombre</mat-label>\n <input matInput type=\"text\" placeholder=\"Ingrese su nombre\" formControlName=\"displayName\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Email</mat-label>\n <input matInput type=\"email\" placeholder=\"Ingrese su email\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field class=\"w-100\">\n <mat-label>Contrase\u00F1a</mat-label>\n <input\n matInput\n type=\"password\"\n placeholder=\"Ingrese su contrase\u00F1a\"\n formControlName=\"password\"\n autocomplete=\"new-password\"\n />\n </mat-form-field>\n\n <!-- Additional signup fields -->\n <ng-