Compare commits

..

2 Commits

Author SHA1 Message Date
Jesse Brault
80752f7513 Add refresh logic to auth interceptor and service. Misc. prettier. 2025-12-15 21:25:28 -06:00
Jesse Brault
28222e0655 Add starring ability. 2025-12-15 20:34:31 -06:00
9 changed files with 163 additions and 40 deletions

View File

@ -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);

View File

@ -1,11 +1,27 @@
<h1>{{ recipe.title }}</h1>
@if (mainImageUrl.isSuccess()) {
<!--suppress AngularNgOptimizedImage -->
@let recipe = recipeView().recipe;
<article>
<div id="recipe-header">
<h1>{{ recipe.title }}</h1>
@if (isLoggedIn()) {
<button id="star" (click)="starMutation.mutate()">
<fa-icon [icon]="faStar" />
<span>Star</span>
<span id="star-count">{{ recipe.starCount }}</span>
</button>
} @else {
<div>
<fa-icon [icon]="faStar" />
<span id="star-count">{{ recipe.starCount }}</span>
</div>
}
</div>
@if (mainImageUrl.isSuccess()) {
<img
[src]="mainImageUrl.data()"
[alt]="recipe.mainImage.alt"
[height]="recipe.mainImage.height"
[width]="recipe.mainImage.width"
/>
}
<div [innerHTML]="recipe.text"></div>
}
<div [innerHTML]="recipe.text"></div>
</article>

View File

@ -1,22 +1,38 @@
import { Component, inject, Input } from '@angular/core';
import { Recipe } from '../../model/Recipe.model';
import { injectQuery } from '@tanstack/angular-query-experimental';
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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecipeService } from '../../service/recipe.service';
import { AuthService } from '../../service/auth.service';
@Component({
selector: 'app-recipe-page-content',
imports: [],
imports: [FaIconComponent],
templateUrl: './recipe-page-content.html',
styleUrl: './recipe-page-content.css',
})
export class RecipePageContent {
@Input({ required: true })
public recipe!: Recipe;
public recipeView = input.required<RecipeView>();
private readonly imageService = inject(ImageService);
private readonly recipeService = inject(RecipeService);
private readonly authService = inject(AuthService);
protected mainImageUrl = injectQuery(() => ({
queryKey: ['images', this.recipe.mainImage.owner.username, this.recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(this.recipe.mainImage.url),
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected readonly mainImageUrl = injectQuery(() => {
const recipe = this.recipeView().recipe;
return {
queryKey: ['images', recipe.mainImage.owner.username, recipe.mainImage.filename],
queryFn: () => this.imageService.getImage(recipe.mainImage.url),
};
});
protected readonly starMutation = injectMutation(() => ({
mutationFn: () => this.recipeService.toggleStar(this.recipeView()),
}));
protected readonly faStar = faStar;
}

View File

@ -1,8 +1,8 @@
@if (recipe.isLoading()) {
@if (recipeView.isLoading()) {
<p>Loading...</p>
} @else if (recipe.isSuccess()) {
<app-recipe-page-content [recipe]="recipe.data()"></app-recipe-page-content>
} @else if (recipe.error(); as error) {
} @else if (recipeView.isSuccess()) {
<app-recipe-page-content [recipeView]="recipeView.data()"></app-recipe-page-content>
} @else if (recipeView.error(); as error) {
<p>{{ error.message }}</p>
} @else {
<p>There was an error loading the recipe.</p>

View File

@ -16,8 +16,8 @@ export class RecipePage {
private username = this.route.snapshot.paramMap.get('username') as string;
private slug = this.route.snapshot.paramMap.get('slug') as string;
protected recipe = injectQuery(() => ({
protected recipeView = injectQuery(() => ({
queryKey: ['recipe', this.username, this.slug],
queryFn: () => this.recipeService.getRecipe(this.username, this.slug),
queryFn: () => this.recipeService.getRecipeView(this.username, this.slug),
}));
}

View File

@ -1,5 +1,22 @@
.recipe-card-image {
#recipe-card-image {
max-height: 200px;
width: 100%;
object-fit: cover;
}
article {
display: flex;
flex-direction: column;
row-gap: 5px;
}
#recipe-title {
margin: 0;
font-size: 18px;
}
#title-and-visibility,
#user-and-stars {
display: flex;
justify-content: space-between;
}

View File

@ -1,13 +1,12 @@
<article>
<a [routerLink]="recipePageLink()">
@if (mainImage.isSuccess()) {
<!--suppress AngularNgOptimizedImage -->
<img [src]="mainImage.data()" class="recipe-card-image" [alt]="recipe.mainImage.alt" />
<img [src]="mainImage.data()" id="recipe-card-image" [alt]="recipe.mainImage.alt" />
}
</a>
<div>
<div id="title-and-visibility">
<a [routerLink]="recipePageLink()">
<h1>{{ recipe.title }}</h1>
<h1 id="recipe-title">{{ recipe.title }}</h1>
</a>
@if (recipe.isPublic) {
<fa-icon [icon]="faGlobe" />
@ -15,7 +14,7 @@
<fa-icon [icon]="faLock" />
}
</div>
<div>
<div id="user-and-stars">
<span><fa-icon [icon]="faUser" /> {{ recipe.owner.username }}</span>
<span><fa-icon [icon]="faStar" /> {{ recipe.starCount }}</span>
</div>

View File

@ -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<LoginView> {
const loginView = await firstValueFrom(
this.http.post<LoginView>('http://localhost:8080/auth/login', { username, password }),
this.http.post<LoginView>(
'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<LoginView> {
this._accessToken.set(null);
this._username.set(null);
return this.http
.post<LoginView>('http://localhost:8080/auth/refresh', null, {
withCredentials: true,
})
.pipe(
tap((loginView) => {
this._username.set(loginView.username);
this._accessToken.set(loginView.accessToken);
}),
);
}
}

View File

@ -1,13 +1,17 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map } from 'rxjs';
import { firstValueFrom, lastValueFrom, map } from 'rxjs';
import { Recipe, RecipeInfoViews, RecipeView } from '../model/Recipe.model';
import { AuthService } from './auth.service';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Injectable({
providedIn: 'root',
})
export class RecipeService {
private readonly http = inject(HttpClient);
private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient);
public getRecipes(): Promise<Recipe[]> {
return firstValueFrom(
@ -17,11 +21,28 @@ export class RecipeService {
);
}
public async getRecipe(username: string, slug: string): Promise<Recipe> {
public async getRecipeView(username: string, slug: string): Promise<RecipeView> {
return firstValueFrom(
this.http
.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`)
.pipe(map((recipeView) => recipeView.recipe)),
this.http.get<RecipeView>(`http://localhost:8080/recipes/${username}/${slug}`),
);
}
private getRecipeUrl(recipeView: RecipeView): string {
return `http://localhost:8080/recipes/${recipeView.recipe.owner.username}/${recipeView.recipe.slug}`;
}
public async toggleStar(recipeView: RecipeView) {
if (this.authService.accessToken()) {
if (recipeView.isStarred) {
await lastValueFrom(this.http.delete(this.getRecipeUrl(recipeView) + '/star'));
} else {
await lastValueFrom(this.http.post(this.getRecipeUrl(recipeView) + '/star', null));
}
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.');
}
}
}