From 80752f75130afb366d363b360468d35533c38ef2 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Mon, 15 Dec 2025 21:25:28 -0600 Subject: [PATCH] Add refresh logic to auth interceptor and service. Misc. prettier. --- src/app/interceptor/auth.interceptor.ts | 35 ++++++++++++++++++- .../recipe-page-content.html | 4 +-- .../recipe-page-content.ts | 10 ++---- src/app/service/auth.service.ts | 25 +++++++++++-- src/app/service/recipe.service.ts | 7 ++-- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/app/interceptor/auth.interceptor.ts b/src/app/interceptor/auth.interceptor.ts index 87c7a13..c886d10 100644 --- a/src/app/interceptor/auth.interceptor.ts +++ b/src/app/interceptor/auth.interceptor.ts @@ -1,15 +1,48 @@ -import { HttpInterceptorFn } from '@angular/common/http'; +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { AuthService } from '../service/auth.service'; +import { catchError, from, switchMap, throwError } from 'rxjs'; +import { Router } from '@angular/router'; export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); + const router = inject(Router); const token = authService.accessToken(); if (token) { + // first we try with the current token return next( req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`), }), + ).pipe( + catchError((error: HttpErrorResponse) => { + // if the request with the current token returned 401, + // try refreshing, then retry the request with the new token + // for this first scenario, do not retry if we are getting 401 from login or refreshing + if ( + error.status === 401 && + !(error.url?.endsWith('auth/login') || error.url?.endsWith('auth/refresh')) + ) { + return authService.refresh().pipe( + switchMap((loginView) => { + const newToken = loginView.accessToken; + return next( + req.clone({ + headers: req.headers.set('Authorization', `Bearer ${newToken}`), + }), + ); + }), + ); + } else if (error.status === 401 && error.url?.endsWith('auth/refresh')) { + // our refresh token is expired + // redirect to login page + return from(router.navigate(['/'])).pipe( + switchMap(() => throwError(() => error)), + ); + } else { + return throwError(() => error); + } + }), ); } else { return next(req); diff --git a/src/app/recipe-page/recipe-page-content/recipe-page-content.html b/src/app/recipe-page/recipe-page-content/recipe-page-content.html index 4a4ad04..1651f75 100644 --- a/src/app/recipe-page/recipe-page-content/recipe-page-content.html +++ b/src/app/recipe-page/recipe-page-content/recipe-page-content.html @@ -4,13 +4,13 @@

{{ recipe.title }}

@if (isLoggedIn()) { } @else {
- + {{ recipe.starCount }}
} diff --git a/src/app/recipe-page/recipe-page-content/recipe-page-content.ts b/src/app/recipe-page/recipe-page-content/recipe-page-content.ts index 6c5feec..df8187d 100644 --- a/src/app/recipe-page/recipe-page-content/recipe-page-content.ts +++ b/src/app/recipe-page/recipe-page-content/recipe-page-content.ts @@ -2,21 +2,18 @@ import { Component, computed, inject, input, Input } from '@angular/core'; import { RecipeView } from '../../model/Recipe.model'; import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; import { ImageService } from '../../service/image.service'; -import { faStar } from "@fortawesome/free-solid-svg-icons"; +import { faStar } from '@fortawesome/free-solid-svg-icons'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecipeService } from '../../service/recipe.service'; import { AuthService } from '../../service/auth.service'; @Component({ selector: 'app-recipe-page-content', - imports: [ - FaIconComponent - ], + imports: [FaIconComponent], templateUrl: './recipe-page-content.html', styleUrl: './recipe-page-content.css', }) export class RecipePageContent { - public recipeView = input.required(); private readonly imageService = inject(ImageService); @@ -30,7 +27,7 @@ export class RecipePageContent { return { queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename], queryFn: () => this.imageService.getImage(recipe.mainImage.url), - } + }; }); protected readonly starMutation = injectMutation(() => ({ @@ -38,5 +35,4 @@ export class RecipePageContent { })); protected readonly faStar = faStar; - } diff --git a/src/app/service/auth.service.ts b/src/app/service/auth.service.ts index d977b09..b75af77 100644 --- a/src/app/service/auth.service.ts +++ b/src/app/service/auth.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable, Signal, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { LoginView } from '../model/LoginView.model'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, Observable, tap } from 'rxjs'; import { QueryClient } from '@tanstack/angular-query-experimental'; import { Router } from '@angular/router'; @@ -21,7 +21,13 @@ export class AuthService { public async login(username: string, password: string): Promise { const loginView = await firstValueFrom( - this.http.post('http://localhost:8080/auth/login', { username, password }), + this.http.post( + 'http://localhost:8080/auth/login', + { username, password }, + { + withCredentials: true, + }, + ), ); this._accessToken.set(loginView.accessToken); this._username.set(loginView.username); @@ -36,4 +42,19 @@ export class AuthService { await this.router.navigate(['/']); await this.queryClient.invalidateQueries(); } + + public refresh(): Observable { + this._accessToken.set(null); + this._username.set(null); + return this.http + .post('http://localhost:8080/auth/refresh', null, { + withCredentials: true, + }) + .pipe( + tap((loginView) => { + this._username.set(loginView.username); + this._accessToken.set(loginView.accessToken); + }), + ); + } } diff --git a/src/app/service/recipe.service.ts b/src/app/service/recipe.service.ts index 096daba..f0fd110 100644 --- a/src/app/service/recipe.service.ts +++ b/src/app/service/recipe.service.ts @@ -23,7 +23,7 @@ export class RecipeService { public async getRecipeView(username: string, slug: string): Promise { return firstValueFrom( - this.http.get(`http://localhost:8080/recipes/${username}/${slug}`) + this.http.get(`http://localhost:8080/recipes/${username}/${slug}`), ); } @@ -40,10 +40,9 @@ export class RecipeService { } await this.queryClient.invalidateQueries({ queryKey: ['recipe', recipeView.recipe.owner.username, recipeView.recipe.slug], - }) + }); } else { - throw new Error('Cannot star a recipe when not logged in.') + throw new Error('Cannot star a recipe when not logged in.'); } } - }