From d635ba0986b84b25245f69ba96587d55b64c8d09 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Thu, 20 Nov 2025 12:14:41 +0100 Subject: [PATCH] implement role support for angular authguard --- admin/package-lock.json | 10 ++ admin/package.json | 3 +- admin/src/app/app.routes.ts | 99 ++++++++++++++++--- admin/src/app/auth/auth.guard.ts | 27 ++++- admin/src/app/auth/auth.service.ts | 67 ++++++++++++- admin/src/app/page/welcome/welcome.css | 0 admin/src/app/page/welcome/welcome.html | 1 + admin/src/app/page/welcome/welcome.spec.ts | 23 +++++ admin/src/app/page/welcome/welcome.ts | 11 +++ .../src/event-type/event-type.controller.ts | 5 +- server/src/product/products.controller.ts | 7 +- 11 files changed, 223 insertions(+), 30 deletions(-) create mode 100644 admin/src/app/page/welcome/welcome.css create mode 100644 admin/src/app/page/welcome/welcome.html create mode 100644 admin/src/app/page/welcome/welcome.spec.ts create mode 100644 admin/src/app/page/welcome/welcome.ts diff --git a/admin/package-lock.json b/admin/package-lock.json index 0872ae0..6baecc4 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -16,6 +16,7 @@ "@angular/router": "^20.3.0", "@tailwindcss/postcss": "^4.1.17", "daisyui": "^5.4.5", + "jwt-decode": "^4.0.0", "postcss": "^8.5.6", "rxjs": "~7.8.0", "tailwindcss": "^4.1.17", @@ -6498,6 +6499,15 @@ ], "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", diff --git a/admin/package.json b/admin/package.json index 62b568c..667bc1f 100644 --- a/admin/package.json +++ b/admin/package.json @@ -30,6 +30,7 @@ "@angular/router": "^20.3.0", "@tailwindcss/postcss": "^4.1.17", "daisyui": "^5.4.5", + "jwt-decode": "^4.0.0", "postcss": "^8.5.6", "rxjs": "~7.8.0", "tailwindcss": "^4.1.17", @@ -49,4 +50,4 @@ "ng-packagr": "^20.3.0", "typescript": "~5.9.2" } -} \ No newline at end of file +} diff --git a/admin/src/app/app.routes.ts b/admin/src/app/app.routes.ts index 8f93e04..9b6b821 100644 --- a/admin/src/app/app.routes.ts +++ b/admin/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './components/login/login.component'; import { AuthGuard } from './auth/auth.guard'; import { HomeComponent } from './components/home/home.component'; +import { Welcome } from './page/welcome/welcome'; import { EventTypeFormComponent } from "./features/event-type/components/event-type-form/event-type-form.component"; import { EventTypeDetailsComponent } from "./features/event-type/components/event-type-details/event-type-details.component"; import { EventTypeTableComponent } from "./features/event-type/components/event-type-table/event-type-table.component"; @@ -12,17 +13,89 @@ import { ProductTableComponent } from "./features/products/components/product-ta import { ProductListComponent } from "./features/products/components/product-list/product-list.component"; export const routes: Routes = [ - { path: 'products', component: ProductListComponent }, - { path: 'products/table', component: ProductTableComponent }, - { path: 'products/:id', component: ProductDetailsComponent }, - { path: 'products/:id/edit', component: ProductFormComponent }, - { path: 'products/new', component: ProductFormComponent }, - { path: 'event-type', component: EventTypeListComponent }, - { path: 'event-type/table', component: EventTypeTableComponent }, - { path: 'event-type/new', component: EventTypeFormComponent }, - { path: 'event-type/:id', component: EventTypeDetailsComponent }, - { path: 'event-type/:id/edit', component: EventTypeFormComponent }, - { path: 'login', component: LoginComponent }, - { path: '', component: HomeComponent, canActivate: [AuthGuard] }, - { path: '**', redirectTo: '' } // Redirect to home for any other route + { + path: 'products', + component: ProductListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/table', + component: ProductTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/:id', + component: ProductDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/:id/edit', + component: ProductFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'products/new', + component: ProductFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type', + component: EventTypeListComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/table', + component: EventTypeTableComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/:id', + component: EventTypeDetailsComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/:id/edit', + component: EventTypeFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { + path: 'event-type/new', + component: EventTypeFormComponent, + canActivate: [AuthGuard], + data: { + roles: ['admin'], + }, + }, + { path: 'login', component: LoginComponent }, + { path: 'welcome', component: Welcome }, + { path: 'home', component: HomeComponent, canActivate: [AuthGuard] }, + { path: '', component: Welcome }, + { path: '**', redirectTo: '' }, // Redirect to home for any other route ]; diff --git a/admin/src/app/auth/auth.guard.ts b/admin/src/app/auth/auth.guard.ts index 90a754c..b1071e1 100644 --- a/admin/src/app/auth/auth.guard.ts +++ b/admin/src/app/auth/auth.guard.ts @@ -13,11 +13,32 @@ export class AuthGuard implements CanActivate { route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean | UrlTree { - if (this.authService.isLoggedIn()) { + const requiredRoles = route.data['roles'] as string[]; + + console.info("auth guard started", requiredRoles) + + // 1. Check if the route requires any specific roles + if (!requiredRoles || requiredRoles.length === 0) { + // If no roles are required, only check if the user is logged in + return this.authService.isLoggedIn() ? true : this.router.parseUrl('/login'); + } + + + // 2. Check if the user is logged in + if (!this.authService.isLoggedIn()) { + // If not logged in, redirect to the login page + return this.router.parseUrl('/login'); + } + + // 3. Check if the user has the required role + if (this.authService.hasRole(requiredRoles)) { + // If the user has the role, allow access return true; } else { - // Redirect to the login page - return this.router.createUrlTree(['/login']); + // If the user does not have the role, redirect to an unauthorized page + console.warn(`User does not have one of the required roles: ${requiredRoles}`); + return this.router.parseUrl('/welcome'); } + } } diff --git a/admin/src/app/auth/auth.service.ts b/admin/src/app/auth/auth.service.ts index 44f2c05..03410b8 100644 --- a/admin/src/app/auth/auth.service.ts +++ b/admin/src/app/auth/auth.service.ts @@ -1,8 +1,23 @@ -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Router } from '@angular/router'; +import { jwtDecode } from 'jwt-decode'; + + +interface User { + name: string; + roles: string[]; +} + +export interface DecodedToken { + name: string; // Or 'username', 'given_name', etc. + roles: string[]; // The claim can be a single string or an array + exp: number; // Expiration time (Unix timestamp) + // Add other claims you need, like 'sub' for user ID + sub: string; +} @Injectable({ providedIn: 'root', @@ -12,11 +27,21 @@ export class AuthService { private readonly REFRESH_TOKEN_KEY = 'refreshToken'; private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different - constructor(private http: HttpClient, private router: Router) {} + currentUser = signal(null); + + constructor(private http: HttpClient, private router: Router) { + const accessToken = this.getAccessToken(); + if (accessToken) { + this.decodeAndSetUser(accessToken); + } + } login(credentials: { username: string; password: string }): Observable { return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe( - tap((response) => this.setTokens(response.accessToken, response.refreshToken)) + tap((response) => { + this.setTokens(response.accessToken, response.refreshToken); + this.decodeAndSetUser(response.accessToken); + }) ); } @@ -32,8 +57,9 @@ export class AuthService { * This is the definitive logout action from the user's perspective. */ clientSideLogout(): void { - console.info("clientSideLogout") + console.info("clientSideLogout"); this.removeTokens(); + this.currentUser.set(null); this.router.navigate(['/login']); } @@ -64,6 +90,15 @@ export class AuthService { return this.getAccessToken() !== null; } + hasRole(requiredRoles: string[]): boolean { + const user = this.currentUser(); + if (!user) { + return false; // Not logged in, so no roles + } + // Check if any of the user's roles match any of the required roles + return requiredRoles.some(requiredRole => user.roles.includes(requiredRole)); + } + private setTokens(accessToken: string, refreshToken: string): void { localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken); @@ -73,4 +108,28 @@ export class AuthService { localStorage.removeItem(this.ACCESS_TOKEN_KEY); localStorage.removeItem(this.REFRESH_TOKEN_KEY); } + + private decodeAndSetUser(token: string): void { + try { + const decodedToken: DecodedToken = jwtDecode(token); + + // Check if the token is expired. exp is in seconds, Date.now() is in ms. + // if (decodedToken.exp * 1000 < Date.now()) { + // console.warn('Attempted to use an expired token.'); + // this.logout(); // The token is expired, so log the user out + // return; + // } + + this.currentUser.set({ + name: decodedToken.name, + roles: decodedToken.roles + }); + } catch (error) { + console.error('Failed to decode JWT:', error); + this.currentUser.set(null); + } + + + } + } diff --git a/admin/src/app/page/welcome/welcome.css b/admin/src/app/page/welcome/welcome.css new file mode 100644 index 0000000..e69de29 diff --git a/admin/src/app/page/welcome/welcome.html b/admin/src/app/page/welcome/welcome.html new file mode 100644 index 0000000..921cd7b --- /dev/null +++ b/admin/src/app/page/welcome/welcome.html @@ -0,0 +1 @@ +

