diff --git a/src/app/core/services/auth/auth.service.spec.ts b/src/app/core/services/auth/auth.service.spec.ts index 3fa671f..aaea6bc 100644 --- a/src/app/core/services/auth/auth.service.spec.ts +++ b/src/app/core/services/auth/auth.service.spec.ts @@ -96,7 +96,7 @@ describe('AuthService', () => { httpController.verify(); }); - it('propagates a 401 from /me without clearing the session', async () => { + it('clears an expired session when /me returns 401', async () => { cookieStore['shopit.auth.token'] = 'expired-token'; TestBed.configureTestingModule({ @@ -117,11 +117,11 @@ describe('AuthService', () => { request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); - await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 }); + await expect(bootstrapPromise).resolves.toBeUndefined(); expect(service.user()).toBeNull(); - expect(service.token()).toBe('expired-token'); - expect(service.isAuthenticated()).toBe(true); - expect(cookieStore['shopit.auth.token']).toBe('expired-token'); + expect(service.token()).toBeNull(); + expect(service.isAuthenticated()).toBe(false); + expect(cookieStore['shopit.auth.token']).toBeUndefined(); httpController.verify(); }); diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index c68ce5d..00ab5d1 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -1,4 +1,5 @@ -import { HttpClient } from '@angular/common/http'; +import { DOCUMENT } from '@angular/common'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; @@ -23,6 +24,7 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey('shopit.auth.user'); }) export class AuthService { private readonly http = inject(HttpClient); + private readonly document = inject(DOCUMENT); private readonly platformId = inject(PLATFORM_ID); private readonly cookieService = inject(CookieService); private readonly transferState = inject(TransferState); @@ -51,6 +53,27 @@ export class AuthService { }); } + loginWithGoogle(): void { + const tenant = this.tenantService.getTenant(); + if (!tenant) { + throw new Error('No se pudo resolver el tenant activo.'); + } + + const apiUrl = new URL(environment.url); + const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin); + authorizationUrl.searchParams.set('tenant', tenant.codigo); + authorizationUrl.searchParams.set('return_url', this.document.location.origin); + + this.document.location.assign(authorizationUrl.toString()); + } + + completeGoogleLogin(oauthCode: string): Observable { + return this.http.post(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe( + tap((response) => this.applyAuthenticatedState(response.token, response.user)), + map((response) => response.user) + ); + } + updateProfile(payload: UpdateProfilePayload): Observable { return this.http.put(`${environment.url}me`, payload).pipe( tap((user) => this.userState.set(user)) @@ -82,9 +105,24 @@ export class AuthService { return; } - const user = await firstValueFrom(this.loadCurrentUser()); - if (isPlatformServer(this.platformId)) { - this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); + try { + const user = await firstValueFrom(this.loadCurrentUser()); + + if (isPlatformServer(this.platformId)) { + this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); + } + } catch (error) { + // An expired token is an expected state, including while rendering on the + // server. Do not let it reject the application initializer: Angular's SSR + // pipeline can otherwise attempt to serialize HttpErrorResponse internals + // as HTTP headers. + if (error instanceof HttpErrorResponse && error.status === 401) { + this.clearSession(); + + return; + } + + throw error; } } diff --git a/src/app/features/store/pages/login-page/login-page.component.html b/src/app/features/store/pages/login-page/login-page.component.html index 8583f2c..776f5d8 100644 --- a/src/app/features/store/pages/login-page/login-page.component.html +++ b/src/app/features/store/pages/login-page/login-page.component.html @@ -81,7 +81,8 @@ variant="neutral-outline" hostClass="w-100 d-block" buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold" - [disabled]="true" + [disabled]="isSubmitting()" + (click)="loginWithGoogle()" > (null); @@ -35,6 +37,18 @@ export class LoginPageComponent { protected readonly serverError = this.serverErrorState.asReadonly(); protected readonly isSubmitting = this.isSubmittingState.asReadonly(); + ngOnInit(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const oauthCode = this.route.snapshot.queryParamMap.get('oauth_code'); + + if (oauthCode) { + this.completeGoogleLogin(oauthCode); + } + } + goToRegister(): void { void this.router.navigate(['/register']); } @@ -62,6 +76,16 @@ export class LoginPageComponent { }); } + loginWithGoogle(): void { + this.serverErrorState.set(null); + + try { + this.authService.loginWithGoogle(); + } catch (error: unknown) { + this.serverErrorState.set(this.resolveErrorMessage(error)); + } + } + protected updateEmail(value: string | number): void { this.form.controls.email.setValue(String(value)); } @@ -107,6 +131,22 @@ export class LoginPageComponent { this.document.location.assign(homeUrl); } + private completeGoogleLogin(oauthCode: string): void { + this.isSubmittingState.set(true); + this.serverErrorState.set(null); + + this.authService.completeGoogleLogin(oauthCode).subscribe({ + next: () => { + this.isSubmittingState.set(false); + this.redirectToHome(); + }, + error: (error: unknown) => { + this.isSubmittingState.set(false); + this.serverErrorState.set(this.resolveErrorMessage(error)); + } + }); + } + private resolveErrorMessage(error: unknown): string { const errorPayload = typeof error === 'object' && error !== null && 'error' in error