fix 401 loop hell
This commit is contained in:
@@ -92,3 +92,4 @@ logs/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
/.claude
|
/.claude
|
||||||
|
/API/ROLAC.API/bin-verify
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
|||||||
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
|
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity
|
// Identity (API-only — no cookie auth; JWT is the default scheme)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddIdentity<AppUser, AppRole>(opt =>
|
.AddIdentityCore<AppUser>(opt =>
|
||||||
{
|
{
|
||||||
opt.Password.RequiredLength = 8;
|
opt.Password.RequiredLength = 8;
|
||||||
opt.Password.RequireDigit = true;
|
opt.Password.RequireDigit = true;
|
||||||
@@ -32,8 +32,8 @@ builder.Services
|
|||||||
opt.Password.RequireLowercase = true;
|
opt.Password.RequireLowercase = true;
|
||||||
opt.Password.RequireNonAlphanumeric = true;
|
opt.Password.RequireNonAlphanumeric = true;
|
||||||
opt.User.RequireUniqueEmail = true;
|
opt.User.RequireUniqueEmail = true;
|
||||||
opt.SignIn.RequireConfirmedAccount = false;
|
|
||||||
})
|
})
|
||||||
|
.AddRoles<AppRole>()
|
||||||
.AddEntityFrameworkStores<AppDbContext>()
|
.AddEntityFrameworkStores<AppDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
@@ -44,11 +44,7 @@ var jwtKey = config["Jwt:SecretKey"]
|
|||||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(opt =>
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
{
|
|
||||||
opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
})
|
|
||||||
.AddJwtBearer(opt =>
|
.AddJwtBearer(opt =>
|
||||||
{
|
{
|
||||||
opt.TokenValidationParameters = new TokenValidationParameters
|
opt.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { ApplicationConfig } from '@angular/core';
|
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
import { AuthService } from './shared/services/auth.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideAnimations(),
|
provideAnimations(),
|
||||||
provideHttpClient(withInterceptors([authInterceptor]))
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
|
||||||
|
deps: [AuthService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo
|
|||||||
import { LoginPage } from './features/login-page/login-page';
|
import { LoginPage } from './features/login-page/login-page';
|
||||||
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
||||||
import { AuthGuard } from './core/guards/auth.guard';
|
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 { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||||||
import { UsersPageComponent } from './features/users/pages/users-page/users-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: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
{ path: 'dashboard', component: DashboardComponent },
|
{ path: 'dashboard', component: DashboardComponent },
|
||||||
{ path: 'admin/members', component: MembersPageComponent },
|
{ path: 'admin/members', component: MembersPageComponent },
|
||||||
{ path: 'admin/users', component: UsersPageComponent },
|
{
|
||||||
|
path: 'admin/users',
|
||||||
|
component: UsersPageComponent,
|
||||||
|
canActivate: [RoleGuard],
|
||||||
|
data: { roles: ['super_admin'] },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map, catchError } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { AuthService } from '../../shared/services/auth.service';
|
import { AuthService } from '../../shared/services/auth.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -14,19 +14,19 @@ export class AuthGuard implements CanActivate {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
canActivate(
|
canActivate(
|
||||||
route: ActivatedRouteSnapshot,
|
_route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean> | Promise<boolean> | boolean {
|
): Observable<boolean> | Promise<boolean> | boolean {
|
||||||
// Check if user is authenticated
|
return this.authService.whenSessionReady().pipe(
|
||||||
if (this.authService.isAuthenticated()) {
|
map(() => {
|
||||||
return true;
|
if (this.authService.isAuthenticated()) {
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Store the attempted URL for redirecting after login
|
this.authService.setRedirectUrl(state.url);
|
||||||
this.authService.setRedirectUrl(state.url);
|
this.router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
// Redirect to login page
|
})
|
||||||
this.router.navigate(['/login']);
|
);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { inject } from '@angular/core';
|
||||||
import { catchError, throwError } from 'rxjs';
|
import { catchError, switchMap, throwError } from 'rxjs';
|
||||||
import { AuthService } from '../../shared/services/auth.service';
|
import { AuthService } from '../../shared/services/auth.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ApiConfigService } from '../services/api-config.service';
|
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 authService = inject(AuthService);
|
||||||
const apiConfigService = inject(ApiConfigService);
|
const apiConfig = inject(ApiConfigService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
// Get the current user and token
|
const request = attachToken(req, authService, apiConfig);
|
||||||
const token = authService.getToken();
|
|
||||||
|
|
||||||
// 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(
|
return next(request).pipe(
|
||||||
catchError((error: HttpErrorResponse) => {
|
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) {
|
if (error.status === 401) {
|
||||||
// Token is invalid or expired, logout user
|
|
||||||
authService.logout();
|
authService.logout();
|
||||||
router.navigate(['/login']);
|
router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
function attachToken(
|
||||||
* Determine if the token should be added to this request
|
req: HttpRequest<unknown>,
|
||||||
* Skip adding token to login requests and other public endpoints
|
authService: AuthService,
|
||||||
*/
|
apiConfig: ApiConfigService
|
||||||
function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean {
|
): HttpRequest<unknown> {
|
||||||
// Don't add token to outbound requests to other domains
|
const token = authService.getToken();
|
||||||
if (!request.url.startsWith(apiConfigService.getBaseUrl())) {
|
if (!token || !shouldAddToken(apiConfig, req)) {
|
||||||
return false;
|
return req;
|
||||||
}
|
}
|
||||||
|
return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
|
||||||
// Don't add token to login requests
|
}
|
||||||
if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) {
|
|
||||||
return false;
|
const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout'];
|
||||||
}
|
|
||||||
// Don't add token to public endpoints (you can customize this list)
|
function isPublicAuthRequest(req: HttpRequest<unknown>, apiConfig: ApiConfigService): boolean {
|
||||||
const publicEndpoints = [
|
if (!req.url.startsWith(apiConfig.getBaseUrl())) {
|
||||||
'/Auth/register',
|
return false;
|
||||||
'/Auth/forgot-password',
|
}
|
||||||
'/Auth/reset-password'
|
return PUBLIC_AUTH_PATHS.some(path => req.url.toLowerCase().includes(path));
|
||||||
];
|
}
|
||||||
|
|
||||||
return !publicEndpoints.some(endpoint => request.url.includes(endpoint));
|
function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest<unknown>): boolean {
|
||||||
|
return !isPublicAuthRequest(req, apiConfig);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class ApiConfigService {
|
|||||||
* Get specific API endpoints
|
* Get specific API endpoints
|
||||||
*/
|
*/
|
||||||
get authUrl(): string {
|
get authUrl(): string {
|
||||||
return this.getApiUrl('Auth');
|
return this.getApiUrl('auth');
|
||||||
}
|
}
|
||||||
|
|
||||||
get tokenUrl(): string {
|
get tokenUrl(): string {
|
||||||
|
|||||||
@@ -36,14 +36,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Administration (role-guarded) -->
|
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||||
<div class="nav-section" *ngIf="showAdminSection">
|
|
||||||
<h4>Administration</h4>
|
<h4>Administration</h4>
|
||||||
<button *ngFor="let item of adminNavItems" kendoButton
|
<button *ngFor="let item of memberAdminNavItems" kendoButton
|
||||||
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
|
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
|
||||||
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
|
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
</button>
|
</button>
|
||||||
|
<ng-container *ngIf="showUserAdminSection">
|
||||||
|
<button *ngFor="let item of userAdminNavItems" kendoButton
|
||||||
|
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
|
||||||
|
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
|
||||||
|
{{ item.text }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
|
|||||||
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' }
|
{ 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: '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<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@@ -89,9 +93,10 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
|
|||||||
// Set initial active state
|
// Set initial active state
|
||||||
this.updateActiveStates(this.router.url);
|
this.updateActiveStates(this.router.url);
|
||||||
|
|
||||||
// Show Administration section for super_admin or secretary
|
|
||||||
this.authService.currentUser$.pipe(takeUntil(this.destroy$)).subscribe(user => {
|
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 {
|
private updateActiveStates(currentUrl: string): void {
|
||||||
// Reset all active states
|
// 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);
|
.forEach(item => item.active = false);
|
||||||
|
|
||||||
// Set active state for current route
|
// 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));
|
.find(item => currentUrl.startsWith(item.path));
|
||||||
|
|
||||||
if (activeItem) {
|
if (activeItem) {
|
||||||
|
|||||||
@@ -47,6 +47,22 @@
|
|||||||
<span *ngIf="!sidebarCollapsed">Dashboard</span>
|
<span *ngIf="!sidebarCollapsed">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section" *ngIf="showAdminSection">
|
||||||
|
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||||
|
<a routerLink="/user-portal/admin/users" routerLinkActive="active" class="nav-item"
|
||||||
|
(click)="onNavigationClick()">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span *ngIf="!sidebarCollapsed">User Management</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
|
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
isMobile = false;
|
isMobile = false;
|
||||||
currentUser: UserInfo | null = null;
|
currentUser: UserInfo | null = null;
|
||||||
currentPageTitle = 'Dashboard';
|
currentPageTitle = 'Dashboard';
|
||||||
|
showAdminSection = false;
|
||||||
unreadMessages = 3;
|
unreadMessages = 3;
|
||||||
unreadNotifications = 2;
|
unreadNotifications = 2;
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(user => {
|
.subscribe(user => {
|
||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
|
this.showAdminSection = !!user?.roles?.includes('super_admin');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.updatePageTitle();
|
this.updatePageTitle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.updatePageTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePageTitle(): void {
|
private updatePageTitle(): void {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
import { catchError, map, tap } from 'rxjs/operators';
|
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
||||||
import { ApiConfigService } from '../../core/services/api-config.service';
|
import { ApiConfigService } from '../../core/services/api-config.service';
|
||||||
|
|
||||||
// ── Public interfaces ─────────────────────────────────────────────────────────
|
// ── Public interfaces ─────────────────────────────────────────────────────────
|
||||||
@@ -69,6 +69,8 @@ export class AuthService {
|
|||||||
/** Observable stream of the current user (null = not authenticated). */
|
/** Observable stream of the current user (null = not authenticated). */
|
||||||
public currentUser = this.currentUser$.asObservable();
|
public currentUser = this.currentUser$.asObservable();
|
||||||
|
|
||||||
|
private readonly sessionReady$ = new BehaviorSubject(false);
|
||||||
|
private refreshInFlight$: Observable<boolean> | null = null;
|
||||||
private redirectUrl = '/dashboard';
|
private redirectUrl = '/dashboard';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -118,18 +120,25 @@ export class AuthService {
|
|||||||
* Never throws.
|
* Never throws.
|
||||||
*/
|
*/
|
||||||
refresh(): Observable<boolean> {
|
refresh(): Observable<boolean> {
|
||||||
return this.http.post<ApiLoginResponse>(
|
if (!this.refreshInFlight$) {
|
||||||
`${this.apiConfig.authUrl}/refresh`,
|
this.refreshInFlight$ = this.http.post<ApiLoginResponse>(
|
||||||
{},
|
`${this.apiConfig.authUrl}/refresh`,
|
||||||
{ withCredentials: true }
|
{},
|
||||||
).pipe(
|
{ withCredentials: true }
|
||||||
tap(response => {
|
).pipe(
|
||||||
this.accessToken$.next(response.accessToken);
|
tap(response => {
|
||||||
this.currentUser$.next(response.user);
|
this.accessToken$.next(response.accessToken);
|
||||||
}),
|
this.currentUser$.next(response.user);
|
||||||
map(() => true),
|
}),
|
||||||
catchError(() => of(false))
|
map(() => true),
|
||||||
);
|
catchError(() => of(false)),
|
||||||
|
finalize(() => {
|
||||||
|
this.refreshInFlight$ = null;
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.refreshInFlight$;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,10 +164,23 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
initializeFromRefreshToken(): Promise<void> {
|
initializeFromRefreshToken(): Promise<void> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
this.refresh().subscribe(() => resolve());
|
this.refresh().pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.sessionReady$.next(true);
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves once startup session restore has finished (success or failure). */
|
||||||
|
whenSessionReady(): Observable<boolean> {
|
||||||
|
if (this.sessionReady$.value) {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
return this.sessionReady$.pipe(filter(Boolean), take(1));
|
||||||
|
}
|
||||||
|
|
||||||
// ── State accessors ─────────────────────────────────────────────────────
|
// ── State accessors ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
@@ -166,7 +188,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return this.currentUser$.value !== null;
|
return this.currentUser$.value !== null && this.getToken() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentUser(): UserInfo | null {
|
getCurrentUser(): UserInfo | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user