From 99e4f14d8b7d7c8642dd90d68361ba29e460cf38 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 28 May 2026 12:56:15 +0300 Subject: [PATCH 1/2] permissions: skip /permissions/available fetch on unauthenticated pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UsersService is provided in root and eagerly creates an httpResource for /permissions/available, which auto-fires on app bootstrap before any auth check, leaking a 401 request from /login and other unauthenticated pages. Gate the request function on a new isAuthenticated signal in AuthService — seeded from the existing localStorage 'token_expiration' and flipped via setAuthenticated() at the login, session-restoration, and logout transitions already managed by AppComponent. Mirrors the connection-id gating used by the other httpResource-backed services. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/app/app.component.ts | 4 ++++ frontend/src/app/services/auth.service.ts | 16 +++++++++++++++- frontend/src/app/services/users.service.ts | 4 +++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 8d08e92af..1d3e47ed5 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -215,6 +215,7 @@ export class AppComponent { localStorage.setItem('token_expiration', expirationTime.toISOString()); expirationToken = expirationTime.toISOString(); } + this._auth.setAuthenticated(true); this.router.navigate(['/connections-list']); @@ -236,6 +237,7 @@ export class AppComponent { const expirationInterval = differenceInMilliseconds(expirationTime, currantTime); console.log('expirationInterval', expirationInterval); if (expirationInterval > 0) { + this._auth.setAuthenticated(true); console.log('App component, session restoration'); this.initializeUserSession(); @@ -355,6 +357,7 @@ export class AppComponent { this._user.setIsDemo(false); this.currentUser = null; localStorage.removeItem('token_expiration'); + this._auth.setAuthenticated(false); this.router.navigate(['/registration']); }); } @@ -375,6 +378,7 @@ export class AppComponent { this._auth.logOutUser().subscribe(() => { this.setUserLoggedIn(null); localStorage.removeItem('token_expiration'); + this._auth.setAuthenticated(false); if (this.isSaas) { if (!isTokenExpired) window.location.href = 'https://rocketadmin.com/'; diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index d0a3ae783..c5bd37c71 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import * as Sentry from '@sentry/angular'; import { BehaviorSubject, EMPTY } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -16,12 +16,19 @@ export class AuthService { private auth = new BehaviorSubject(''); public cast = this.auth.asObservable(); + private _isAuthenticated = signal(AuthService._hasValidSessionToken()); + public readonly isAuthenticated = this._isAuthenticated.asReadonly(); + constructor( private _http: HttpClient, private _notifications: NotificationsService, private _configuration: ConfigurationService, ) {} + setAuthenticated(value: boolean): void { + this._isAuthenticated.set(value); + } + signUpUser(userData: NewAuthUser) { const config = this._configuration.getConfig(); return this._http.post(config.saasURL + '/saas/user/register', userData).pipe( @@ -328,4 +335,11 @@ export class AuthService { }), ); } + + private static _hasValidSessionToken(): boolean { + const exp = localStorage.getItem('token_expiration'); + if (!exp) return false; + const expiration = new Date(exp); + return !isNaN(expiration.getTime()) && expiration.getTime() > Date.now(); + } } diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 017cdadd1..095e9be09 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -5,6 +5,7 @@ import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items' import { groupNameForAction, PERMISSION_GROUP_ORDER } from 'src/app/lib/permission-display'; import { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user'; import { ApiService } from './api.service'; +import { AuthService } from './auth.service'; import { NotificationsService } from './notifications.service'; export type GroupUpdateEvent = @@ -21,6 +22,7 @@ export type GroupUpdateEvent = }) export class UsersService { private _api = inject(ApiService); + private _auth = inject(AuthService); private _http = inject(HttpClient); private _notifications = inject(NotificationsService); @@ -48,7 +50,7 @@ export class UsersService { private _availablePermissionsResource: HttpResourceRef<{ actions: PolicyAction[] } | undefined> = this._api.resource<{ actions: PolicyAction[]; - }>(() => '/permissions/available'); + }>(() => (this._auth.isAuthenticated() ? '/permissions/available' : undefined)); public readonly availablePermissions = computed( () => this._availablePermissionsResource.value()?.actions ?? [], From e4c8cc3293a623ab544d67755e30135c6f4d76e4 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 28 May 2026 13:10:13 +0300 Subject: [PATCH 2/2] test: add isAuthenticated signal to AuthService mock in app.component.spec Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/app/app.component.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index 94f3ae11e..71b56ab5e 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -1,5 +1,5 @@ import { provideHttpClient } from '@angular/common/http'; -import { ChangeDetectorRef } from '@angular/core'; +import { ChangeDetectorRef, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialogModule } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; @@ -44,9 +44,12 @@ describe('AppComponent', () => { const authCast = new Subject(); const userCast = new Subject(); + const isAuthenticatedSignal = signal(false); const mockAuthService = { cast: authCast, logOutUser: vi.fn().mockReturnValue(of(true)), + isAuthenticated: isAuthenticatedSignal.asReadonly(), + setAuthenticated: (value: boolean) => isAuthenticatedSignal.set(value), }; const mockUserService = {