diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index e407b8c..2c5a11d 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,10 +1,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ - { - path: 'checkout', - renderMode: RenderMode.Client - }, { path: '**', renderMode: RenderMode.Server diff --git a/src/app/core/services/auth/auth.service.spec.ts b/src/app/core/services/auth/auth.service.spec.ts index 292bcef..7692049 100644 --- a/src/app/core/services/auth/auth.service.spec.ts +++ b/src/app/core/services/auth/auth.service.spec.ts @@ -1,20 +1,37 @@ -import { PLATFORM_ID } from '@angular/core'; +import { PLATFORM_ID, TransferState } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { environment } from '../../../../environments/environment'; import { AuthService } from './auth.service'; +import { CookieService } from '../cookie/cookie.service'; describe('AuthService', () => { + let cookieStore: Record = {}; + beforeEach(() => { - window.localStorage.clear(); + cookieStore = {}; TestBed.resetTestingModule(); }); + function createCookieServiceStub() { + return { + get: (name: string) => cookieStore[name] || null, + set: (name: string, value: string) => { cookieStore[name] = value; }, + delete: (name: string) => { delete cookieStore[name]; } + }; + } + it('stores token and user on successful login', () => { TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), AuthService] + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] }); const service = TestBed.inject(AuthService); @@ -40,16 +57,22 @@ describe('AuthService', () => { expect(service.token()).toBe('plain-text-token'); expect(service.user()?.email).toBe('ada@example.com'); expect(service.isAuthenticated()).toBe(true); - expect(window.localStorage.getItem('shopit.auth.token')).toBe('plain-text-token'); + expect(cookieStore['shopit.auth.token']).toBe('plain-text-token'); httpController.verify(); }); - it('hydrates token from localStorage and loads the current user during bootstrap', async () => { - window.localStorage.setItem('shopit.auth.token', 'persisted-token'); + it('hydrates token from cookies and loads the current user during bootstrap', async () => { + cookieStore['shopit.auth.token'] = 'persisted-token'; TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), AuthService] + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] }); const service = TestBed.inject(AuthService); @@ -73,10 +96,16 @@ describe('AuthService', () => { }); it('clears the session when bootstrap receives 401 from /me', async () => { - window.localStorage.setItem('shopit.auth.token', 'expired-token'); + cookieStore['shopit.auth.token'] = 'expired-token'; TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), AuthService] + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] }); const service = TestBed.inject(AuthService); @@ -91,14 +120,20 @@ describe('AuthService', () => { expect(service.user()).toBeNull(); expect(service.token()).toBeNull(); expect(service.isAuthenticated()).toBe(false); - expect(window.localStorage.getItem('shopit.auth.token')).toBeNull(); + expect(cookieStore['shopit.auth.token']).toBeUndefined(); httpController.verify(); }); it('registers without creating a session', () => { TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), AuthService] + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] }); const service = TestBed.inject(AuthService); @@ -135,7 +170,13 @@ describe('AuthService', () => { it('clears local session even when logout request fails', () => { TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), AuthService] + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AuthService, + { provide: CookieService, useValue: createCookieServiceStub() }, + TransferState + ] }); const service = TestBed.inject(AuthService); @@ -163,26 +204,8 @@ describe('AuthService', () => { expect(service.token()).toBeNull(); expect(service.user()).toBeNull(); - expect(window.localStorage.getItem('shopit.auth.token')).toBeNull(); + expect(cookieStore['shopit.auth.token']).toBeUndefined(); httpController.verify(); }); - - it('does not access localStorage on the server', () => { - TestBed.configureTestingModule({ - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - AuthService, - { provide: PLATFORM_ID, useValue: 'server' } - ] - }); - - const service = TestBed.inject(AuthService); - - service.hydrateSession(); - - expect(service.token()).toBeNull(); - expect(service.isAuthenticated()).toBe(false); - }); }); diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 53bc084..78b195a 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { isPlatformBrowser } from '@angular/common'; -import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; +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'; import { environment } from '../../../../environments/environment'; @@ -11,8 +11,10 @@ import { RegisterPayload, RegisterResponse } from './auth.interfaces'; +import { CookieService } from '../cookie/cookie.service'; -const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token'; +const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token'; +const AUTH_USER_SSR_STATE_KEY = makeStateKey('shopit.auth.user'); @Injectable({ providedIn: 'root' @@ -20,6 +22,8 @@ const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token'; export class AuthService { private readonly http = inject(HttpClient); private readonly platformId = inject(PLATFORM_ID); + private readonly cookieService = inject(CookieService); + private readonly transferState = inject(TransferState); private readonly userState = signal(null); private readonly tokenState = signal(null); @@ -60,8 +64,18 @@ export class AuthService { return; } + const transferredUser = this.transferState.get(AUTH_USER_SSR_STATE_KEY, null); + if (transferredUser) { + this.transferState.remove(AUTH_USER_SSR_STATE_KEY); + this.userState.set(transferredUser); + return; + } + try { - await firstValueFrom(this.loadCurrentUser()); + const user = await firstValueFrom(this.loadCurrentUser()); + if (isPlatformServer(this.platformId)) { + this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); + } } catch (error) { if (this.isUnauthorizedError(error)) { this.clearSession(); @@ -79,34 +93,20 @@ export class AuthService { } hydrateSession(): void { - if (!isPlatformBrowser(this.platformId)) { - return; - } - - const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + const token = this.cookieService.get(AUTH_TOKEN_COOKIE_KEY); this.tokenState.set(token); } clearSession(): void { this.userState.set(null); this.tokenState.set(null); - - if (!isPlatformBrowser(this.platformId)) { - return; - } - - window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + this.cookieService.delete(AUTH_TOKEN_COOKIE_KEY); } private applyAuthenticatedState(token: string, user: AuthUser): void { this.tokenState.set(token); this.userState.set(user); - - if (!isPlatformBrowser(this.platformId)) { - return; - } - - window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token); } private isUnauthorizedError(error: unknown): error is HttpErrorResponse { diff --git a/src/app/core/services/cookie/cookie.service.ts b/src/app/core/services/cookie/cookie.service.ts new file mode 100644 index 0000000..340e3f4 --- /dev/null +++ b/src/app/core/services/cookie/cookie.service.ts @@ -0,0 +1,59 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID, REQUEST } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +@Injectable({ + providedIn: 'root' +}) +export class CookieService { + private readonly platformId = inject(PLATFORM_ID); + private readonly document = inject(DOCUMENT); + private readonly request = inject(REQUEST, { optional: true }); + + get(name: string): string | null { + if (isPlatformBrowser(this.platformId)) { + return this.getCookieFromString(name, this.document.cookie); + } + + if (this.request) { + const cookieHeader = this.request.headers.get('cookie') || ''; + return this.getCookieFromString(name, cookieHeader); + } + + return null; + } + + set(name: string, value: string, days = 7): void { + if (isPlatformBrowser(this.platformId)) { + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + const expires = `expires=${date.toUTCString()}`; + this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`; + } + } + + delete(name: string): void { + if (isPlatformBrowser(this.platformId)) { + this.document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`; + } + } + + private getCookieFromString(name: string, cookieString: string): string | null { + if (!cookieString) { + return null; + } + + const nameEQ = `${name}=`; + const ca = cookieString.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') { + c = c.substring(1, c.length); + } + if (c.indexOf(nameEQ) === 0) { + return c.substring(nameEQ.length, c.length); + } + } + return null; + } +}