Welcome on the DV Booking

diff --git a/admin/src/app/page/welcome/welcome.spec.ts b/admin/src/app/page/welcome/welcome.spec.ts new file mode 100644 index 0000000..2d3fa77 --- /dev/null +++ b/admin/src/app/page/welcome/welcome.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Welcome } from './welcome'; + +describe('Welcome', () => { + let component: Welcome; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Welcome] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Welcome); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/admin/src/app/page/welcome/welcome.ts b/admin/src/app/page/welcome/welcome.ts new file mode 100644 index 0000000..6b795aa --- /dev/null +++ b/admin/src/app/page/welcome/welcome.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-welcome', + imports: [], + templateUrl: './welcome.html', + styleUrl: './welcome.css', +}) +export class Welcome { + +} diff --git a/server/src/event-type/event-type.controller.ts b/server/src/event-type/event-type.controller.ts index 4e9c80a..9a7db5d 100644 --- a/server/src/event-type/event-type.controller.ts +++ b/server/src/event-type/event-type.controller.ts @@ -52,10 +52,7 @@ export class EventTypesController { } @Patch(':id') - update( - @Param('id', ParseIntPipe) id: number, - @Body() updateEventTypeDto: UpdateEventTypeDto, - ) { + update(@Param('id', ParseIntPipe) id: number, @Body() updateEventTypeDto: UpdateEventTypeDto) { return this.eventTypesService.update(id, updateEventTypeDto); } diff --git a/server/src/product/products.controller.ts b/server/src/product/products.controller.ts index d328afb..44a5b69 100644 --- a/server/src/product/products.controller.ts +++ b/server/src/product/products.controller.ts @@ -52,10 +52,7 @@ export class ProductsController { } @Patch(':id') - update( - @Param('id', ParseIntPipe) id: number, - @Body() updateProductDto: UpdateProductDto, - ) { + update(@Param('id', ParseIntPipe) id: number, @Body() updateProductDto: UpdateProductDto) { return this.productsService.update(id, updateProductDto); } @@ -63,4 +60,4 @@ export class ProductsController { remove(@Param('id', ParseIntPipe) id: number) { return this.productsService.remove(id); } -} +} \ No newline at end of file