diff --git a/.gitignore b/.gitignore index 7bb71a0..e1190fa 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ logs/ *.tmp *.temp /.claude +/API/ROLAC.API/bin-verify diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index ba966ba..6651f8f 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -21,10 +21,10 @@ builder.Services.AddDbContext((sp, opt) => .AddInterceptors(sp.GetRequiredService())); // --------------------------------------------------------------------------- -// Identity +// Identity (API-only — no cookie auth; JWT is the default scheme) // --------------------------------------------------------------------------- builder.Services - .AddIdentity(opt => + .AddIdentityCore(opt => { opt.Password.RequiredLength = 8; opt.Password.RequireDigit = true; @@ -32,8 +32,8 @@ builder.Services opt.Password.RequireLowercase = true; opt.Password.RequireNonAlphanumeric = true; opt.User.RequireUniqueEmail = true; - opt.SignIn.RequireConfirmedAccount = false; }) + .AddRoles() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); @@ -44,11 +44,7 @@ var jwtKey = config["Jwt:SecretKey"] ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); builder.Services - .AddAuthentication(opt => - { - opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => { opt.TokenValidationParameters = new TokenValidationParameters diff --git a/APP/src/app/app.config.ts b/APP/src/app/app.config.ts index 8ecee26..428bd7c 100644 --- a/APP/src/app/app.config.ts +++ b/APP/src/app/app.config.ts @@ -1,16 +1,22 @@ -import { ApplicationConfig } from '@angular/core'; +import { ApplicationConfig, APP_INITIALIZER } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; +import { AuthService } from './shared/services/auth.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAnimations(), - provideHttpClient(withInterceptors([authInterceptor])) + provideHttpClient(withInterceptors([authInterceptor])), + { + provide: APP_INITIALIZER, + useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(), + deps: [AuthService], + multi: true, + }, ] }; - diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 5e21e57..2b02596 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo import { LoginPage } from './features/login-page/login-page'; import { UserPortalComponent } from './portals/user-portal/user-portal.component'; import { AuthGuard } from './core/guards/auth.guard'; +import { RoleGuard } from './core/guards/role.guard'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; @@ -19,7 +20,12 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'dashboard', component: DashboardComponent }, { path: 'admin/members', component: MembersPageComponent }, - { path: 'admin/users', component: UsersPageComponent }, + { + path: 'admin/users', + component: UsersPageComponent, + canActivate: [RoleGuard], + data: { roles: ['super_admin'] }, + }, ] }, diff --git a/APP/src/app/core/guards/auth.guard.ts b/APP/src/app/core/guards/auth.guard.ts index dd00e22..c4116c4 100644 --- a/APP/src/app/core/guards/auth.guard.ts +++ b/APP/src/app/core/guards/auth.guard.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { map, catchError } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { AuthService } from '../../shared/services/auth.service'; @Injectable({ @@ -14,19 +14,19 @@ export class AuthGuard implements CanActivate { ) { } canActivate( - route: ActivatedRouteSnapshot, + _route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable | Promise | boolean { - // Check if user is authenticated - if (this.authService.isAuthenticated()) { - return true; - } + return this.authService.whenSessionReady().pipe( + map(() => { + if (this.authService.isAuthenticated()) { + return true; + } - // Store the attempted URL for redirecting after login - this.authService.setRedirectUrl(state.url); - - // Redirect to login page - this.router.navigate(['/login']); - return false; + this.authService.setRedirectUrl(state.url); + this.router.navigate(['/login']); + return false; + }) + ); } } diff --git a/APP/src/app/core/guards/role.guard.ts b/APP/src/app/core/guards/role.guard.ts new file mode 100644 index 0000000..e288e92 --- /dev/null +++ b/APP/src/app/core/guards/role.guard.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot, Router } from '@angular/router'; +import { AuthService } from '../../shared/services/auth.service'; + +@Injectable({ providedIn: 'root' }) +export class RoleGuard implements CanActivate { + constructor( + private authService: AuthService, + private router: Router + ) { } + + canActivate(route: ActivatedRouteSnapshot): boolean { + const requiredRoles = route.data['roles'] as string[] | undefined; + if (!requiredRoles?.length) { + return true; + } + + const user = this.authService.getCurrentUser(); + const allowed = user?.roles?.some(r => requiredRoles.includes(r)) ?? false; + + if (!allowed) { + this.router.navigate(['/user-portal/dashboard']); + } + + return allowed; + } +} diff --git a/APP/src/app/core/interceptors/auth.interceptor.ts b/APP/src/app/core/interceptors/auth.interceptor.ts index 9fe78cd..5f1ee3a 100644 --- a/APP/src/app/core/interceptors/auth.interceptor.ts +++ b/APP/src/app/core/interceptors/auth.interceptor.ts @@ -1,60 +1,75 @@ -import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; -import { catchError, throwError } from 'rxjs'; +import { catchError, switchMap, throwError } from 'rxjs'; import { AuthService } from '../../shared/services/auth.service'; import { Router } from '@angular/router'; import { ApiConfigService } from '../services/api-config.service'; -export const authInterceptor: HttpInterceptorFn = (request, next) => { +/** + * Attaches Bearer tokens to ROLAC API calls and silently refreshes on 401. + */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); - const apiConfigService = inject(ApiConfigService); - const router = inject(Router); + const apiConfig = inject(ApiConfigService); + const router = inject(Router); - // Get the current user and token - const token = authService.getToken(); + const request = attachToken(req, authService, apiConfig); - // Clone the request and add the Authorization header if token exists - if (token && shouldAddToken(apiConfigService, request)) { - request = request.clone({ - setHeaders: { - Authorization: `Bearer ${token}` - } - }); - } - - // Handle the request and catch 401 errors return next(request).pipe( catchError((error: HttpErrorResponse) => { + if ( + error.status === 401 && + !req.headers.has('X-Retry') && + !isPublicAuthRequest(req, apiConfig) + ) { + return authService.refresh().pipe( + switchMap(success => { + if (success) { + const retryReq = attachToken( + req.clone({ setHeaders: { 'X-Retry': 'true' } }), + authService, + apiConfig + ); + return next(retryReq); + } + authService.logout(); + router.navigate(['/login']); + return throwError(() => error); + }) + ); + } + if (error.status === 401) { - // Token is invalid or expired, logout user authService.logout(); router.navigate(['/login']); } + return throwError(() => error); }) ); }; -/** - * Determine if the token should be added to this request - * Skip adding token to login requests and other public endpoints - */ -function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean { - // Don't add token to outbound requests to other domains - if (!request.url.startsWith(apiConfigService.getBaseUrl())) { - return false; +function attachToken( + req: HttpRequest, + authService: AuthService, + apiConfig: ApiConfigService +): HttpRequest { + const token = authService.getToken(); + if (!token || !shouldAddToken(apiConfig, req)) { + return req; } - - // Don't add token to login requests - if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) { - return false; - } - // Don't add token to public endpoints (you can customize this list) - const publicEndpoints = [ - '/Auth/register', - '/Auth/forgot-password', - '/Auth/reset-password' - ]; - - return !publicEndpoints.some(endpoint => request.url.includes(endpoint)); + return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); +} + +const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout']; + +function isPublicAuthRequest(req: HttpRequest, apiConfig: ApiConfigService): boolean { + if (!req.url.startsWith(apiConfig.getBaseUrl())) { + return false; + } + return PUBLIC_AUTH_PATHS.some(path => req.url.toLowerCase().includes(path)); +} + +function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest): boolean { + return !isPublicAuthRequest(req, apiConfig); } diff --git a/APP/src/app/core/services/api-config.service.ts b/APP/src/app/core/services/api-config.service.ts index f2cd637..bda64d4 100644 --- a/APP/src/app/core/services/api-config.service.ts +++ b/APP/src/app/core/services/api-config.service.ts @@ -30,7 +30,7 @@ export class ApiConfigService { * Get specific API endpoints */ get authUrl(): string { - return this.getApiUrl('Auth'); + return this.getApiUrl('auth'); } get tokenUrl(): string { diff --git a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html index 6e9173e..01f88c7 100644 --- a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html +++ b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html @@ -36,14 +36,20 @@ - - diff --git a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts index 8bcce27..33f484a 100644 --- a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts +++ b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts @@ -60,12 +60,16 @@ export class UserNavbarComponent implements OnInit, OnDestroy { { text: 'Support', icon: this.supportIcon, path: '/user-portal/support' } ]; - public adminNavItems: NavItem[] = [ + public memberAdminNavItems: NavItem[] = [ { text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' }, - { text: 'Users', icon: userIcon, path: '/user-portal/admin/users' }, ]; - public showAdminSection = false; + public userAdminNavItems: NavItem[] = [ + { text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' }, + ]; + + public showMemberAdminSection = false; + public showUserAdminSection = false; private destroy$ = new Subject(); @@ -89,9 +93,10 @@ export class UserNavbarComponent implements OnInit, OnDestroy { // Set initial active state this.updateActiveStates(this.router.url); - // Show Administration section for super_admin or secretary this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => { - this.showAdminSection = !!user?.roles?.some(r => r === 'super_admin' || r === 'secretary'); + const roles = user?.roles ?? []; + this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary'); + this.showUserAdminSection = roles.includes('super_admin'); }); } @@ -107,11 +112,13 @@ export class UserNavbarComponent implements OnInit, OnDestroy { private updateActiveStates(currentUrl: string): void { // Reset all active states - [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, ...this.adminNavItems] + [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, + ...this.memberAdminNavItems, ...this.userAdminNavItems] .forEach(item => item.active = false); // Set active state for current route - const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, ...this.adminNavItems] + const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, + ...this.memberAdminNavItems, ...this.userAdminNavItems] .find(item => currentUrl.startsWith(item.path)); if (activeItem) { diff --git a/APP/src/app/portals/user-portal/user-portal.component.html b/APP/src/app/portals/user-portal/user-portal.component.html index 93f7db9..a521562 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.html +++ b/APP/src/app/portals/user-portal/user-portal.component.html @@ -47,6 +47,22 @@ Dashboard + +