@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
JavaScript
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-