From f47634fec43c737141536fe997b391878d74751d Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Tue, 17 Sep 2024 13:52:49 +0200 Subject: [PATCH 01/48] Pfda session extension --- .../session-expiration-dialog.component.ts | 12 +- .../session-expiration.component.ts | 177 +++++++++++------- src/app/core/config/config.model.ts | 3 +- src/app/fda/fda.module.ts | 16 -- .../fda/service/sso-refresh.service.spec.ts | 12 -- src/app/fda/service/sso-refresh.service.ts | 87 --------- 6 files changed, 116 insertions(+), 191 deletions(-) delete mode 100644 src/app/fda/service/sso-refresh.service.spec.ts delete mode 100644 src/app/fda/service/sso-refresh.service.ts diff --git a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts index 3dbae841e..da96f8478 100644 --- a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts @@ -1,9 +1,8 @@ import { Component, OnInit, Inject } from '@angular/core'; import { Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; -import { ConfigService, SessionExpirationWarning } from '@gsrs-core/config'; -import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { AnyNsRecord } from 'dns'; +import { SessionExpirationWarning } from '@gsrs-core/config'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @Component({ selector: 'app-session-expiration-dialog', @@ -49,11 +48,10 @@ export class SessionExpirationDialogComponent implements OnInit { if (this.timeRemainingSeconds > 0) { const remainingMinutes = Math.floor(this.timeRemainingSeconds / 60); - const reminaingSeconds = String(this.timeRemainingSeconds % 60).padStart(2, '0'); + const remainingSeconds = String(this.timeRemainingSeconds % 60).padStart(2, '0'); this.dialogTitle = "Session Ending Soon" - this.dialogMessage = `You will be logged out in ${remainingMinutes}:${reminaingSeconds}` - } - else { + this.dialogMessage = `You will be logged out in ${remainingMinutes}:${remainingSeconds}` + } else { this.dialogTitle = "Session Ended" this.dialogMessage = "Your session has expired, please login again." } diff --git a/src/app/core/auth/session-expiration/session-expiration.component.ts b/src/app/core/auth/session-expiration/session-expiration.component.ts index 14fb287a5..bafcaaf28 100644 --- a/src/app/core/auth/session-expiration/session-expiration.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration.component.ts @@ -1,12 +1,12 @@ -import { Router, Event as NavigationEvent, NavigationStart } from '@angular/router'; +import { Router } from '@angular/router'; import { Component, OnInit } from '@angular/core'; import { OverlayContainer } from '@angular/cdk/overlay'; import { HttpClient } from '@angular/common/http'; import { ConfigService, SessionExpirationWarning } from '@gsrs-core/config'; import { AuthService } from '../auth.service'; import { SessionExpirationDialogComponent } from './session-expiration-dialog/session-expiration-dialog.component' -import { Subscription } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; +import { UtilsService } from "@gsrs-core/utils"; @Component({ selector: 'app-session-expiration', @@ -16,8 +16,13 @@ export class SessionExpirationComponent implements OnInit { sessionExpirationWarning: SessionExpirationWarning = null; sessionExpiringAt: number; private overlayContainer: HTMLElement; - private subscriptions: Array = []; - private expirationTimer: any; + private refreshInterval: any; + private activityRefreshInterval: any; + private userActive: boolean = false; + private baseHref: string = '/ginas/app/'; + + private static instance?: SessionExpirationComponent = undefined; + private static sessionExpirationCheckInterval = null; constructor( private router: Router, @@ -25,72 +30,118 @@ export class SessionExpirationComponent implements OnInit { private authService: AuthService, private http: HttpClient, private dialog: MatDialog, - private overlayContainerService: OverlayContainer + private overlayContainerService: OverlayContainer, + private utilsService: UtilsService ) { + if (SessionExpirationComponent.instance !== undefined) { + return SessionExpirationComponent.instance; + } this.sessionExpirationWarning = configService.configData.sessionExpirationWarning; this.overlayContainer = this.overlayContainerService.getContainerElement(); } ngOnInit() { - // If SessionExpirationWarning is not found in configData, the intervals are never set - // and this component is inert - const authSubscription = this.authService.getAuth().subscribe(auth => { - if (this.sessionExpirationWarning) { - if (auth) { - this.resetExpirationTimer(); - } - else { - // User has logged out while timeout is active - this.clearExpirationTimer(); - } + if (SessionExpirationComponent.instance !== undefined) { + return; + } + SessionExpirationComponent.instance = this; + + const homeBaseUrl = this.configService.configData && this.configService.configData.gsrsHomeBaseUrl || null; + if (homeBaseUrl) { + this.baseHref = homeBaseUrl; + } + + this.startSessionTimeoutInterval(); + } + + setup() { + this.configService.afterLoad().then(cd => { + // If enabled in config file, this functionality periodically checks whether there was a user activity (mouse or keyboard) or not + // In case there was some activity, the session is refreshed (otherwise the session is not refreshed and may eventually expire) + if (this.configService.configData.sessionRefreshOnActiveUser) { + const page = document.getElementsByTagName('body')[0]; + page.addEventListener('mousemove', (e) => { + if (e instanceof MouseEvent) { + this.userActive = true; + } + }); + page.addEventListener('keydown', (e) => { + if (e instanceof KeyboardEvent) { + this.userActive = true; + } + }); + clearInterval(this.activityRefreshInterval); + this.activityRefreshInterval = setInterval(() => { + if (this.userActive) { + this.refreshSession(); + this.userActive = false; + } + }, 10000); + } + + if (!this.configService.configData.disableSessionAutoRefresh) { + clearInterval(this.refreshInterval); + this.refreshInterval = setInterval(() => { + this.refreshSession(); + }, 600000); } }); - this.subscriptions.push(authSubscription); - - // This component seems to be destroyed and recreated on route change, so maybe - // the following isn't necessary: - // const routerSubscription = this.router.events.subscribe((event: NavigationEvent) => { - // if (event instanceof NavigationStart && this.expirationTimer) { - // this.extendSession(); - // } - // }); - // this.subscriptions.push(routerSubscription); } - ngOnDestroy() { - this.subscriptions.forEach(subscription => { - subscription.unsubscribe(); - }); - this.clearExpirationTimer(); + refreshSession(): any { + fetch(`${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}`) } - getCurrentTime() { - return Math.floor((new Date()).getTime() / 1000); + startSessionTimeoutInterval() { + this.authService.getAuth().subscribe(auth => { + if (auth != null && this.refreshInterval == null) { + this.setup(); + } else if (auth === null) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + }); + + clearInterval(SessionExpirationComponent.sessionExpirationCheckInterval); + SessionExpirationComponent.sessionExpirationCheckInterval = setInterval(() => { + this.sessionExpiringAt = this.getSessionExpiredAt(); + const currentTime = this.getCurrentTime(); + const sessionTtl = this.sessionExpiringAt - currentTime; + // If session is about to expire in less than 60 seconds, show dialog window + if (sessionTtl > 0 && sessionTtl < 60) { + if (!this.isDialogOpened()) { + this.openDialog(); + } + // Do not automatically (mouse/keyboard event) extend session when the dialog is opened + clearInterval(this.activityRefreshInterval); + } else if (this.sessionExpiringAt !== null && sessionTtl > 0) { + // The session was externally extended (eg. in pfda) -> close the session dialog + if (this.isDialogOpened()) { + this.dialog.closeAll(); + } + } + }, 5000) } - clearExpirationTimer() { - if (this.expirationTimer) { - clearTimeout(this.expirationTimer); - this.expirationTimer = null; + private getCookie(name: string) { + const cookieArr = document.cookie.split(';') + for (let i = 0; i < cookieArr.length; i++) { + const cookiePair = cookieArr[i].split('=') + if (name === cookiePair[0].trim()) { + return decodeURIComponent(cookiePair[1]) + } } + return null } - resetExpirationTimer() { - this.clearExpirationTimer(); - - const currentTime = this.getCurrentTime() - this.sessionExpiringAt = currentTime + this.sessionExpirationWarning.maxSessionDurationMinutes * 60; + private getSessionExpiredAt() { + const cookie = this.getCookie('sessionExpiredAt') + if (!cookie) return null + return parseInt(cookie) + } - const timeRemainingSeconds = this.sessionExpiringAt - currentTime; - const timeBeforeDisplayingDialogMs = (timeRemainingSeconds - 61) * 1000; - if (timeBeforeDisplayingDialogMs > 0) { - this.expirationTimer = setTimeout( () => { - this.openDialog(); - }, timeBeforeDisplayingDialogMs); - } - else { - this.login(); - } + getCurrentTime() { + return Math.floor((new Date()).getTime() / 1000); } openDialog() { @@ -104,27 +155,17 @@ export class SessionExpirationComponent implements OnInit { disableClose: true }); this.overlayContainer.style.zIndex = '1501'; - const dialogSubscription = dialogRef.afterClosed().subscribe(response => { + dialogRef.afterClosed().subscribe(response => { this.overlayContainer.style.zIndex = null; - if (response) { - // Session was extended - this.resetExpirationTimer(); - } + this.startSessionTimeoutInterval(); }); } - extendSession() { - const url = this.sessionExpirationWarning.extendSessionApiUrl; - this.http.get(url).subscribe( - data => { - this.resetExpirationTimer(); - }, - err => { console.log("Error extending session: ", err) }, - () => { } - ); - } - login() { window.location.assign('/login'); } + + isDialogOpened(): boolean { + return this.dialog.openDialogs.length > 0; + } } diff --git a/src/app/core/config/config.model.ts b/src/app/core/config/config.model.ts index b546aca89..6bb4844cc 100644 --- a/src/app/core/config/config.model.ts +++ b/src/app/core/config/config.model.ts @@ -38,7 +38,8 @@ export interface Config { facetDisplay?: Array; relationshipsVisualizationUri?: string; customToolbarComponent?: string; - disableSessionRefresh?: boolean; + sessionRefreshOnActiveUser?: boolean; + disableSessionAutoRefresh?: boolean; sessionExpirationWarning?: SessionExpirationWarning; disableReferenceDocumentUpload?: boolean; externalSiteWarning?: ExternalSiteWarning; diff --git a/src/app/fda/fda.module.ts b/src/app/fda/fda.module.ts index ad4410cd5..7f83a7751 100644 --- a/src/app/fda/fda.module.ts +++ b/src/app/fda/fda.module.ts @@ -27,7 +27,6 @@ import { SubstanceApplicationMatchListComponent} from './substance-browse/substa import { ApplicationsBrowseComponent } from './application/applications-browse/applications-browse.component'; import { ClinicalTrialsBrowseComponent } from './clinical-trials/clinical-trials-browse/clinical-trials-browse.component'; import { fdaSubstanceCardsFilters } from './substance-details/fda-substance-cards-filters.constant'; -import { SsoRefreshService } from './service/sso-refresh.service'; import { ProductService } from './product/service/product.service'; import { GeneralService} from './service/general.service'; import { ShowApplicationToggleComponent } from './substance-browse/show-application-toggle/show-application-toggle.component'; @@ -57,12 +56,6 @@ const fdaRoutes: Routes = [ } ]; -export function init_sso_refresh_service(ssoService: SsoRefreshService) { - return() => { - ssoService.init(); - }; -} - @NgModule({ imports: [ CommonModule, @@ -100,15 +93,6 @@ export function init_sso_refresh_service(ssoService: SsoRefreshService) { SubstanceCountsComponent, ShowApplicationToggleComponent - ], - providers: [ - SsoRefreshService, - { - provide: APP_INITIALIZER, - useFactory: init_sso_refresh_service, - deps: [SsoRefreshService], - multi: true - } ] }) export class FdaModule { diff --git a/src/app/fda/service/sso-refresh.service.spec.ts b/src/app/fda/service/sso-refresh.service.spec.ts deleted file mode 100644 index ca2f068f5..000000000 --- a/src/app/fda/service/sso-refresh.service.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { SsoRefreshService } from './sso-refresh.service'; - -describe('SsoRefreshService', () => { - beforeEach(() => TestBed.configureTestingModule({})); - - it('should be created', () => { - const service: SsoRefreshService = TestBed.get(SsoRefreshService); - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/fda/service/sso-refresh.service.ts b/src/app/fda/service/sso-refresh.service.ts deleted file mode 100644 index aa97fb0a4..000000000 --- a/src/app/fda/service/sso-refresh.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Injectable, Inject, PLATFORM_ID, OnDestroy } from '@angular/core'; -import { Router, NavigationExtras, ActivatedRoute } from '@angular/router'; -import { isPlatformBrowser } from '@angular/common'; -import { take } from 'rxjs/operators'; -import { AuthService } from '@gsrs-core/auth'; -import { UtilsService } from '@gsrs-core/utils'; -import { ConfigService } from '@gsrs-core/config/config.service'; - -@Injectable() -export class SsoRefreshService implements OnDestroy { - private iframe: HTMLIFrameElement; - private refreshInterval: any; - private baseHref: string; - private showHeaderBar = 'true'; - - constructor( - @Inject(PLATFORM_ID) private platformId: Object, - private utilsService: UtilsService, - private configService: ConfigService, - private authService: AuthService, - private activatedRoute: ActivatedRoute - ) { - if (isPlatformBrowser(this.platformId)) { - - if (window.location.pathname.indexOf('/ginas/app/ui/') > -1) { - this.baseHref = '/ginas/app/'; - } - } - } - - updateIframe(): any { - if (!this.iframe) { - this.iframe = document.createElement('IFRAME') as HTMLIFrameElement; - this.iframe.title = 'page refresher'; - this.iframe.name = 'refresher'; - this.iframe.style.height = '0'; - this.iframe.style.opacity = '0'; - this.iframe.src = `${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}&noWarningBox=true`; - document.body.appendChild(this.iframe); - } else { - this.iframe.src = `${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}&noWarningBox=true`; - } - } - - setup() { - this.configService.afterLoad().then(cd => { - // Session auto refresh can be explicitly disabled in config file - if (this.configService.configData.disableSessionRefresh) { - return; - } - const homeBaseUrl = this.configService.configData && this.configService.configData.gsrsHomeBaseUrl || null; - if (homeBaseUrl) { - this.baseHref = homeBaseUrl; - this.updateIframe(); - } - clearInterval(this.refreshInterval); - this.refreshInterval = setInterval(() => { - console.log("REFRESHING iFrame"); - this.updateIframe(); - }, 600000); - }); - } - - init(): any { - if(new URLSearchParams(window.location.search).get("noWarningBox") === 'true'){ - //do not do sso refresher recursively - return; - } - if (new URLSearchParams(window.location.search).get("header") === 'false') { - this.setup(); - } else { - this.authService.getAuth().subscribe(auth => { - if (auth != null && this.refreshInterval == null) { - this.setup(); - } else if (auth === null){ - clearInterval(this.refreshInterval); - this.refreshInterval = null; - } - }); - } //else - } - - ngOnDestroy() { - clearInterval(this.refreshInterval); - this.refreshInterval = null; - } -} From 019dfe29e670d37dd61bb384c9b0d2625745a943 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Wed, 18 Sep 2024 14:29:08 +0200 Subject: [PATCH 02/48] Config sessionRefreshOnActiveUserOnly field --- .../auth/session-expiration/session-expiration.component.ts | 6 ++---- src/app/core/config/config.model.ts | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/app/core/auth/session-expiration/session-expiration.component.ts b/src/app/core/auth/session-expiration/session-expiration.component.ts index bafcaaf28..4f8b1fdbd 100644 --- a/src/app/core/auth/session-expiration/session-expiration.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration.component.ts @@ -58,7 +58,7 @@ export class SessionExpirationComponent implements OnInit { this.configService.afterLoad().then(cd => { // If enabled in config file, this functionality periodically checks whether there was a user activity (mouse or keyboard) or not // In case there was some activity, the session is refreshed (otherwise the session is not refreshed and may eventually expire) - if (this.configService.configData.sessionRefreshOnActiveUser) { + if (this.configService.configData.sessionRefreshOnActiveUserOnly) { const page = document.getElementsByTagName('body')[0]; page.addEventListener('mousemove', (e) => { if (e instanceof MouseEvent) { @@ -77,9 +77,7 @@ export class SessionExpirationComponent implements OnInit { this.userActive = false; } }, 10000); - } - - if (!this.configService.configData.disableSessionAutoRefresh) { + } else { clearInterval(this.refreshInterval); this.refreshInterval = setInterval(() => { this.refreshSession(); diff --git a/src/app/core/config/config.model.ts b/src/app/core/config/config.model.ts index 6bb4844cc..00c149ab2 100644 --- a/src/app/core/config/config.model.ts +++ b/src/app/core/config/config.model.ts @@ -38,8 +38,7 @@ export interface Config { facetDisplay?: Array; relationshipsVisualizationUri?: string; customToolbarComponent?: string; - sessionRefreshOnActiveUser?: boolean; - disableSessionAutoRefresh?: boolean; + sessionRefreshOnActiveUserOnly?: boolean; sessionExpirationWarning?: SessionExpirationWarning; disableReferenceDocumentUpload?: boolean; externalSiteWarning?: ExternalSiteWarning; From 53d71dec258d325dedf82837c27ca97e1d051a53 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Thu, 19 Sep 2024 11:49:06 +0200 Subject: [PATCH 03/48] Session expiration dialog fix --- .../session-expiration.component.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/app/core/auth/session-expiration/session-expiration.component.ts b/src/app/core/auth/session-expiration/session-expiration.component.ts index 4f8b1fdbd..e2a55062a 100644 --- a/src/app/core/auth/session-expiration/session-expiration.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration.component.ts @@ -1,11 +1,9 @@ -import { Router } from '@angular/router'; import { Component, OnInit } from '@angular/core'; import { OverlayContainer } from '@angular/cdk/overlay'; -import { HttpClient } from '@angular/common/http'; import { ConfigService, SessionExpirationWarning } from '@gsrs-core/config'; import { AuthService } from '../auth.service'; import { SessionExpirationDialogComponent } from './session-expiration-dialog/session-expiration-dialog.component' -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef, MatDialogState } from '@angular/material/dialog'; import { UtilsService } from "@gsrs-core/utils"; @Component({ @@ -20,16 +18,15 @@ export class SessionExpirationComponent implements OnInit { private activityRefreshInterval: any; private userActive: boolean = false; private baseHref: string = '/ginas/app/'; + private extendSessionDialog: MatDialogRef; private static instance?: SessionExpirationComponent = undefined; private static sessionExpirationCheckInterval = null; constructor( - private router: Router, private configService: ConfigService, private authService: AuthService, - private http: HttpClient, - private dialog: MatDialog, + private matDialog: MatDialog, private overlayContainerService: OverlayContainer, private utilsService: UtilsService ) { @@ -115,7 +112,7 @@ export class SessionExpirationComponent implements OnInit { } else if (this.sessionExpiringAt !== null && sessionTtl > 0) { // The session was externally extended (eg. in pfda) -> close the session dialog if (this.isDialogOpened()) { - this.dialog.closeAll(); + this.extendSessionDialog.close(); } } }, 5000) @@ -143,7 +140,7 @@ export class SessionExpirationComponent implements OnInit { } openDialog() { - const dialogRef = this.dialog.open(SessionExpirationDialogComponent, { + this.extendSessionDialog = this.matDialog.open(SessionExpirationDialogComponent, { data: { 'sessionExpirationWarning': this.sessionExpirationWarning, 'sessionExpiringAt': this.sessionExpiringAt @@ -153,7 +150,7 @@ export class SessionExpirationComponent implements OnInit { disableClose: true }); this.overlayContainer.style.zIndex = '1501'; - dialogRef.afterClosed().subscribe(response => { + this.extendSessionDialog.afterClosed().subscribe(response => { this.overlayContainer.style.zIndex = null; this.startSessionTimeoutInterval(); }); @@ -164,6 +161,6 @@ export class SessionExpirationComponent implements OnInit { } isDialogOpened(): boolean { - return this.dialog.openDialogs.length > 0; + return this.extendSessionDialog && this.extendSessionDialog.getState() === MatDialogState.OPEN; } } From 82cb43a4c61c0e5e31675b6fd086269293ed85bb Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Thu, 10 Oct 2024 15:08:28 +0200 Subject: [PATCH 04/48] PFDA-5656 Substance registration pages do not require pFDA login to access --- src/app/core/auth/auth.service.ts | 49 +++++++++++++++-- .../session-expiration.component.ts | 6 ++- src/app/core/base/base-http.service.ts | 4 ++ src/app/core/base/base.component.html | 4 +- src/app/core/base/base.component.ts | 4 +- src/app/core/config/config.model.ts | 3 +- src/app/core/config/config.pfda.json | 4 +- src/app/core/config/config.service.ts | 3 ++ .../can-register-substance-form.ts | 51 ++++++++++-------- src/app/core/substance/substance.service.ts | 54 +++++++++++++++++-- src/environments/environment.model.ts | 1 + 11 files changed, 144 insertions(+), 39 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 2d354cdb4..317fb295e 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -303,16 +303,55 @@ export class AuthService { private fetchAuth(): Observable { return new Observable(observer => { this.configService.afterLoad().then(cd => { - const url = `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/'}api/v1/`; + const isPfdaVersion = this.configService.configData.isPfdaVersion === true; + const url = isPfdaVersion ? '/api/user' : + `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/'}api/v1/whoami`; if (this.configService.configData && this.configService.configData.dummyWhoami) { observer.next(this.configService.configData.dummyWhoami); } else { - this.http.get(`${url}whoami`) + this.http.get(url) .subscribe( auth => { - // console.log("Authorized as"); - // console.log(auth); - observer.next(auth); + if (isPfdaVersion) { + // @ts-ignore + const dxuser = auth.user.dxuser; + const pfdaAuth: Auth = { + id: 0, + version: 0, + created: 0, + modified: 0, + deprecated: false, + user: { + id: 0, + version: 0, + created: 0, + modified: 0, + deprecated: false, + username: dxuser, + email: auth.user.email, + admin: auth.user.admin + }, + active: true, + systemAuth: false, + key: 'unused', + identifier: dxuser, + groups: [], + roles: [ + "Query", + "Updater", + "SuperUpdate", + "DataEntry", + "SuperDataEntry" + ], + computedToken: 'unused', + tokenTimeToExpireMS: 9999999999999, + roleQueryOnly: false, + permissions: [] + } + observer.next(pfdaAuth); + } else { + observer.next(auth); + } }, err => { console.log("Authorized error"); diff --git a/src/app/core/auth/session-expiration/session-expiration.component.ts b/src/app/core/auth/session-expiration/session-expiration.component.ts index e2a55062a..1f903f2f1 100644 --- a/src/app/core/auth/session-expiration/session-expiration.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration.component.ts @@ -84,7 +84,11 @@ export class SessionExpirationComponent implements OnInit { } refreshSession(): any { - fetch(`${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}`) + if (this.configService.configData.isPfdaVersion) { + fetch(`${this.configService.configData.pfdaApiBaseUrl}user`) + } else { + fetch(`${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}`) + } } startSessionTimeoutInterval() { diff --git a/src/app/core/base/base-http.service.ts b/src/app/core/base/base-http.service.ts index c8a7db553..fd27e461e 100644 --- a/src/app/core/base/base-http.service.ts +++ b/src/app/core/base/base-http.service.ts @@ -2,12 +2,16 @@ import { ConfigService } from '../config/config.service'; export abstract class BaseHttpService { public apiBaseUrl: string; + public pfdaApiBaseUrl: string = ''; public baseUrl: string; constructor( public configService: ConfigService ) { this.apiBaseUrl = `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/' }api/v1/`; + if (this.configService.configData.isPfdaVersion && this.configService.configData.pfdaApiBaseUrl) { + this.pfdaApiBaseUrl = this.configService.configData.pfdaApiBaseUrl; + } this.baseUrl = (this.configService.configData && this.configService.configData.apiBaseUrl) || '/'; } } diff --git a/src/app/core/base/base.component.html b/src/app/core/base/base.component.html index 4194f0be6..7355090e3 100644 --- a/src/app/core/base/base.component.html +++ b/src/app/core/base/base.component.html @@ -1,4 +1,4 @@ - + -
+
diff --git a/src/app/core/base/base.component.ts b/src/app/core/base/base.component.ts index 1d12fe9bb..186303abb 100644 --- a/src/app/core/base/base.component.ts +++ b/src/app/core/base/base.component.ts @@ -46,7 +46,7 @@ export class BaseComponent implements OnInit, OnDestroy { appId: string; clasicBaseHref: string; navItems: Array; - customToolbarComponent: string = ''; + isPfdaVersion: boolean = false; canRegister = false; registerNav: Array; searchNav: Array; @@ -75,7 +75,7 @@ export class BaseComponent implements OnInit, OnDestroy { private utilsService: UtilsService, private wildCardService: WildcardService ) { - this.customToolbarComponent = this.configService.configData.customToolbarComponent; + this.isPfdaVersion = this.configService.configData.isPfdaVersion === true; this.wildCardService.wildCardObservable.subscribe((data) => { this.wildCardText = data; }); diff --git a/src/app/core/config/config.model.ts b/src/app/core/config/config.model.ts index 00c149ab2..821401668 100644 --- a/src/app/core/config/config.model.ts +++ b/src/app/core/config/config.model.ts @@ -2,6 +2,7 @@ import { Auth } from "@gsrs-core/auth"; export interface Config { apiBaseUrl?: string; + pfdaApiBaseUrl?: string; gsrsHomeBaseUrl?: string; apiSSG4mBaseUrl?: string; apiUrlDomain?: string; @@ -37,7 +38,7 @@ export interface Config { advancedSearchFacetDisplay?: boolean; facetDisplay?: Array; relationshipsVisualizationUri?: string; - customToolbarComponent?: string; + isPfdaVersion?: boolean; sessionRefreshOnActiveUserOnly?: boolean; sessionExpirationWarning?: SessionExpirationWarning; disableReferenceDocumentUpload?: boolean; diff --git a/src/app/core/config/config.pfda.json b/src/app/core/config/config.pfda.json index dc35f4eb0..54955437b 100644 --- a/src/app/core/config/config.pfda.json +++ b/src/app/core/config/config.pfda.json @@ -544,5 +544,5 @@ "dialogMessage" : "You will be making an API call outside of the precisionFDA boundary. Do you want to continue?" }, "googleAnalyticsId": "", - "customToolbarComponent": "precisionFDA" -} \ No newline at end of file + "isPfdaVersion": true +} diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index e2a78a6af..43586ac7b 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -47,6 +47,9 @@ export class ConfigService { if (config.apiBaseUrl == null && environment.apiBaseUrl != null) { config.apiBaseUrl = environment.apiBaseUrl; } + if (config.pfdaApiBaseUrl == null && environment.pfdaApiBaseUrl != null) { + config.pfdaApiBaseUrl = environment.pfdaApiBaseUrl; + } if (config.apiBaseUrl.indexOf('//') > -1) { const parts = config.apiBaseUrl.split('/'); config.apiUrlDomain = `${parts[0]}//${parts[2]}`; diff --git a/src/app/core/substance-form/can-register-substance-form.ts b/src/app/core/substance-form/can-register-substance-form.ts index 8f6ce3ba4..a4cad22a6 100644 --- a/src/app/core/substance-form/can-register-substance-form.ts +++ b/src/app/core/substance-form/can-register-substance-form.ts @@ -3,13 +3,15 @@ import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Navig import { AuthService } from '../auth/auth.service'; import { Observable } from 'rxjs'; import {Role} from '@gsrs-core/auth/auth.model'; +import { ConfigService } from "@gsrs-core/config"; @Injectable() export class CanRegisterSubstanceForm implements CanActivate { constructor( private router: Router, - private authService: AuthService + private authService: AuthService, + private configService: ConfigService ) {} canActivate( @@ -17,27 +19,32 @@ export class CanRegisterSubstanceForm implements CanActivate { state: RouterStateSnapshot ): Observable | Promise | (boolean | UrlTree) { return new Observable(observer => { - this.authService.getAuth().subscribe(auth => { - if (auth) { - this.authService.hasAnyRolesAsync('DataEntry', 'SuperDataEntry').subscribe(response => { - if (response) { - observer.next(true); - observer.complete(); - } else { - observer.next(this.router.parseUrl('/browse-substance')); - observer.complete(); - } - }); - } else { - const navigationExtras: NavigationExtras = { - queryParams: { - path: state.url - } - }; - observer.next(this.router.createUrlTree(['/login'], navigationExtras)); - observer.complete(); - } - }); + if (this.configService.configData.isPfdaVersion) { + observer.next(true); + observer.complete(); + } else { + this.authService.getAuth().subscribe(auth => { + if (auth) { + this.authService.hasAnyRolesAsync('DataEntry', 'SuperDataEntry').subscribe(response => { + if (response) { + observer.next(true); + observer.complete(); + } else { + observer.next(this.router.parseUrl('/browse-substance')); + observer.complete(); + } + }); + } else { + const navigationExtras: NavigationExtras = { + queryParams: { + path: state.url + } + }; + observer.next(this.router.createUrlTree(['/login'], navigationExtras)); + observer.complete(); + } + }); + } }); } } diff --git a/src/app/core/substance/substance.service.ts b/src/app/core/substance/substance.service.ts index a83bf065b..acd62eada 100644 --- a/src/app/core/substance/substance.service.ts +++ b/src/app/core/substance/substance.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams, HttpClientJsonpModule, HttpParameterCodec } from '@angular/common/http'; -import { BehaviorSubject, interval, Observable, Observer, Subject } from 'rxjs'; +import { BehaviorSubject, concatMap, filter, interval, Observable, Observer, Subject, throwError } from 'rxjs'; import { ConfigService } from '../config/config.service'; import { BaseHttpService } from '../base/base-http.service'; import { @@ -27,6 +27,7 @@ import {HierarchyNode} from '@gsrs-core/substances-browse/substance-hierarchy/hi import { SubstanceDependenciesImageNode } from '@gsrs-core/substance-details/substance-dependencies-image/substance-dependencies-image.model'; import { stringify } from 'querystring'; +import { AuthService } from "@gsrs-core/auth"; class CustomEncoder implements HttpParameterCodec { encodeKey(key: string): string { return encodeURIComponent(key); @@ -58,6 +59,7 @@ export class SubstanceService extends BaseHttpService { tempObject: any; constructor( public http: HttpClient, + private authService: AuthService, public configService: ConfigService, private sanitizer: DomSanitizer, private utilsService: UtilsService, @@ -752,8 +754,16 @@ export class SubstanceService extends BaseHttpService { } + // Helper function to create an Observable that emits when the popup login window closes + waitForPopupToClose(popupWindow) { + return interval(1000).pipe( + takeWhile(() => !popupWindow.closed, true), + filter(() => popupWindow.closed) + ); + } + + saveSubstance(substance: SubstanceDetail, type?: string): Observable { - const url = `${this.apiBaseUrl}substances?view=internal`; let method = substance.uuid ? 'PUT' : 'POST'; if (type && type === 'import') { method = 'POST'; @@ -761,7 +771,43 @@ export class SubstanceService extends BaseHttpService { const options = { body: substance }; - return this.http.request(method, url, options); + + if (!this.configService.configData.isPfdaVersion) { + const url = `${this.apiBaseUrl}substances?view=internal`; + return this.http.request(method, url, options); + } else { + return this.authService.getAuth().pipe( + concatMap(auth => { + if (auth) { + // If authenticated, make the HTTP request + const url = `${this.pfdaApiBaseUrl}substances?view=internal`; + return this.http.request(method, url, options); + } else { + // If not authenticated, open the login window and wait for it to close + const height = 700; + const width = 700; + const left = (screen.width / 2) - (width / 2); + const top = (screen.height / 2) - (height / 2); + const loginWindow = window.open( + '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', + 'pFda Login', + `height=${height},width=${width},top=${top},left=${left}` + ); + + // Use an observable to wait for the popup window to close + return this.waitForPopupToClose(loginWindow).pipe( + concatMap(() => { + // Retry saving the substance after the window closes + return this.saveSubstance(substance, type); + }) + ); + } + }), + catchError(error => { + return throwError(() => new Error('Failed to save substance.')); + }) + ); + } } validateSubstance(substance: SubstanceDetail, stagingID?: string): Observable { @@ -1015,7 +1061,7 @@ export class SubstanceService extends BaseHttpService { public GetStagedRecord(id:string) { let url = `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/' }api/v1/substances/stagingArea/${id}`; - + return this.http.get< any >(`${url}`); } diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 82c5e56d2..9debe8d9a 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -1,5 +1,6 @@ export interface Environment { apiBaseUrl: string; + pfdaApiBaseUrl?: string | undefined; configFileLocation?: string; baseHref: string; clasicBaseHref: string; From a78b7bc89721698f58eb1ac002ec63b9eae97cda Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Tue, 22 Oct 2024 15:40:36 +0200 Subject: [PATCH 05/48] Request CSRF token before every POST request --- src/app/core/auth/csrf-token.interceptor.ts | 37 +++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/app/core/auth/csrf-token.interceptor.ts b/src/app/core/auth/csrf-token.interceptor.ts index 597f28061..d7eb94911 100644 --- a/src/app/core/auth/csrf-token.interceptor.ts +++ b/src/app/core/auth/csrf-token.interceptor.ts @@ -1,35 +1,36 @@ import { Injectable } from '@angular/core'; -import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import {HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpClient} from '@angular/common/http'; +import {from, Observable, switchMap} from 'rxjs'; +import {ConfigService} from "@gsrs-core/config"; @Injectable() export class CsrfTokenInterceptor implements HttpInterceptor { - - constructor() {} + constructor(private http: HttpClient, private configService: ConfigService) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { // CSRF token for GET and HEAD is not needed - if (['GET', 'HEAD'].includes(request.method)) { + if (['GET', 'HEAD'].includes(request.method) || !(this.configService.configData?.isPfdaVersion)) { return next.handle(request); } - // Parse CSRF token from HTML meta tag - const metaTag: HTMLMetaElement | null = document.querySelector('meta[name=csrf-token]'); - let csrfToken = metaTag?.content; - if (csrfToken === undefined) { - csrfToken = 'CSRF-TOKEN-NOT-PARSED'; - } + return from(this.fetchCsrfToken()).pipe( + switchMap((token: string) => { + const modifiedRequest = this.addCsrfToken(request, token); + return next.handle(modifiedRequest); + }) + ); + } - // Clone the request and add the CSRF token to the headers - const modifiedRequest = request.clone({ + private fetchCsrfToken(): Promise { + return this.http.get(`${this.configService.configData.apiBaseUrl}csrf-token`, { responseType: 'text' }).toPromise(); + } + + private addCsrfToken(request: HttpRequest, token: string): HttpRequest { + return request.clone({ setHeaders: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'X-CSRF-Token': csrfToken + 'X-CSRF-Token': token } }); - - // Pass the modified request to the next handler - return next.handle(modifiedRequest); } } From f5d7639b12ef796bec0da3bfaac2430309fff2c9 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Wed, 23 Oct 2024 14:43:46 +0200 Subject: [PATCH 06/48] CSRF token uri --- src/app/core/auth/csrf-token.interceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/auth/csrf-token.interceptor.ts b/src/app/core/auth/csrf-token.interceptor.ts index d7eb94911..35a5f8de0 100644 --- a/src/app/core/auth/csrf-token.interceptor.ts +++ b/src/app/core/auth/csrf-token.interceptor.ts @@ -23,7 +23,7 @@ export class CsrfTokenInterceptor implements HttpInterceptor { } private fetchCsrfToken(): Promise { - return this.http.get(`${this.configService.configData.apiBaseUrl}csrf-token`, { responseType: 'text' }).toPromise(); + return this.http.get(`/csrf-token`, { responseType: 'text' }).toPromise(); } private addCsrfToken(request: HttpRequest, token: string): HttpRequest { From bc656cc386e655d2ef42c201e64c933e150f2a3b Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Wed, 20 Nov 2024 08:16:12 -0600 Subject: [PATCH 07/48] Login, logout fix; improvements --- src/app/core/auth/auth.service.ts | 11 ++- src/app/core/auth/csrf-token.interceptor.ts | 6 +- .../pfda-toolbar/pfda-toolbar.component.html | 8 +-- .../pfda-toolbar/pfda-toolbar.component.scss | 1 + .../pfda-toolbar/pfda-toolbar.component.ts | 9 +++ .../substance-form.component.ts | 5 +- src/app/core/substance/substance.service.ts | 72 +++++++++---------- 7 files changed, 66 insertions(+), 46 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 317fb295e..62e483afc 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -161,8 +161,15 @@ export class AuthService { document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; } } - const url = `${this.configService.configData.apiBaseUrl}logout`; - this.http.get(url).subscribe(() => { + let url = `${this.configService.configData.apiBaseUrl}logout`; + let method = 'GET'; + + if (this.configService.configData.isPfdaVersion) { + url = '/logout'; + method = 'DELETE'; + } + + this.http.request(method, url).subscribe(() => { this._auth = null; this._authUpdate.next(null); }, error => { diff --git a/src/app/core/auth/csrf-token.interceptor.ts b/src/app/core/auth/csrf-token.interceptor.ts index 35a5f8de0..854fce46f 100644 --- a/src/app/core/auth/csrf-token.interceptor.ts +++ b/src/app/core/auth/csrf-token.interceptor.ts @@ -9,8 +9,10 @@ export class CsrfTokenInterceptor implements HttpInterceptor { intercept(request: HttpRequest, next: HttpHandler): Observable> { - // CSRF token for GET and HEAD is not needed - if (['GET', 'HEAD'].includes(request.method) || !(this.configService.configData?.isPfdaVersion)) { + // CSRF token request needed in pFDA version only, for POST and DELETE requests made on /gsrs-auth/* and /logout endpoints + if (['GET', 'HEAD'].includes(request.method) + || (!request.url.toLowerCase().includes('/gsrs-auth/') && !request.url.toLowerCase().includes('/logout')) + || !(this.configService.configData?.isPfdaVersion)) { return next.handle(request); } diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 3e2bd168b..7f867f2b4 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -61,12 +61,12 @@
- +
Login
-
+ diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index 8a863ed32..d5e1fc63f 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -47,6 +47,7 @@ $screenMedium: 1045px; align-items: center; justify-content: center; padding: 10px 6px; + cursor: pointer; &:hover { color: $pfda-navbar-item-hover; diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index f05646868..42b52f1fe 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -87,4 +87,13 @@ export class PfdaToolbarComponent implements OnInit { removeZindex(): void { this.overlayContainer.style.zIndex = null; } + + login(): void { + const locationEncoded = encodeURIComponent(`${window.location.pathname}${window.location.search}`); + window.location.assign(`${this.pfdaBaseUrl}login?user_return_to=${locationEncoded}`); + } + + logout(): void { + this.authService.logout(); + } } diff --git a/src/app/core/substance-form/substance-form.component.ts b/src/app/core/substance-form/substance-form.component.ts index c7c118989..0a15a2b80 100644 --- a/src/app/core/substance-form/substance-form.component.ts +++ b/src/app/core/substance-form/substance-form.component.ts @@ -1056,6 +1056,7 @@ export class SubstanceFormComponent implements OnInit, AfterViewInit, OnDestroy } this.openSuccessDialog({ type: 'submit', fileUrl: response.fileUrl }); }, (error: SubstanceFormResults) => { + console.log('error: ', error); this.showSubmissionMessages = true; this.loadingService.setLoading(false); this.isLoading = false; @@ -1099,7 +1100,9 @@ export class SubstanceFormComponent implements OnInit, AfterViewInit, OnDestroy messageType: 'ERROR', message: 'Unknown Server Error' }; - if (error && error.error && error.error.message) { + if (error && error.type === 'AUTH') { + message.message = `Authentication Error: ${error.message}`; + } else if (error && error.error && error.error.message) { message.message = 'Server Error ' + (error.status + ': ' || ': ') + error.error.message; } else if (error && error.error && (typeof error.error) === 'string') { message.message = 'Server Error ' + (error.status + ': ' || '') + error.error; diff --git a/src/app/core/substance/substance.service.ts b/src/app/core/substance/substance.service.ts index acd62eada..a5b0f1a9d 100644 --- a/src/app/core/substance/substance.service.ts +++ b/src/app/core/substance/substance.service.ts @@ -764,52 +764,50 @@ export class SubstanceService extends BaseHttpService { saveSubstance(substance: SubstanceDetail, type?: string): Observable { - let method = substance.uuid ? 'PUT' : 'POST'; - if (type && type === 'import') { - method = 'POST'; - } - const options = { - body: substance - }; + const method = type === 'import' || !substance.uuid ? 'POST' : 'PUT'; + const options = { body: substance }; + + const url = this.configService.configData.isPfdaVersion + ? `${this.pfdaApiBaseUrl}substances?view=internal` + : `${this.apiBaseUrl}substances?view=internal`; if (!this.configService.configData.isPfdaVersion) { - const url = `${this.apiBaseUrl}substances?view=internal`; return this.http.request(method, url, options); } else { return this.authService.getAuth().pipe( - concatMap(auth => { - if (auth) { - // If authenticated, make the HTTP request - const url = `${this.pfdaApiBaseUrl}substances?view=internal`; - return this.http.request(method, url, options); - } else { - // If not authenticated, open the login window and wait for it to close - const height = 700; - const width = 700; - const left = (screen.width / 2) - (width / 2); - const top = (screen.height / 2) - (height / 2); - const loginWindow = window.open( - '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', - 'pFda Login', - `height=${height},width=${width},top=${top},left=${left}` - ); - - // Use an observable to wait for the popup window to close - return this.waitForPopupToClose(loginWindow).pipe( - concatMap(() => { - // Retry saving the substance after the window closes - return this.saveSubstance(substance, type); - }) - ); - } - }), - catchError(error => { - return throwError(() => new Error('Failed to save substance.')); - }) + concatMap(auth => auth + ? this.http.request(method, url, options) + : this.handlePfdaLoginAndRetry(method, url, options) + ) ); } } + private handlePfdaLoginAndRetry(method: string, url: string, options: any): Observable { + const height = 700; + const width = 700; + const left = (screen.width / 2) - (width / 2); + const top = (screen.height / 2) - (height / 2); + const loginWindow = window.open( + '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', + 'pFDA Login', + `height=${height},width=${width},top=${top},left=${left}` + ); + + return this.waitForPopupToClose(loginWindow).pipe( + concatMap(() => + this.authService.getAuth().pipe( + concatMap(authAfterLogin => + authAfterLogin + ? this.http.request(method, url, options) + : throwError(() => ({ type: 'AUTH', message: 'Authentication failed' })) + ) + ) + ) + ); + } + + validateSubstance(substance: SubstanceDetail, stagingID?: string): Observable { let url = `${this.configService.configData.apiBaseUrl}api/v1/substances/@validate`; if (stagingID) { From 65e4f6b367bddad0f6636b4d8f12a9f9efcda2c6 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Tue, 26 Nov 2024 14:40:21 +0100 Subject: [PATCH 08/48] PFDA auth update --- src/app/core/auth/auth.service.ts | 40 ++++++++++++++- .../session-expiration-dialog.component.html | 1 + .../session-expiration-dialog.component.ts | 12 ++++- .../pfda-toolbar/pfda-toolbar.component.ts | 10 ++-- src/app/core/substance/substance.service.ts | 50 +++++-------------- 5 files changed, 69 insertions(+), 44 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 62e483afc..3ad9860c8 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,8 +1,8 @@ import { Injectable, PLATFORM_ID, Inject } from '@angular/core'; import { ConfigService } from '../config/config.service'; import { Auth, Role, UserGroup } from './auth.model'; -import { Observable, Subject, of } from 'rxjs'; -import { map, take, catchError } from 'rxjs/operators'; +import { interval, Observable, Subject, of } from 'rxjs'; +import { catchError, concatMap, filter, map, take, takeWhile } from 'rxjs/operators'; import { HttpClient, HttpParams } from '@angular/common/http'; import { isPlatformBrowser } from '@angular/common'; import { UserDownload, AllUserDownloads } from '@gsrs-core/auth/user-downloads/download.model'; @@ -96,6 +96,36 @@ export class AuthService { ); } + // Helper function to create an Observable that emits when the popup login window closes + private waitForPopupToClose(popupWindow: Window): Observable { + return interval(1000).pipe( + takeWhile(() => !popupWindow.closed, true), + filter(() => popupWindow.closed) + ); + } + + // Method to handle pFDA login (using popup window) and return success/unsuccess flag + pfdaLogin(): Observable { + const height = 700; + const width = 700; + const left = (screen.width / 2) - (width / 2); + const top = (screen.height / 2) - (height / 2); + const loginWindow = window.open( + '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', + 'pFDA Login', + `height=${height},width=${width},top=${top},left=${left}` + ); + + return this.waitForPopupToClose(loginWindow).pipe( + concatMap(() => + this.getAuth().pipe( + map(authAfterLogin => !!authAfterLogin), // Convert to boolean (true = success) + catchError(() => of(false)) // Return false if there's an error + ) + ) + ); + } + getAuth(): Observable { return new Observable(observer => { @@ -143,6 +173,10 @@ export class AuthService { }); } + private deleteCookie(name: string) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; + } + logout(): void { // if ( // !this.configService.configData @@ -172,9 +206,11 @@ export class AuthService { this.http.request(method, url).subscribe(() => { this._auth = null; this._authUpdate.next(null); + this.deleteCookie('sessionExpiredAt'); }, error => { this._auth = null; this._authUpdate.next(null); + this.deleteCookie('sessionExpiredAt'); }); } diff --git a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.html b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.html index c35a6869c..2903ea40f 100644 --- a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.html +++ b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.html @@ -6,6 +6,7 @@

+
diff --git a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts index da96f8478..671946484 100644 --- a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { SessionExpirationWarning } from '@gsrs-core/config'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { AuthService } from '@gsrs-core/auth'; @Component({ selector: 'app-session-expiration-dialog', @@ -22,7 +23,8 @@ export class SessionExpirationDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) public data: any, // N.B. injected services has to come after data private router: Router, - private http: HttpClient + private http: HttpClient, + private authService: AuthService ) { this.sessionExpirationWarning = data.sessionExpirationWarning; this.sessionExpiringAt = data.sessionExpiringAt; @@ -75,4 +77,12 @@ export class SessionExpirationDialogComponent implements OnInit { login() { window.location.assign('/login'); } + + proceedAsGuest() { + clearInterval(this.updateDialogInterval); + if (this.timeRemainingSeconds > 0) { + this.authService.logout(); + } + this.closeDialog(); + } } diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 42b52f1fe..7e922b555 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -5,7 +5,7 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { AuthService } from '../../auth/auth.service'; import { SubstanceTextSearchService } from '@gsrs-core/substance-text-search/substance-text-search.service'; import { Auth } from '../../auth/auth.model'; -import { Subscription } from 'rxjs'; +import { concatMap, Subscription } from 'rxjs'; import { NavItem } from '@gsrs-core/config'; @Component({ @@ -40,7 +40,7 @@ export class PfdaToolbarComponent implements OnInit { ngOnInit() { this.pfdaBaseUrl = this.configService.configData.pfdaBaseUrl || '/'; - const baseHref = this.configService.environment.baseHref || '/' + const baseHref = this.configService.environment.baseHref || '/ginas/app/beta/'; this.logoSrcPath = `${baseHref}assets/images/pfda/pfda-logo.png`; this.homeIconPath = `${baseHref}assets/images/pfda/home.svg`; @@ -89,8 +89,10 @@ export class PfdaToolbarComponent implements OnInit { } login(): void { - const locationEncoded = encodeURIComponent(`${window.location.pathname}${window.location.search}`); - window.location.assign(`${this.pfdaBaseUrl}login?user_return_to=${locationEncoded}`); + this.authService.pfdaLogin().pipe( + concatMap(success => { + return this.authService.getAuth(); + })).subscribe(); } logout(): void { diff --git a/src/app/core/substance/substance.service.ts b/src/app/core/substance/substance.service.ts index a5b0f1a9d..03c30e61a 100644 --- a/src/app/core/substance/substance.service.ts +++ b/src/app/core/substance/substance.service.ts @@ -754,15 +754,6 @@ export class SubstanceService extends BaseHttpService { } - // Helper function to create an Observable that emits when the popup login window closes - waitForPopupToClose(popupWindow) { - return interval(1000).pipe( - takeWhile(() => !popupWindow.closed, true), - filter(() => popupWindow.closed) - ); - } - - saveSubstance(substance: SubstanceDetail, type?: string): Observable { const method = type === 'import' || !substance.uuid ? 'POST' : 'PUT'; const options = { body: substance }; @@ -775,39 +766,24 @@ export class SubstanceService extends BaseHttpService { return this.http.request(method, url, options); } else { return this.authService.getAuth().pipe( - concatMap(auth => auth - ? this.http.request(method, url, options) - : this.handlePfdaLoginAndRetry(method, url, options) + concatMap(auth => + auth + ? this.http.request(method, url, options) + : this.authService.pfdaLogin().pipe( + concatMap(success => + success + ? this.http.request(method, url, options) + : throwError(() => ({ + type: 'AUTH', + message: 'Authentication failed', + })) + ) + ) ) ); } } - private handlePfdaLoginAndRetry(method: string, url: string, options: any): Observable { - const height = 700; - const width = 700; - const left = (screen.width / 2) - (width / 2); - const top = (screen.height / 2) - (height / 2); - const loginWindow = window.open( - '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', - 'pFDA Login', - `height=${height},width=${width},top=${top},left=${left}` - ); - - return this.waitForPopupToClose(loginWindow).pipe( - concatMap(() => - this.authService.getAuth().pipe( - concatMap(authAfterLogin => - authAfterLogin - ? this.http.request(method, url, options) - : throwError(() => ({ type: 'AUTH', message: 'Authentication failed' })) - ) - ) - ) - ); - } - - validateSubstance(substance: SubstanceDetail, stagingID?: string): Observable { let url = `${this.configService.configData.apiBaseUrl}api/v1/substances/@validate`; if (stagingID) { From e233a8d2063eb2bc6139b1376ffeb0afce0c7baf Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Thu, 28 Nov 2024 15:11:28 +0100 Subject: [PATCH 09/48] Public GSRS --- src/app/core/auth/auth.service.ts | 51 +++---------------- src/app/core/auth/csrf-token.interceptor.ts | 9 ++-- .../session-expiration-dialog.component.ts | 19 +++++-- .../session-expiration.component.ts | 6 +-- src/app/core/base/base-http.service.ts | 4 -- src/app/core/config/config.model.ts | 1 - src/app/core/config/config.service.ts | 3 -- .../substance-form.component.html | 3 +- .../substance-form.component.ts | 16 +++++- src/app/core/substance/substance.service.ts | 5 +- src/environments/environment.model.ts | 1 - 11 files changed, 44 insertions(+), 74 deletions(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 3ad9860c8..59b86ebb1 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -111,7 +111,7 @@ export class AuthService { const left = (screen.width / 2) - (width / 2); const top = (screen.height / 2) - (height / 2); const loginWindow = window.open( - '/login?user_return_to=%2Fgsrs-auth%2Fclose-login-window', + '/login?user_return_to=%2Fginas%2Fclose-pfda-login-window', 'pFDA Login', `height=${height},width=${width},top=${top},left=${left}` ); @@ -346,55 +346,16 @@ export class AuthService { private fetchAuth(): Observable { return new Observable(observer => { this.configService.afterLoad().then(cd => { - const isPfdaVersion = this.configService.configData.isPfdaVersion === true; - const url = isPfdaVersion ? '/api/user' : - `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/'}api/v1/whoami`; + const url = `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/'}api/v1/`; if (this.configService.configData && this.configService.configData.dummyWhoami) { observer.next(this.configService.configData.dummyWhoami); } else { - this.http.get(url) + this.http.get(`${url}whoami`) .subscribe( auth => { - if (isPfdaVersion) { - // @ts-ignore - const dxuser = auth.user.dxuser; - const pfdaAuth: Auth = { - id: 0, - version: 0, - created: 0, - modified: 0, - deprecated: false, - user: { - id: 0, - version: 0, - created: 0, - modified: 0, - deprecated: false, - username: dxuser, - email: auth.user.email, - admin: auth.user.admin - }, - active: true, - systemAuth: false, - key: 'unused', - identifier: dxuser, - groups: [], - roles: [ - "Query", - "Updater", - "SuperUpdate", - "DataEntry", - "SuperDataEntry" - ], - computedToken: 'unused', - tokenTimeToExpireMS: 9999999999999, - roleQueryOnly: false, - permissions: [] - } - observer.next(pfdaAuth); - } else { - observer.next(auth); - } + // console.log("Authorized as"); + // console.log(auth); + observer.next(auth); }, err => { console.log("Authorized error"); diff --git a/src/app/core/auth/csrf-token.interceptor.ts b/src/app/core/auth/csrf-token.interceptor.ts index 854fce46f..412d3b5c1 100644 --- a/src/app/core/auth/csrf-token.interceptor.ts +++ b/src/app/core/auth/csrf-token.interceptor.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; -import {HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpClient} from '@angular/common/http'; -import {from, Observable, switchMap} from 'rxjs'; -import {ConfigService} from "@gsrs-core/config"; +import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpClient } from '@angular/common/http'; +import { from, Observable, switchMap } from 'rxjs'; +import { ConfigService } from "@gsrs-core/config"; @Injectable() export class CsrfTokenInterceptor implements HttpInterceptor { @@ -9,9 +9,8 @@ export class CsrfTokenInterceptor implements HttpInterceptor { intercept(request: HttpRequest, next: HttpHandler): Observable> { - // CSRF token request needed in pFDA version only, for POST and DELETE requests made on /gsrs-auth/* and /logout endpoints + // CSRF token request needed in pFDA version only if (['GET', 'HEAD'].includes(request.method) - || (!request.url.toLowerCase().includes('/gsrs-auth/') && !request.url.toLowerCase().includes('/logout')) || !(this.configService.configData?.isPfdaVersion)) { return next.handle(request); } diff --git a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts index 671946484..051f1725b 100644 --- a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts @@ -1,9 +1,10 @@ import { Component, OnInit, Inject } from '@angular/core'; import { Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; -import { SessionExpirationWarning } from '@gsrs-core/config'; +import {ConfigService, SessionExpirationWarning} from '@gsrs-core/config'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { AuthService } from '@gsrs-core/auth'; +import {concatMap} from "rxjs"; @Component({ selector: 'app-session-expiration-dialog', @@ -24,7 +25,8 @@ export class SessionExpirationDialogComponent implements OnInit { // N.B. injected services has to come after data private router: Router, private http: HttpClient, - private authService: AuthService + private authService: AuthService, + public configService: ConfigService ) { this.sessionExpirationWarning = data.sessionExpirationWarning; this.sessionExpiringAt = data.sessionExpiringAt; @@ -75,7 +77,18 @@ export class SessionExpirationDialogComponent implements OnInit { } login() { - window.location.assign('/login'); + if (this.configService.configData.isPfdaVersion) { + this.authService.pfdaLogin().pipe( + concatMap(success => { + console.log('success: ', success); + if (success) { + this.closeDialog(); + return this.authService.getAuth(); + } + })).subscribe(); + } else { + window.location.assign('/login'); + } } proceedAsGuest() { diff --git a/src/app/core/auth/session-expiration/session-expiration.component.ts b/src/app/core/auth/session-expiration/session-expiration.component.ts index 1f903f2f1..16f4b7967 100644 --- a/src/app/core/auth/session-expiration/session-expiration.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration.component.ts @@ -84,11 +84,7 @@ export class SessionExpirationComponent implements OnInit { } refreshSession(): any { - if (this.configService.configData.isPfdaVersion) { - fetch(`${this.configService.configData.pfdaApiBaseUrl}user`) - } else { - fetch(`${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}`) - } + fetch(`${this.baseHref || ''}api/v1/whoami?key=${this.utilsService.newUUID()}`); } startSessionTimeoutInterval() { diff --git a/src/app/core/base/base-http.service.ts b/src/app/core/base/base-http.service.ts index fd27e461e..c8a7db553 100644 --- a/src/app/core/base/base-http.service.ts +++ b/src/app/core/base/base-http.service.ts @@ -2,16 +2,12 @@ import { ConfigService } from '../config/config.service'; export abstract class BaseHttpService { public apiBaseUrl: string; - public pfdaApiBaseUrl: string = ''; public baseUrl: string; constructor( public configService: ConfigService ) { this.apiBaseUrl = `${(this.configService.configData && this.configService.configData.apiBaseUrl) || '/' }api/v1/`; - if (this.configService.configData.isPfdaVersion && this.configService.configData.pfdaApiBaseUrl) { - this.pfdaApiBaseUrl = this.configService.configData.pfdaApiBaseUrl; - } this.baseUrl = (this.configService.configData && this.configService.configData.apiBaseUrl) || '/'; } } diff --git a/src/app/core/config/config.model.ts b/src/app/core/config/config.model.ts index 821401668..f04db4df7 100644 --- a/src/app/core/config/config.model.ts +++ b/src/app/core/config/config.model.ts @@ -2,7 +2,6 @@ import { Auth } from "@gsrs-core/auth"; export interface Config { apiBaseUrl?: string; - pfdaApiBaseUrl?: string; gsrsHomeBaseUrl?: string; apiSSG4mBaseUrl?: string; apiUrlDomain?: string; diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 43586ac7b..e2a78a6af 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -47,9 +47,6 @@ export class ConfigService { if (config.apiBaseUrl == null && environment.apiBaseUrl != null) { config.apiBaseUrl = environment.apiBaseUrl; } - if (config.pfdaApiBaseUrl == null && environment.pfdaApiBaseUrl != null) { - config.pfdaApiBaseUrl = environment.pfdaApiBaseUrl; - } if (config.apiBaseUrl.indexOf('//') > -1) { const parts = config.apiBaseUrl.split('/'); config.apiUrlDomain = `${parts[0]}//${parts[2]}`; diff --git a/src/app/core/substance-form/substance-form.component.html b/src/app/core/substance-form/substance-form.component.html index bec1f84eb..2d7f3b82e 100644 --- a/src/app/core/substance-form/substance-form.component.html +++ b/src/app/core/substance-form/substance-form.component.html @@ -212,8 +212,7 @@

{{ section.menuLabel }}

+ + + + + + + + + + + + + + + + + + + +
@@ -97,6 +117,15 @@
+
+
+
+ {{pauseStructureSearch}} +
+
+
+ {{asyncFinished}} +
diff --git a/src/app/core/substances-browse/substances-browse.component.ts b/src/app/core/substances-browse/substances-browse.component.ts index bed97c53c..806094534 100644 --- a/src/app/core/substances-browse/substances-browse.component.ts +++ b/src/app/core/substances-browse/substances-browse.component.ts @@ -219,7 +219,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr }); }); - + this.title.setTitle('Browse Substances'); this.pageSize = 10; @@ -248,7 +248,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr this.privateSearchSeqType = this.activatedRoute.snapshot.queryParams['seq_type'] || ''; this.smiles = this.activatedRoute.snapshot.queryParams['smiles'] || ''; // the sort order should be set to default (similarity) for structure searches, last edited for all others - this.order = this.activatedRoute.snapshot.queryParams['order'] || + this.order = this.activatedRoute.snapshot.queryParams['order'] || (this.privateStructureSearchTerm && this.privateStructureSearchTerm !== '' ? 'default':'$root_lastEdited'); this.view = this.activatedRoute.snapshot.queryParams['view'] || 'cards'; this.pageSize = parseInt(this.activatedRoute.snapshot.queryParams['pageSize'], null) || 10; @@ -516,7 +516,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr id:'structure-dialog' }); this.overlayContainer.style.zIndex = '1002'; - + this.structureSearchDialog.afterClosed().subscribe(result => { this.overlayContainer.style.zIndex = null; this.loadingService.setLoading(false); @@ -525,7 +525,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr }); this.structureDialogOpened = true; } - + } searchSubstances() { @@ -580,7 +580,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr // this.pauseStructureSearch = true; iterations++; } - + this.privateBulkSearchStatusKey = pagingResponse.statusKey; this.isError = false; @@ -620,7 +620,7 @@ export class SubstancesBrowseComponent implements OnInit, AfterViewInit, OnDestr this.etag = pagingResponse.etag; if (pagingResponse.facets && pagingResponse.facets.length > 0) { this.rawFacets = pagingResponse.facets; - + } this.narrowSearchSuggestions = {}; this.matchTypes = []; @@ -760,7 +760,7 @@ searchTermOkforBeginsWithSearch(): boolean { maxHeight: '85%', width: '60%', - + data: { 'extension': extension } }); @@ -1287,21 +1287,21 @@ searchTermOkforBeginsWithSearch(): boolean { addToList(): void { let data = {view: 'add', etag: this.etag, lists: this.userLists}; - + const dialogRef = this.dialog.open(UserQueryListDialogComponent, { width: '800px', autoFocus: false, data: data - + }); this.overlayContainer.style.zIndex = '1002'; - + const dialogSubscription = dialogRef.afterClosed().pipe(take(1)).subscribe(response => { if (response) { this.overlayContainer.style.zIndex = null; } }); } - + } diff --git a/src/app/fda/config/config.json b/src/app/fda/config/config.json index a16732a6c..23c417119 100644 --- a/src/app/fda/config/config.json +++ b/src/app/fda/config/config.json @@ -10,6 +10,7 @@ "bannerMessage": null, "showNameStandardizeButton": true, "advancedSearchFacetDisplay": false, + "apiBaseUrl": "https://gsrs.ncats.nih.gov/ginas/app/", "approvalCodeName": "UNII", "primaryCode": "BDNUM", "useDataUrl": false, From 2e66cf3e26126945ef615902709fb57b7648be09 Mon Sep 17 00:00:00 2001 From: NikoAnderson Date: Mon, 23 Dec 2024 10:26:50 -0500 Subject: [PATCH 19/48] final version for testing --- .../substances-browse.component.html | 31 ------------------- .../substances-browse.component.scss | 5 ++- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/src/app/core/substances-browse/substances-browse.component.html b/src/app/core/substances-browse/substances-browse.component.html index 25e36e5f8..aa6ead17c 100644 --- a/src/app/core/substances-browse/substances-browse.component.html +++ b/src/app/core/substances-browse/substances-browse.component.html @@ -75,26 +75,6 @@ Or start a new chemical registration using this Smiles
- - - - - - - - - - - - - - - - - - - -
@@ -117,15 +97,6 @@
-
-
-
- {{pauseStructureSearch}} -
-
-
- {{asyncFinished}} -
@@ -730,5 +701,3 @@

{{privateSearchType | titlecase }} Search is Processing...


- - diff --git a/src/app/core/substances-browse/substances-browse.component.scss b/src/app/core/substances-browse/substances-browse.component.scss index a342a6572..6011c967b 100644 --- a/src/app/core/substances-browse/substances-browse.component.scss +++ b/src/app/core/substances-browse/substances-browse.component.scss @@ -346,7 +346,7 @@ display: flex; font-family: Menlo,Monaco,Consolas,"Courier New",monospace; color: var(--pink-span-color); } - + .similarity-label { font-style: italic; } @@ -626,7 +626,7 @@ display: flex; ::ng-deep .mat-select-value { max-width: 100%; width: auto; - } + } } .page-label { @@ -774,4 +774,3 @@ margin-left: 20px; line-height: 28px; margin-left: 20px; } - From bb02e750337ea9a7df28ac2a37099fd142902d67 Mon Sep 17 00:00:00 2001 From: NikoAnderson Date: Mon, 23 Dec 2024 10:28:11 -0500 Subject: [PATCH 20/48] adding service --- src/app/core/substance/substance.service.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/app/core/substance/substance.service.ts b/src/app/core/substance/substance.service.ts index 8c4bd867b..1cffdfaee 100644 --- a/src/app/core/substance/substance.service.ts +++ b/src/app/core/substance/substance.service.ts @@ -267,7 +267,6 @@ export class SubstanceService extends BaseHttpService { sync = true; } if (!sync && this.searchKeys[structureFacetsKey]) { - console.log('not sync'); url += `status(${this.searchKeys[structureFacetsKey]})/results`; params = params.appendFacetParams(facets, this.showDeprecated); if(querySearchTerm.length > 0) { @@ -285,12 +284,8 @@ export class SubstanceService extends BaseHttpService { if (order != null && order !== '') { params = params.append('order', order); } - console.log(url); - console.log(params); } else { - console.log(sync); - console.log(type); params = params.append('q', (searchTerm)); if (type) { params = params.append('type', type); @@ -315,7 +310,6 @@ export class SubstanceService extends BaseHttpService { } } url += 'substances/structureSearch'; - console.log(url); } const options = { @@ -324,10 +318,8 @@ export class SubstanceService extends BaseHttpService { this.http.get(url, options).subscribe( response => { - console.log(response); // call async if (response.results) { - console.log('call async'); const resultKey = response.key; this.searchKeys[structureFacetsKey] = resultKey; this.processAsyncSearchResults( @@ -342,7 +334,6 @@ export class SubstanceService extends BaseHttpService { skip ); } else { - console.log('complete'); observer.next(response); observer.complete(); } @@ -488,8 +479,6 @@ export class SubstanceService extends BaseHttpService { response => { // call async if (response.results) { - console.log('has results'); - console.log(response); const resultKey = response.key; this.searchKeys[bulkFacetsKey] = resultKey; this.processAsyncSearchResults( @@ -505,7 +494,6 @@ export class SubstanceService extends BaseHttpService { ); } else { // consider making API backend provide statusKey in JSON - console.log('not results)'); if(this.searchKeys && this.searchKeys[bulkFacetsKey]) { response.statusKey = this.searchKeys[bulkFacetsKey]; } From be0b95e736277fb07773fc95368d7e474d431b12 Mon Sep 17 00:00:00 2001 From: NikoAnderson Date: Mon, 23 Dec 2024 10:31:05 -0500 Subject: [PATCH 21/48] removing local changes --- src/app/fda/config/config.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/fda/config/config.json b/src/app/fda/config/config.json index 23c417119..a16732a6c 100644 --- a/src/app/fda/config/config.json +++ b/src/app/fda/config/config.json @@ -10,7 +10,6 @@ "bannerMessage": null, "showNameStandardizeButton": true, "advancedSearchFacetDisplay": false, - "apiBaseUrl": "https://gsrs.ncats.nih.gov/ginas/app/", "approvalCodeName": "UNII", "primaryCode": "BDNUM", "useDataUrl": false, From 1588625e3a38f896cf41c2968e34f1c44fee678a Mon Sep 17 00:00:00 2001 From: NikoAnderson Date: Tue, 21 Jan 2025 15:16:12 -0500 Subject: [PATCH 22/48] adding formulation autofill option for ssg1 --- .../constituents/substance-form-constituents-card.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/substance-form/constituents/substance-form-constituents-card.component.ts b/src/app/core/substance-form/constituents/substance-form-constituents-card.component.ts index a0a25052a..4b6b092e8 100644 --- a/src/app/core/substance-form/constituents/substance-form-constituents-card.component.ts +++ b/src/app/core/substance-form/constituents/substance-form-constituents-card.component.ts @@ -78,7 +78,7 @@ export class SubstanceFormConstituentsCardComponent extends SubstanceCardBaseFil this.formulationPercent = 0; this.components = 0; this.constituents.forEach(constituent => { - if(constituent && constituent.amount && constituent.amount.type === "WEIGHT PERCENT" + if(constituent && constituent.amount && constituent.amount.type === "WEIGHT PERCENT" && constituent.amount.units === "%" && constituent.amount.average) { this.formulationPercent = parseFloat(this.formulationPercent.toString()) + parseFloat(constituent.amount.average.toString()); this.components++; From fee2e109f7d589f92f8e20f964edda32429083e2 Mon Sep 17 00:00:00 2001 From: NikoAnderson Date: Thu, 23 Jan 2025 11:09:12 -0500 Subject: [PATCH 23/48] adding formulation percentage for G1ss, det.pagin --- .../substance-codes/substance-codes.component.html | 8 ++++---- .../substance-names/substance-names.component.html | 10 +++++----- .../substance-references.component.html | 2 +- .../substance-relationships.component.html | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/core/substance-details/substance-codes/substance-codes.component.html b/src/app/core/substance-details/substance-codes/substance-codes.component.html index fae32366b..282837881 100644 --- a/src/app/core/substance-details/substance-codes/substance-codes.component.html +++ b/src/app/core/substance-details/substance-codes/substance-codes.component.html @@ -93,7 +93,7 @@ + [disabled] = "!code.comments && !code.codeText" >{{!code.comments && !code.codeText ? 'None' : 'View'}}

Code Comments

@@ -110,17 +110,17 @@

Code Comments

- +
- + References - diff --git a/src/app/core/substance-details/substance-names/substance-names.component.html b/src/app/core/substance-details/substance-names/substance-names.component.html index 2dc7a32c0..b8b5876b1 100644 --- a/src/app/core/substance-details/substance-names/substance-names.component.html +++ b/src/app/core/substance-details/substance-names/substance-names.component.html @@ -14,7 +14,7 @@ '>Both
- + {{showHideFilterText}} @@ -162,22 +162,22 @@ Details -

Details

- + -
- Naming organizations: + Naming organizations:
- {{org.nameOrg}}{{!last? ', ':''}} + {{org.nameOrg}}{{!last? ', ':''}}
diff --git a/src/app/core/substance-details/substance-references/substance-references.component.html b/src/app/core/substance-details/substance-references/substance-references.component.html index e33d554d8..9ec599324 100644 --- a/src/app/core/substance-details/substance-references/substance-references.component.html +++ b/src/app/core/substance-details/substance-references/substance-references.component.html @@ -97,7 +97,7 @@
Access + diff --git a/src/app/core/substance-details/substance-relationships/substance-relationships.component.html b/src/app/core/substance-details/substance-relationships/substance-relationships.component.html index d213b4b8b..3798f0ea2 100644 --- a/src/app/core/substance-details/substance-relationships/substance-relationships.component.html +++ b/src/app/core/substance-details/substance-relationships/substance-relationships.component.html @@ -33,12 +33,12 @@ Details -
{{filename? filename: 'no file chosen'}}
- - -
-
Or paste JSON here:
- -
-
- {{message}} -
+ +
+
+
{{filename? filename: 'no file chosen'}}
+ +
+ + +
+
Paste JSON here:
+ +
+
+ +
+
URL:
+ +
Note: The URL needs to be publicly accessible
+
+
+ +
+ {{message}} +

- -
\ No newline at end of file + + diff --git a/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts b/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts index dd28c539b..774d23b60 100644 --- a/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts +++ b/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit, Inject } from '@angular/core'; -import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Router } from '@angular/router'; +import { MatTabChangeEvent } from '@angular/material/tabs'; +import { ConfigService } from '@gsrs-core/config'; @Component({ selector: 'app-substance-edit-import-dialog', @@ -14,15 +16,21 @@ export class SubstanceEditImportDialogComponent implements OnInit { record: any; filename: string; pastedJSON: string; - uploaded = false; + pastedUrl: string; title = 'Substance Import'; entity = 'Substance'; + currentTab: number = 0; + urlImportEnabled: boolean = false; + constructor( private router: Router, + private configService: ConfigService, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any - ) { } + ) { + this.urlImportEnabled = this.configService.configData.isPfdaVersion; + } ngOnInit() { if (this.data) { @@ -59,36 +67,70 @@ export class SubstanceEditImportDialogComponent implements OnInit { } }; reader.readAsText(event.target.files[0]); - this.uploaded = true; } } - useFile() { - if (!this.uploaded && this.pastedJSON) { - const read = JSON.parse(this.pastedJSON); - if (!read['substanceClass']) { - this.message = 'Error: Invalid JSON format'; - this.loaded = false; + importSubstance() { + if (this.currentTab === 0) { + // Nothing + this.dialogRef.close(this.record); + } else if (this.currentTab === 1) { + const read = JSON.parse(this.pastedJSON); + if (!read['substanceClass']) { + this.message = 'Error: Invalid JSON format'; + this.loaded = false; + } else { + this.loaded = true; + this.record = this.pastedJSON; + this.message = ''; + this.dialogRef.close(this.record); + } + } else if (this.currentTab === 2) { + fetch(`/reverse-proxy?url=${this.pastedUrl}`).then(r => { + if (r.status !== 200) { + r.json().then(data => { + this.message = data.message ? data.message : 'Error while loading given URL'; + }).catch(_e => { + this.message = 'Error while loading given URL'; + }) } else { - this.loaded = true; - this.record = this.pastedJSON; - this.message = ''; + const json = r.text().then(data => { + try { + JSON.parse(data); + this.record = data; + this.dialogRef.close(this.record); + } catch (_e) { + this.message = 'Error: The URL does not point to a valid JSON file' + } + }); } + }).catch(e => { + this.message = `Error: ${e.message}`; + }) } - this.dialogRef.close(this.record); } - checkLoaded() { this.loaded = true; try { JSON.parse(this.pastedJSON); this.message = ''; - } catch (e) { - this.message = 'Error: Invalid JSON format in pasted string'; - this.loaded = false; + } catch (e) { + this.message = 'Error: Invalid JSON format in pasted string'; + this.loaded = false; + } + } + + checkUrl() { + try { + new URL(this.pastedUrl); + this.loaded = true; + this.message = ''; + } catch (_e) { + this.message = 'Invalid URL'; + this.loaded = false; + } } -} openInput(): void { @@ -104,4 +146,15 @@ export class SubstanceEditImportDialogComponent implements OnInit { return true; } + tabChanged(tabChangeEvent: MatTabChangeEvent) { + if (this.currentTab !== tabChangeEvent.index) { + this.currentTab = tabChangeEvent.index; + this.message = ''; + this.loaded = false; + this.record = ''; + this.pastedJSON = ''; + this.pastedUrl = ''; + this.filename = ''; + } + } } From 5d387825d204d80179204208571f8875ed4d6176 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Wed, 23 Apr 2025 11:46:21 +0200 Subject: [PATCH 33/48] PFDA Support email address loaded from config file --- src/app/core/base/pfda-toolbar/pfda-toolbar.component.html | 2 +- src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts | 2 ++ src/app/core/config/config.pfda.json | 2 +- src/app/fda/config/config.json | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 7f867f2b4..1d0177b47 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -46,7 +46,7 @@ (closed)="removeZindex()"> - +
Support
diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 7e922b555..60d727f5d 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -15,6 +15,7 @@ import { NavItem } from '@gsrs-core/config'; }) export class PfdaToolbarComponent implements OnInit { pfdaBaseUrl: string; + supportEmail: string; logoSrcPath: string; homeIconPath: string; auth?: Auth; @@ -43,6 +44,7 @@ export class PfdaToolbarComponent implements OnInit { const baseHref = this.configService.environment.baseHref || '/ginas/app/beta/'; this.logoSrcPath = `${baseHref}assets/images/pfda/pfda-logo.png`; this.homeIconPath = `${baseHref}assets/images/pfda/home.svg`; + this.supportEmail = this.configService.configData.contactEmail || 'fda-srs@fda.hhs.gov'; this.overlayContainer = this.overlayContainerService.getContainerElement(); diff --git a/src/app/core/config/config.pfda.json b/src/app/core/config/config.pfda.json index 54955437b..c036b5f06 100644 --- a/src/app/core/config/config.pfda.json +++ b/src/app/core/config/config.pfda.json @@ -532,7 +532,7 @@ "root_codes_CAS", "root_codes_ECHA\\ \\(EC\/EINECS\\)" ], - "contactEmail": "precisionfda-support@dnanexus.com", + "contactEmail": "fda-srs@fda.hhs.gov", "sessionExpirationWarning": { "extendSessionApiUrl": "/api/update_active", "maxSessionDurationMinutes": 15 diff --git a/src/app/fda/config/config.json b/src/app/fda/config/config.json index a16732a6c..4e365cfad 100644 --- a/src/app/fda/config/config.json +++ b/src/app/fda/config/config.json @@ -1,6 +1,6 @@ { "version": "3.1.1", - "contactEmail": "GSRSSupport@fda.hhs.gov", + "contactEmail": "fda-srs@fda.hhs.gov", "displayMatchApplication": "true", "adverseEventShinyHomepageDisplay": "true", "adverseEventShinySubstanceNameDisplay": "true", From c9ea49be502bf08d2310494d216e885dc18ddde4 Mon Sep 17 00:00:00 2001 From: Petr Barta Date: Tue, 6 May 2025 12:42:04 +0300 Subject: [PATCH 34/48] missing property fixed --- .../session-expiration-dialog.component.ts | 1 - .../substance-edit-import-dialog.component.ts | 45 ++++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts index 071c55c64..12d22c520 100644 --- a/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts +++ b/src/app/core/auth/session-expiration/session-expiration-dialog/session-expiration-dialog.component.ts @@ -80,7 +80,6 @@ export class SessionExpirationDialogComponent implements OnInit { if (this.configService.configData.isPfdaVersion) { this.authService.pfdaLogin().pipe( concatMap(success => { - console.log('success: ', success); if (success) { this.closeDialog(); return this.authService.getAuth(); diff --git a/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts b/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts index f52eed7e7..5f5895bca 100644 --- a/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts +++ b/src/app/core/substance-edit-import-dialog/substance-edit-import-dialog.component.ts @@ -110,28 +110,29 @@ export class SubstanceEditImportDialogComponent implements OnInit { } } - useFile() { - if (!this.uploaded && this.pastedJSON) { - const read = JSON.parse(this.pastedJSON); - // If there is no substanceClass field in Substance JSON data - if (!read['substanceClass']) { - // if JSON data is from non-substance entity, read the json from the textbox - if (read['id']) { - this.loaded = true; - this.record = this.pastedJSON; - this.message = ''; - } else { - this.message = 'Error: Invalid JSON format'; - this.loaded = false; - } - } else { - this.loaded = true; - this.record = this.pastedJSON; - this.message = ''; - } - - } - } + // Is this method still used anywhere? + // useFile() { + // if (!this.uploaded && this.pastedJSON) { + // const read = JSON.parse(this.pastedJSON); + // // If there is no substanceClass field in Substance JSON data + // if (!read['substanceClass']) { + // // if JSON data is from non-substance entity, read the json from the textbox + // if (read['id']) { + // this.loaded = true; + // this.record = this.pastedJSON; + // this.message = ''; + // } else { + // this.message = 'Error: Invalid JSON format'; + // this.loaded = false; + // } + // } else { + // this.loaded = true; + // this.record = this.pastedJSON; + // this.message = ''; + // } + // + // } + // } checkLoaded() { this.loaded = true; From e4baa10499b4ac62cc7b5ad707c48bba25428742 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 13 Aug 2025 12:14:39 +0200 Subject: [PATCH 35/48] update JSDraw license --- src/app/core/assets/jsdraw/Scilligence.JSDraw2.Pro.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/assets/jsdraw/Scilligence.JSDraw2.Pro.js b/src/app/core/assets/jsdraw/Scilligence.JSDraw2.Pro.js index b007bf5b5..a3fad8d9d 100644 --- a/src/app/core/assets/jsdraw/Scilligence.JSDraw2.Pro.js +++ b/src/app/core/assets/jsdraw/Scilligence.JSDraw2.Pro.js @@ -25,8 +25,8 @@ JSDraw2.password = { encrypt: true, key: null, iv: null }; // Place the license code below // Licensed to: FDA // Product: JSDraw -// Expiration Date: 2025-Jul-30 -JSDraw2.licensecode='405562538916781761723242424242424131213141512181'; +// Expiration Date: 2026-Jul-30 +JSDraw2.licensecode='405562537916781761723242424242424131213141512181'; ////////////////////////////////////////////////////////////////////////////////// From 0219f5627094f1b4526cea4878bfe21f06e4b368 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Thu, 20 Nov 2025 14:03:48 +0100 Subject: [PATCH 36/48] add: UUID generator PFDA-6362 --- src/app/core/substance-form/substance-form.component.html | 4 ++-- src/app/core/substance-form/substance-form.component.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/core/substance-form/substance-form.component.html b/src/app/core/substance-form/substance-form.component.html index aa021f55b..4c566991c 100644 --- a/src/app/core/substance-form/substance-form.component.html +++ b/src/app/core/substance-form/substance-form.component.html @@ -20,7 +20,7 @@ {{ showSubmissionMessages ? 'Hide' : 'Show' }} messages -
+
Advanced Features @@ -59,7 +59,7 @@ Switch primary and alt definitions - + Predict N-Glycosylation Sites diff --git a/src/app/core/substance-form/substance-form.component.ts b/src/app/core/substance-form/substance-form.component.ts index 6c1e357f7..9d1c47f9a 100644 --- a/src/app/core/substance-form/substance-form.component.ts +++ b/src/app/core/substance-form/substance-form.component.ts @@ -92,6 +92,7 @@ export class SubstanceFormComponent implements OnInit, AfterViewInit, OnDestroy isAdmin: boolean; isUpdater: boolean; messageField: string; + isPfdaVersion: boolean = false; uuid: string; substanceClass: string; drafts: Array; @@ -304,6 +305,7 @@ export class SubstanceFormComponent implements OnInit, AfterViewInit, OnDestroy } this.isAdmin = this.authService.hasRoles('admin'); this.isUpdater = this.authService.hasAnyRoles('Updater', 'SuperUpdater'); + this.isPfdaVersion = this.configService.configData.isPfdaVersion; this.overlayContainer = this.overlayContainerService.getContainerElement(); this.imported = false; if(this.location.path().includes('chemical-simplified')) { From db24848d96986121e494ce0c7e3bee1c20d6ac38 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 11 Mar 2026 13:28:45 +0100 Subject: [PATCH 37/48] rebrand pfda-toolbar to TRS --- src/app/core/assets/images/pfda/trs-logo.png | Bin 0 -> 37464 bytes .../base/pfda-toolbar/pfda-toolbar.component.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/app/core/assets/images/pfda/trs-logo.png diff --git a/src/app/core/assets/images/pfda/trs-logo.png b/src/app/core/assets/images/pfda/trs-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cc0d4a806a62e6764bc8c914ada85a4d7dfba258 GIT binary patch literal 37464 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 literal 0 HcmV?d00001 diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 60d727f5d..2777e6497 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -42,7 +42,7 @@ export class PfdaToolbarComponent implements OnInit { this.pfdaBaseUrl = this.configService.configData.pfdaBaseUrl || '/'; const baseHref = this.configService.environment.baseHref || '/ginas/app/beta/'; - this.logoSrcPath = `${baseHref}assets/images/pfda/pfda-logo.png`; + this.logoSrcPath = `${baseHref}assets/images/pfda/trs-logo.png`; this.homeIconPath = `${baseHref}assets/images/pfda/home.svg`; this.supportEmail = this.configService.configData.contactEmail || 'fda-srs@fda.hhs.gov'; From 4fff1e52b796f966d3f2ddb773c61446f72b0cab Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 11 Mar 2026 13:31:27 +0100 Subject: [PATCH 38/48] change default support email to TRS one --- src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 2777e6497..60e91e297 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -44,7 +44,7 @@ export class PfdaToolbarComponent implements OnInit { const baseHref = this.configService.environment.baseHref || '/ginas/app/beta/'; this.logoSrcPath = `${baseHref}assets/images/pfda/trs-logo.png`; this.homeIconPath = `${baseHref}assets/images/pfda/home.svg`; - this.supportEmail = this.configService.configData.contactEmail || 'fda-srs@fda.hhs.gov'; + this.supportEmail = this.configService.configData.contactEmail || 'trsc@dnanexus.com'; this.overlayContainer = this.overlayContainerService.getContainerElement(); From 5d0d7cafcbdec4725912beacc83a413ed8baa4a7 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Thu, 12 Mar 2026 14:26:36 +0100 Subject: [PATCH 39/48] update: make pfda toolbar logo configurable --- src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts | 3 ++- src/app/core/config/config.model.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 60e91e297..39f294013 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -42,7 +42,8 @@ export class PfdaToolbarComponent implements OnInit { this.pfdaBaseUrl = this.configService.configData.pfdaBaseUrl || '/'; const baseHref = this.configService.environment.baseHref || '/ginas/app/beta/'; - this.logoSrcPath = `${baseHref}assets/images/pfda/trs-logo.png`; + const logoFileName = this.configService.configData.pfdaLogoFileName || 'pfda-logo.png'; + this.logoSrcPath = `${baseHref}assets/images/pfda/${logoFileName}`; this.homeIconPath = `${baseHref}assets/images/pfda/home.svg`; this.supportEmail = this.configService.configData.contactEmail || 'trsc@dnanexus.com'; diff --git a/src/app/core/config/config.model.ts b/src/app/core/config/config.model.ts index cb397e490..8d91b8fdf 100644 --- a/src/app/core/config/config.model.ts +++ b/src/app/core/config/config.model.ts @@ -44,6 +44,7 @@ export interface Config { disableReferenceDocumentUpload?: boolean; externalSiteWarning?: ExternalSiteWarning; pfdaBaseUrl?: string; + pfdaLogoFileName?: string; homeDynamicLinks?: Array; registrarDynamicLinks?: Array; registrarDynamicLinks2?: Array; From 6183bc0e4277c867f54cdfb685b3d42549778b03 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Tue, 14 Apr 2026 14:46:08 +0200 Subject: [PATCH 40/48] update: layout of pFDA main page + pfda toolbar --- .../pfda-toolbar/pfda-toolbar.component.html | 19 +++- .../pfda-toolbar/pfda-toolbar.component.scss | 58 ++++++++++ .../pfda-toolbar/pfda-toolbar.component.ts | 8 +- src/app/core/home/home.component.html | 101 +++++++++--------- 4 files changed, 130 insertions(+), 56 deletions(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 1d0177b47..90b0b6f29 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -17,12 +17,21 @@
-
-
- -
GSRS
+
diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index d5e1fc63f..4e6d17e44 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -87,3 +87,61 @@ $screenMedium: 1045px; white-space: nowrap; text-overflow: ellipsis; } + +.gsrs-menu-trigger { + position: relative; + display: inline-block; + + .gsrs-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 1001; + background: #fff; + min-width: 112px; + max-width: 280px; + border-radius: 4px; + box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); + padding: 0; + + a { + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; + color: rgba(0,0,0,0.87); + text-decoration: none; + white-space: nowrap; + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 48px; + cursor: pointer; + box-sizing: border-box; + + &:hover { + background: rgba(0,0,0,0.04); + } + } + } + + &:hover .gsrs-dropdown { + display: block; + } +} + + +::ng-deep .reg-a { + .mat-mdc-menu-content { + padding: 0; + width: 100%; + box-sizing: border-box; + } + + .mat-mdc-menu-item { + width: 100%; + box-sizing: border-box; + padding: 0 16px; + } +} diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts index 39f294013..97ff539a6 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { Router, ActivatedRoute, NavigationExtras } from '@angular/router'; import { ConfigService } from '../../config/config.service'; import { OverlayContainer } from '@angular/cdk/overlay'; @@ -13,7 +13,7 @@ import { NavItem } from '@gsrs-core/config'; templateUrl: './pfda-toolbar.component.html', styleUrls: ['./pfda-toolbar.component.scss'] }) -export class PfdaToolbarComponent implements OnInit { +export class PfdaToolbarComponent implements OnInit, OnDestroy { pfdaBaseUrl: string; supportEmail: string; logoSrcPath: string; @@ -101,4 +101,8 @@ export class PfdaToolbarComponent implements OnInit { logout(): void { this.authService.logout(); } + + ngOnDestroy(): void { + this.subscriptions.forEach(sub => sub.unsubscribe()); + } } diff --git a/src/app/core/home/home.component.html b/src/app/core/home/home.component.html index b8d9e65fa..69fab3579 100644 --- a/src/app/core/home/home.component.html +++ b/src/app/core/home/home.component.html @@ -26,55 +26,58 @@ - - - Structure Search - - - - - Sequence Search - - - - - Advanced Search - - - - - - - - Other - - - - - - - - - Browse Applications - - - - - - - Browse Products - - - - - - - Browse Clinical Trials - - - - From ae4252cafe7862bf0b0dfe9d51185e81ea337e67 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 15 Apr 2026 12:24:38 +0200 Subject: [PATCH 41/48] update z index of dropdown --- .../core/base/pfda-toolbar/pfda-toolbar.component.html | 2 +- .../core/base/pfda-toolbar/pfda-toolbar.component.scss | 2 +- src/app/core/home/home.component.html | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 90b0b6f29..1a739eeac 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -28,8 +28,8 @@ Browse Substances Structure Search Sequence Search - Advanced Search Bulk Search + Advanced Search
diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index 4e6d17e44..b24eacaba 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -97,7 +97,7 @@ $screenMedium: 1045px; position: absolute; top: 100%; left: 0; - z-index: 1001; + z-index: 1002; background: #fff; min-width: 112px; max-width: 280px; diff --git a/src/app/core/home/home.component.html b/src/app/core/home/home.component.html index 69fab3579..e27306813 100644 --- a/src/app/core/home/home.component.html +++ b/src/app/core/home/home.component.html @@ -36,15 +36,15 @@ Sequence Search - - - Advanced Search - - Bulk Search + + + + Advanced Search + From 6d65c8ec9d2ac0127783e3fef57f446753e262e8 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 15 Apr 2026 16:09:47 +0200 Subject: [PATCH 42/48] update z-index of header --- src/app/core/base/base.component.scss | 2 +- src/styles/_material-overrides.scss | 1665 +++++++++++++++++++++++++ 2 files changed, 1666 insertions(+), 1 deletion(-) create mode 100644 src/styles/_material-overrides.scss diff --git a/src/app/core/base/base.component.scss b/src/app/core/base/base.component.scss index 8dca2150c..35ff366e1 100644 --- a/src/app/core/base/base.component.scss +++ b/src/app/core/base/base.component.scss @@ -111,7 +111,7 @@ .mat-toolbar { position: fixed; top: 0; - z-index: 1001; + z-index: 1002; } .logo { diff --git a/src/styles/_material-overrides.scss b/src/styles/_material-overrides.scss new file mode 100644 index 000000000..dac5b3ac6 --- /dev/null +++ b/src/styles/_material-overrides.scss @@ -0,0 +1,1665 @@ +/** + * Global Angular Material Overrides + * + * Project-wide overrides for Angular Material (MDC) components. + * Loaded last in main.scss so these rules take precedence over Material's + * generated theme styles. + */ + +// ============================================================================ +// MAT-CARD FIXES +// ============================================================================ + +// Fix MDC card padding to match legacy behavior +.mat-mdc-card { + // Angular 15 MDC cards have padding: 16px by default + // Ensure consistency with Angular 14 legacy cards + padding: 16px; + margin: 0 auto 20px auto; + max-width: 1228px; + width: 100%; + box-sizing: border-box; + + &:not([class*="mat-elevation-z"]) { + box-shadow: + 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12); + } +} + +// Fix MDC card header to match legacy +.mat-mdc-card-header { + display: flex; + padding: 0; +} + +// Fix MDC card title spacing +.mat-mdc-card-title { + font-size: 24px; + font-weight: 500; + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +// Fix MDC card subtitle +.mat-mdc-card-subtitle { + margin-top: 0 !important; + margin-bottom: 12px !important; +} + +// Fix MDC card content spacing +.mat-mdc-card-content { + display: block; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } +} + +// ============================================================================ +// MAT-CHIP FIXES +// ============================================================================ + +// Fix chip styling for consistency with Angular 14 +.mat-mdc-chip { + &.mat-mdc-standard-chip { + min-height: 32px; + --mdc-chip-container-height: 32px; + } + + .mdc-evolution-chip__action--primary { + padding-left: 12px; + padding-right: 12px; + } + + .mdc-evolution-chip__text-label { + font-size: 14px; + } +} + +.mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) { + --mat-chip-elevated-container-color: var(--primary-color); + --mat-chip-label-text-color: #fff; /* optional */ +} + +// Chip spacing for both chip-set and chip-listbox (deprecated) +.mat-mdc-chip-set .mat-mdc-chip, +.mat-mdc-chip-listbox .mat-mdc-chip { + margin: 4px; +} + +// ============================================================================ +// MAT-FORM-FIELD FIXES +// ============================================================================ + +// MDC form fields have different default appearance and structure +.mat-mdc-form-field { + // Match Angular 14 form field appearance + font-family: Roboto, "Helvetica Neue", sans-serif !important; + // font-size: 14px !important; + // line-height: 1.125 !important; + + // Fix the wrapper to not add extra spacing + .mat-mdc-text-field-wrapper { + padding-bottom: 0; + background-color: transparent; + + .mat-mdc-form-field-flex { + align-items: center; + } + } + + // Fix fill appearance to match legacy + &.mat-form-field-appearance-fill { + .mat-mdc-text-field-wrapper { + padding-bottom: 0; + } + + .mdc-text-field { + background-color: transparent; + border-radius: 4px 4px 0 0; + // padding: 0 12px; + } + + .mdc-text-field--filled { + &:not(.mdc-text-field--disabled) { + background-color: transparent; + } + + .mdc-line-ripple::after { + border-bottom-width: 2px; + } + } + + // Fix infix padding + .mat-mdc-form-field-infix { + min-height: auto; + // padding: 25px 0 0.4375em 0; + } + + // Fix label positioning + .mat-mdc-floating-label { + top: 28px; + } + } + + // Fix outline appearance + &.mat-form-field-appearance-outline { + .mdc-text-field { + padding: 0; + } + + .mdc-text-field--outlined { + .mdc-notched-outline { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: rgba(0, 0, 0, 0.38); + border-width: 1px; + } + } + + &:not(.mdc-text-field--disabled) { + &:hover .mdc-notched-outline { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: rgba(0, 0, 0, 0.87); + } + } + } + } + + .mat-mdc-form-field-infix { + padding-top: 16px; + padding-bottom: 16px; + } + + .mat-mdc-floating-label { + top: 28px; + } + } + + // Fix input and label alignment + .mat-mdc-input-element { + font: inherit; + } + + // Fix label styling + .mat-mdc-floating-label { + font-size: 14px; + font-weight: 400; + } + + // Beat Material's runtime: .mdc-text-field--filled .mdc-floating-label { font-size: Xrem } + // That rule has specificity (0,2,0). Adding .mat-mdc-form-field parent gives us (0,3,0). + .mdc-text-field .mdc-floating-label { + font-size: var(--floating-label-font-size, 14px); + } + + // Fix subscript wrapper (hints and errors) + .mat-mdc-form-field-subscript-wrapper { + font-size: 12px; + margin-top: 0.66667em; + padding: 0; + + .mat-mdc-form-field-hint-wrapper, + .mat-mdc-form-field-error-wrapper { + padding: 0; + } + } + + // Fix bottom spacing + .mat-mdc-form-field-bottom-align::before { + content: none; + } + + // Fix icon button inside form field + .mat-mdc-icon-button { + width: 36px; + height: 36px; + + .mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + // Fix prefix and suffix icon alignment + .mat-mdc-form-field-icon-prefix, + .mat-mdc-form-field-icon-suffix { + display: inline-flex; + align-items: center; + justify-content: center; + + .mat-icon { + display: flex; + align-items: center; + justify-content: center; + } + } +} + +// Legacy form field icon fixes +.mat-form-field-prefix, +.mat-form-field-suffix { + .mat-icon, + .mat-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +// ============================================================================ +// MAT-BUTTON FIXES +// ============================================================================ + +// Fix button styling for consistency with Angular 14 +.mat-mdc-button .mdc-button__label, +.mat-mdc-raised-button .mdc-button__label, +.mat-mdc-unelevated-button .mdc-button__label, +.mat-mdc-outlined-button .mdc-button__label { + white-space: nowrap; +} +.mat-mdc-button, +.mat-mdc-raised-button, +.mat-mdc-unelevated-button, +.mat-mdc-outlined-button { + // Match Angular 14 button heights and appearance + --mdc-text-button-container-height: 36px; + --mdc-filled-button-container-height: 36px; + --mdc-outlined-button-container-height: 36px; + --mdc-protected-button-container-height: 36px; + + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 36px; + min-width: 64px; + padding: 0 16px; + + // CRITICAL: Ensure button content respects DOM order + display: inline-flex; + flex-direction: row; + align-items: center; + + // .mdc-button__label { + // font-size: 16px; + // font-weight: 500; + // line-height: normal; + // display: flex; + // flex-direction: row; + // align-items: center; + // order: 0; // Ensure label stays in natural order + // } + + // Ensure mat-icons stay in their HTML order + .mat-icon { + order: 0; // Don't reorder + } + + // Fix the persistent ripple that can cause visual issues + .mat-mdc-button-persistent-ripple { + border-radius: 4px; + } + + // Ensure touch target doesn't break layout + .mat-mdc-button-touch-target { + height: 100%; + } +} + +// Fix raised button elevation +.mat-mdc-raised-button:not(:disabled) { + box-shadow: + 0px 3px 1px -2px rgba(0, 0, 0, 0.2), + 0px 2px 2px 0px rgba(0, 0, 0, 0.14), + 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + + &:hover { + box-shadow: + 0px 2px 4px -1px rgba(0, 0, 0, 0.2), + 0px 4px 5px 0px rgba(0, 0, 0, 0.14), + 0px 1px 10px 0px rgba(0, 0, 0, 0.12); + } +} + +// Fix flat button (now called unelevated in MDC) +.mat-mdc-unelevated-button { + --mdc-filled-button-container-height: 36px; +} + +// Ensure icon buttons have consistent size +.mat-mdc-icon-button { + --mdc-icon-button-state-layer-size: 40px; + width: 40px; + height: 40px; + padding: 8px; + line-height: 24px; + + .mat-mdc-button-touch-target { + width: 48px; + height: 48px; + } + + .mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + line-height: 24px; + } +} + +// Fix FAB button +.mat-mdc-fab, +.mat-mdc-mini-fab { + .mat-mdc-button-touch-target { + width: 100%; + height: 100%; + } +} + +// ============================================================================ +// MAT-ICON FIXES +// ============================================================================ + +// Fix icon sizing and alignment +.mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + line-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + flex-shrink: 0; + + // Critical: Fix SVG positioning to center + svg { + width: 100%; + height: 100%; + fill: currentColor; + display: block; + margin: auto; + } +} + +// Specific fix for mat-icons inside buttons +button .mat-icon, +.mat-button .mat-icon, +.mat-raised-button .mat-icon, +.mat-flat-button .mat-icon, +.mat-stroked-button .mat-icon, +.mat-mdc-button .mat-icon, +.mat-mdc-raised-button .mat-icon, +.mat-mdc-unelevated-button .mat-icon, +.mat-mdc-outlined-button .mat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; +} + +// Fix icon button sizing (already covered above but ensure consistency) +.mat-mdc-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + + .mat-icon { + position: relative; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + } + + .mat-mdc-button-touch-target { + position: absolute; + } +} + +.mat-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + + .mat-icon { + position: relative; + margin: auto; + } +} + +// ============================================================================ +// MAT-MENU FIXES +// ============================================================================ + +// Fix menu panel +.mat-mdc-menu-panel { + min-width: 112px; + max-width: 280px; + border-radius: 4px; + box-shadow: + 0px 2px 4px -1px rgba(0, 0, 0, 0.2), + 0px 4px 5px 0px rgba(0, 0, 0, 0.14), + 0px 1px 10px 0px rgba(0, 0, 0, 0.12); +} + +.mat-mdc-menu-content { + padding: 0; + width: 100%; + box-sizing: border-box; + + // Ensure all menu items have consistent vertical rhythm + > * { + margin: 0 !important; + display: block !important; + } + + // Fix for wrapper divs (common pattern but not recommended) + > div { + display: contents !important; + margin: 0 !important; + padding: 0 !important; + height: auto !important; + } +} + +// Fix menu item styling +.mat-mdc-menu-item { + font-family: Roboto, "Helvetica Neue", sans-serif !important; + font-size: 14px !important; + font-weight: 400 !important; + min-height: 48px !important; + height: 48px !important; + padding: 0 16px !important; + display: flex !important; + align-items: center !important; + position: relative !important; + pointer-events: auto !important; + cursor: pointer !important; + width: 100% !important; + box-sizing: border-box !important; + margin: 0 !important; + line-height: 48px !important; + + .mat-icon { + margin-right: 16px; + line-height: normal !important; + } + + .mat-mdc-menu-item-text { + flex-grow: 1; + line-height: normal !important; + } + + // Ensure the MDC button inside menu item is clickable + .mat-mdc-menu-item-text, + .mdc-list-item__primary-text { + pointer-events: auto !important; + line-height: normal !important; + } + + // Fix for anchor tag menu items + &[href] { + pointer-events: auto !important; + cursor: pointer !important; + } + + // Fix internal content wrapper that Angular Material adds + .mdc-list-item__content { + display: flex !important; + align-items: center !important; + height: 48px !important; + padding: 0 !important; + margin: 0 !important; + } +} + +// Additional fixes for submenu trigger menu items (menu items with matMenuTriggerFor) +// The base .mat-mdc-menu-item rule above handles most of it, but ensure no overrides +.mat-mdc-menu-item.mat-mdc-menu-trigger, +a.mat-mdc-menu-item[ng-reflect-menu], +button.mat-mdc-menu-item[ng-reflect-menu], +.mat-mdc-menu-item.cdk-menu-trigger { + // Ensure submenu triggers don't have any extra spacing + vertical-align: middle !important; + + // Ensure the submenu indicator icon aligns properly + &::after { + line-height: normal !important; + } +} + +// Fix for nested menu positioning in Angular Material 19 MDC +// Ensure the CDK overlay positioning works correctly +.cdk-overlay-connected-position-bounding-box { + // Don't interfere with the calculated position + .mat-mdc-menu-panel { + // Ensure nested menus align properly with their trigger + &.mat-mdc-menu-nested { + margin-top: 0 !important; + } + } +} + +// ============================================================================ +// MAT-LIST FIXES +// ============================================================================ + +.mat-mdc-list, +.mat-mdc-list-base { + padding: 8px 0; + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +.mat-mdc-list-item { + font-size: 14px; + font-weight: 400; + height: 48px; + + .mdc-list-item__primary-text { + font-size: 14px; + font-weight: 400; + color: var(--link-color); + } + + .mdc-list-item__secondary-text { + font-size: 12px; + font-weight: 400; + } + + // Prevent mat-icons from inheriting link-color from primary text + .mat-icon { + color: var(--mat-list-color); + } +} + +.mat-mdc-list-item-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 16px; +} + +.mat-mdc-list-item-icon { + width: 24px; + height: 24px; + font-size: 24px; + margin-right: 16px; +} + +// Two-line list items +.mat-mdc-list-item.mdc-list-item--with-two-lines { + height: 64px; +} + +// Three-line list items +.mat-mdc-list-item.mdc-list-item--with-three-lines { + height: 88px; +} + +// ============================================================================ +// MAT-TABLE FIXES +// ============================================================================ + +// Fix table styling for consistency with Angular 14 +.mat-mdc-table { + background-color: inherit; + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +.mat-mdc-header-row { + min-height: 56px; +} + +.mat-mdc-row { + min-height: 48px; +} + +.mat-mdc-header-cell { + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); +} + +.mat-mdc-cell { + font-size: 14px; + color: rgba(0, 0, 0, 0.87); +} + +// ============================================================================ +// MAT-PAGINATOR FIXES +// ============================================================================ + +// Fix paginator styling to match Angular 14 +.mat-mdc-paginator { + background-color: transparent; + display: block; + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +.mat-mdc-paginator-container { + display: flex; + align-items: center; + justify-content: flex-end; + min-height: 56px; + padding: 0 8px; +} + +.mat-mdc-paginator-page-size { + display: flex; + align-items: center; +} + +.mat-mdc-paginator-range-label { + margin: 0 32px 0 24px; +} + +.mat-mdc-paginator-page-size .mdc-notched-outline__leading, +.mat-mdc-paginator-page-size .mdc-notched-outline__trailing, +.mat-mdc-paginator-page-size .mdc-notched-outline__notch { + border-bottom: 1px solid currentColor !important; + border-radius: 0 !important; +} + +/* Adjust the overall infix height if needed, as padding affects height */ +.mat-mdc-paginator-page-size .mat-mdc-form-field-infix { + padding: 5px 0 !important; + min-height: auto !important; /* Ensure min-height does not enforce extra space */ +} + +/* Adjust the select's value text container height/line-height for vertical alignment */ +.mat-mdc-paginator-page-size .mat-mdc-select-value-text { + line-height: unset !important; + display: flex; + align-items: center; +} + +// ============================================================================ +// PAGE SELECTOR (GLOBAL) +// ============================================================================ + +// Page selector styles used across browse components +.page-selector { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 20px; +} + +.page-label { + color: var(--dark-label-color); + display: block; + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + padding-right: 12px; +} + +// Page selector form field adjustments +// Increase specificity to override the general mat-form-field-appearance-fill styles above +.page-selector > .mat-mdc-form-field.mat-form-field-appearance-fill { + .mat-mdc-form-field-infix { + min-height: auto; + padding: 10px 0 0 0 !important; + } + + .mdc-text-field { + padding: 0; + } +} + +// Responsive behavior - hide page selector on small screens +@media (max-width: 730px) { + .page-selector { + display: none !important; + } +} + +// ============================================================================ +// MAT-SELECT FIXES +// ============================================================================ + +// Fix select styling to match Angular 14 +.mat-mdc-select { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; +} + +.mat-mdc-select-value { + font-size: 14px; +} + +.mat-mdc-select-trigger { + height: auto; +} + +.mat-mdc-select-panel { + max-height: 256px; +} + +.mat-mdc-option { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + min-height: 48px; + + .mdc-list-item__primary-text { + font-size: 14px !important; + } +} + +// ============================================================================ +// MAT-CHECKBOX FIXES +// ============================================================================ + +.mat-mdc-checkbox { + --mdc-checkbox-state-layer-size: 40px; + + .mdc-checkbox { + padding: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); + } + + .mdc-checkbox__background { + width: 18px; + height: 18px; + top: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); + left: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); + } + + .mdc-form-field { + font-size: 14px; + } +} + +// ============================================================================ +// MAT-RADIO FIXES +// ============================================================================ + +.mat-mdc-radio-button { + .mdc-radio { + padding: 10px; + } + + .mdc-form-field { + font-size: 14px; + } +} + +// ============================================================================ +// MAT-EXPANSION-PANEL FIXES +// ============================================================================ + +.mat-expansion-panel { + box-shadow: + 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12); +} + +.mat-expansion-panel-header { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + height: 48px; +} + +.mat-expansion-panel-content { + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +// ============================================================================ +// MAT-DIALOG FIXES +// ============================================================================ + +.mat-mdc-dialog-container { + --mat-dialog-supporting-text-color: black; + + .mdc-dialog__surface { + border-radius: 4px; + padding: 24px; + } +} + +// User Edit dialog +.user-edit-dialog { + .mat-mdc-dialog-title { + font-size: 40px !important; + font-weight: bold !important; + } + + .mat-mdc-dialog-content { + padding: 10px 24px !important; + max-height: none !important; + overflow-y: unset !important; + } + + .mat-mdc-dialog-container, + .mdc-dialog__surface { + height: unset !important; + max-height: 90vh !important; + overflow-y: auto !important; + } +} + +// Cross Entity Search dialog — MDC caps surface at 560px by default; override per panel class +.cross-entity-search-dialog { + .mdc-dialog__surface { + max-width: none !important; + } +} + +// Advanced Selector dialog — override MDC's 560px max-width CSS variable at the pane level, +// which is where var(--mat-dialog-container-max-width, 560px) is resolved. Children that use +// max-width: inherit will then inherit none instead of 560px. +.advanced-selector-dialog { + --mat-dialog-container-max-width: none; + + .mat-mdc-dialog-inner-container, + .mdc-dialog__surface { + max-width: none !important; + } +} + +.mat-mdc-dialog-title { + font-size: 20px; + font-weight: 500; + margin: 0 0 16px; + padding: 24px 24px 0; +} + +.mat-mdc-dialog-content { + font-size: 14px; + padding: 0 24px; +} + +.mat-mdc-dialog-inner-container { + height: fit-content !important; + max-height: 90vh !important; + overflow-y: auto !important; +} + +.mat-mdc-dialog-actions { + padding: 0 24px 8px 24px !important; + min-height: 52px; +} + +// ============================================================================ +// MAT-TAB FIXES +// ============================================================================ + +.mat-mdc-tab-group { + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +.mat-mdc-tab-list { + flex-grow: 0 !important; +} + +.mat-mdc-tab { + font-size: 14px; + font-weight: 500; + min-width: 160px; + height: 48px; +} + +.mat-mdc-tab-list .mat-mdc-tab, +.mat-tab-list .mat-tab-label { + letter-spacing: normal; + + .mdc-tab__text-label { + letter-spacing: normal; + line-height: 1.3; + } +} + +.mat-mdc-tab-body-content { + padding: 16px 0; +} + +// Admin tab group: 18px tab labels (beats Material runtime at 0,2,0) +.tab-group .mat-mdc-tab .mdc-tab__text-label { + font-size: 18px; +} + +// ============================================================================ +// MAT-SLIDER FIXES +// ============================================================================ + +.mat-mdc-slider { + .mdc-slider__track { + height: 2px; + } + + .mdc-slider__thumb-knob { + width: 12px; + height: 12px; + } +} + +// ============================================================================ +// MAT-PROGRESS-BAR FIXES +// ============================================================================ + +.mat-mdc-progress-bar { + --mdc-linear-progress-track-height: 4px; +} + +// ============================================================================ +// MAT-PROGRESS-SPINNER FIXES +// ============================================================================ + +.mat-mdc-progress-spinner { + circle { + stroke-width: 10%; + } +} + +// Scoped to app-loading only — prevents ALL spinners from being fixed/centered +app-loading .mat-mdc-progress-spinner { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1003; + display: block; +} + +// ============================================================================ +// MAT-TOOLTIP FIXES +// ============================================================================ + +.mat-mdc-tooltip { + .mdc-tooltip__surface { + font-size: 10px; + padding: 4px 8px; + max-width: 200px; + } +} + +// ============================================================================ +// MAT-SNACKBAR FIXES +// ============================================================================ + +.mat-mdc-snack-bar-container { + .mdc-snackbar__surface { + background-color: #323232; + } + + .mdc-snackbar__label { + color: white; + font-size: 14px; + } +} + +// ============================================================================ +// MAT-TOOLBAR FIXES +// ============================================================================ + +.mat-toolbar, +.mat-mdc-toolbar { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + font-weight: 400; + display: flex; + box-sizing: border-box; + width: 100%; + flex-direction: row; + align-items: center; + white-space: nowrap; + padding: 0 16px; + min-height: 64px; + position: relative; + + // Single row toolbar (default) + &:not(.mat-toolbar-multiple-rows) { + flex-direction: row; + height: 64px; + background-color: var(--primary-color); + position: fixed; + top: 0; + z-index: 1002; + + // @media (max-width: 990px) { + // height: auto; + // flex-wrap: wrap; + // } + } + + // Multiple rows toolbar + &.mat-toolbar-multiple-rows { + flex-direction: column; + min-height: 64px; + } +} + +// Fix toolbar row heights to match Angular 14 +.mat-toolbar-row, +.mat-mdc-toolbar-row { + display: flex; + box-sizing: border-box; + width: 100%; + height: 64px; + flex-direction: row; + align-items: center; + white-space: nowrap; + padding: 0 16px; +} + +.mat-toolbar-single-row, +.mat-mdc-toolbar-single-row { + display: flex; + box-sizing: border-box; + width: 100%; + height: 64px; + flex-direction: row; + align-items: center; + white-space: nowrap; + padding: 0 16px; +} + +// Fix spacer/middle-fill pattern (flex-grow to push items to sides) +.mat-toolbar .middle-fill, +.mat-mdc-toolbar .middle-fill, +.mat-toolbar .spacer, +.mat-mdc-toolbar .spacer, +.mat-toolbar .fill, +.mat-mdc-toolbar .fill { + flex: 1 1 auto; +} + +// ============================================================================ +// RESPONSIVE TOOLBAR ADJUSTMENTS +// ============================================================================ + +// Tablet/Mobile: Hide most navigation buttons at 1350px, show only Logo, Menu, Search, and Login +// Note: scoped to :not(.pfda-toolbar) to avoid hiding pfda-toolbar buttons +@media (max-width: 1350px) { + .mat-toolbar:not(.pfda-toolbar), + .mat-mdc-toolbar:not(.pfda-toolbar) { + // Logo container - always visible + > .logo-container { + display: flex !important; + } + + // Menu button (nav-small span) - always visible + > span.nav-small { + display: inline-block !important; + } + + // Hide all div containers EXCEPT logo-container and the one containing login + > div { + // Hide by default + display: none !important; + + // But keep logo container visible + &.logo-container { + display: flex !important; + } + + // Keep the div that contains login button or logged-in user visible + &:has(.login-link), + &:has(.logged-in) { + display: block !important; + } + + // If logged-in is inside, ensure it displays properly + .logged-in { + display: flex !important; + } + } + + // Middle-fill spacer - keep visible + > span.middle-fill { + display: block !important; + flex: 1 1 auto; + } + + // Search component - keep visible and maintain flex behavior + > app-substance-text-search { + display: block !important; + flex-grow: 1; + max-width: 600px; + } + + // Classic view container - hide + .classic-view-container { + display: none !important; + } + } +} + +// ============================================================================ +// MAT-SIDENAV FIXES +// ============================================================================ + +.mat-drawer-container, +.mat-sidenav-container { + background-color: inherit; + color: inherit; +} + +.mat-drawer, +.mat-sidenav { + box-shadow: + 0px 8px 10px -5px rgba(0, 0, 0, 0.2), + 0px 16px 24px 2px rgba(0, 0, 0, 0.14), + 0px 6px 30px 5px rgba(0, 0, 0, 0.12); + background-color: white; +} + +.mat-drawer-backdrop, +.mat-sidenav-backdrop { + background-color: rgba(0, 0, 0, 0.6); +} + +.mat-drawer-content, +.mat-sidenav-content { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +// Fix sidenav positioning and transitions +.mat-drawer-side { + border-right: solid 1px rgba(0, 0, 0, 0.12); +} + +.mat-drawer.mat-drawer-side { + z-index: 2; +} + +// Global export button styles in sidenav content +mat-sidenav-content .controls-container .export-button { + color: var(--regular-black-color); + background-color: white; + border-radius: 4px; + box-shadow: + 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 2px 2px rgba(0, 0, 0, 0.1411764706), + 0 1px 5px rgba(0, 0, 0, 0.1215686275); + padding: 16px 16px; +} + +button.mat-mdc-button.export-button > .mat-icon, +button.mat-mdc-button.export-button .mat-icon, +.mat-icon[data-mat-icon-name="chevron_down"] { + height: 24px !important; + width: 24px !important; + font-size: 24px !important; + line-height: 24px !important; +} + +// ============================================================================ +// GLOBAL BUTTON ICON SIZING + SPACING (MDC) +// ============================================================================ + +// Apply to "text buttons" (buttons that can have icon + label) +button.mat-mdc-button, +button.mat-mdc-raised-button, +button.mat-mdc-unelevated-button, +button.mat-mdc-outlined-button { + .mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + line-height: 24px; + + // space between icon and label + margin-right: 6px; + margin-left: 0; + } + + // If svgIcon renders sizing on the SVG element + .mat-icon svg { + width: var(--button-icon-svg-size, 24px); + height: var(--button-icon-svg-size, 24px); + } +} + +// Do NOT add label spacing to icon-only buttons +button.mat-mdc-icon-button .mat-icon { + margin-right: 0; +} + +// ============================================================================ +// EXPORT BUTTON ONLY: keep svg mat-icon LEFT of label + spacing +// ============================================================================ + +button.export-button { + // Only for SVG icons inside export buttons + .mat-icon[data-mat-icon-type="svg"] { + order: 0 !important; + margin-right: 6px !important; + margin-left: 0 !important; + + width: 24px; + height: 24px; + + svg { + width: 24px; + height: 24px; + } + } + + // Ensure label stays after the icon + .mdc-button__label { + order: 1 !important; + display: inline-flex; + align-items: center; + } +} + +// button.mat-mdc-button >.mat-icon[data-mat-icon-name="chevron_down"]{ +// height: 24px !important; +// width: 24px !important; +// font-size: 24px !important; +// line-height: 24px !important; +// } + +// ============================================================================ +// GENERAL MDC FIXES +// ============================================================================ + +// Fix elevation classes if needed +.mat-elevation-z0 { + box-shadow: none; +} + +.mat-elevation-z1 { + box-shadow: + 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12); +} + +.mat-elevation-z2 { + box-shadow: + 0px 3px 1px -2px rgba(0, 0, 0, 0.2), + 0px 2px 2px 0px rgba(0, 0, 0, 0.14), + 0px 1px 5px 0px rgba(0, 0, 0, 0.12); +} + +.mat-elevation-z3 { + box-shadow: + 0px 3px 3px -2px rgba(0, 0, 0, 0.2), + 0px 3px 4px 0px rgba(0, 0, 0, 0.14), + 0px 1px 8px 0px rgba(0, 0, 0, 0.12); +} + +.mat-elevation-z4 { + box-shadow: + 0px 2px 4px -1px rgba(0, 0, 0, 0.2), + 0px 4px 5px 0px rgba(0, 0, 0, 0.14), + 0px 1px 10px 0px rgba(0, 0, 0, 0.12); +} + +.mat-elevation-z6 { + box-shadow: + 0px 3px 5px -1px rgba(0, 0, 0, 0.2), + 0px 6px 10px 0px rgba(0, 0, 0, 0.14), + 0px 1px 18px 0px rgba(0, 0, 0, 0.12); +} + +.mat-elevation-z8 { + box-shadow: + 0px 5px 5px -3px rgba(0, 0, 0, 0.2), + 0px 8px 10px 1px rgba(0, 0, 0, 0.14), + 0px 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +// Override MDC ripple behavior if it interferes with existing styles +.mat-mdc-button-ripple, +.mat-mdc-icon-button-ripple, +.mat-ripple, +.mat-mdc-button-persistent-ripple { + position: absolute; + pointer-events: none; +} + +// Ensure proper focus indicators +.mat-mdc-focus-indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border: 2px solid transparent; +} + +// Fix button touch targets that can cause layout issues +.mat-mdc-button-touch-target { + position: absolute; + top: 50%; + height: 48px; + left: 0; + right: 0; + transform: translateY(-50%); +} + +// ============================================================================ +// SPECIFIC COMPONENT FIXES +// ============================================================================ + +// Fix for search buttons in substance-text-search component +.search-button, +.close-button, +.activate-search-button { + &[mat-icon-button] { + // Ensure these buttons work properly with icon centering + .mat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + } +} + +// Fix for login button +.login-link { + &[mat-button] { + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +// Fix for user account buttons +.user-button { + &[mat-icon-button] { + // These have custom sizing, ensure icons still center + .mat-icon, + .user-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + } +} + +// ============================================================================ +// COMPREHENSIVE ICON CENTERING FIX +// ============================================================================ +// This ensures all mat-icons are properly centered regardless of context + +// Force all icon containers to use flexbox centering +button[mat-icon-button], +button[mat-mini-fab], +button[mat-fab], +a[mat-icon-button], +a[mat-mini-fab], +a[mat-fab], +.mat-icon-button, +.mat-mdc-icon-button, +.mat-mini-fab, +.mat-mdc-mini-fab, +.mat-fab, +.mat-mdc-fab { + display: inline-flex; + align-items: center !important; + justify-content: center !important; + + .mat-icon { + position: relative !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + // Don't force margin to 0 - allow component-specific margins + } + + // Ensure SVG icons are centered within the mat-icon + .mat-icon svg, + svg { + position: relative; + left: auto; + right: auto; + top: auto; + bottom: auto; + transform: none; + display: block; + margin: auto; + } +} + +// Exception: substance-hierarchy tree toggle buttons — keep icon in static flow +.tree-button.mat-mdc-icon-button .mat-icon { + position: static !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + margin: 0 !important; + transform: none !important; +} + +// Fix for icons in regular buttons (not icon buttons) +.mat-button, +.mat-raised-button, +.mat-flat-button, +.mat-stroked-button, +.mat-mdc-button, +.mat-mdc-raised-button, +.mat-mdc-unelevated-button, +.mat-mdc-outlined-button { + // Override Angular Material's DOM reordering + &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { + // CRITICAL: Angular Material wraps content in .mdc-button__label + // and places mat-icon OUTSIDE of it, but before it in the DOM + // We need to visually reorder them + + display: inline-flex !important; + flex-direction: row !important; + align-items: center !important; + + // The mat-icon appears FIRST in DOM (Angular moves it) + > .mat-icon { + order: 10 !important; // Force it to appear LAST visually + // margin-left: 8px !important; + margin-right: 0 !important; + vertical-align: middle; + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + vertical-align: top; + } + } + + // The .mdc-button__label contains the text content + > .mdc-button__label { + order: 1 !important; // Make it appear FIRST visually + display: inline-flex; + align-items: center; + + // // If there's a mat-icon inside the label (shouldn't happen, but just in case) + // > .mat-icon { + // margin-left: 8px; + // order: inherit; + // } + } + + // Handle ripple and touch target (they should be positioned absolutely) + > .mat-mdc-button-persistent-ripple, + > .mat-mdc-button-ripple, + > .mat-mdc-button-touch-target, + > .mat-mdc-focus-indicator { + order: 0 !important; + position: absolute !important; + } + } +} + +// ============================================================================ +// EXPORT BUTTON: put SVG icon left, keep dropdown icon right +// (Matches the specificity of the global reorder rule) +// ============================================================================ + +.mat-mdc-button.export-button, +.mat-mdc-raised-button.export-button, +.mat-mdc-unelevated-button.export-button, +.mat-mdc-outlined-button.export-button { + &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { + display: inline-flex !important; + flex-direction: row !important; + align-items: center !important; + + // SVG icon (get_app) must be LEFT of label + > .mat-icon[data-mat-icon-type="svg"] { + order: 0 !important; + margin-right: 6px !important; + margin-left: 0 !important; + } + + // Label in the middle + > .mdc-button__label { + order: 1 !important; + } + + // Font icon (arrow_drop_down) stays on the RIGHT + > .mat-icon[data-mat-icon-type="font"] { + order: 2 !important; + margin-left: 6px !important; + margin-right: 0 !important; + } + } +} + +// ============================================================================ +// ANCHOR "BUTTONS" (a[mat-button], a[mat-flat-button], etc.) +// Restore icon sizing (24px) + spacing for svg icons inside anchors +// ============================================================================ + +a.mat-mdc-button, +a.mat-mdc-raised-button, +a.mat-mdc-unelevated-button, +a.mat-mdc-outlined-button { + &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { + display: inline-flex !important; + flex-direction: row !important; + align-items: center !important; + + // SVG icons should have proper size + spacing + > .mat-icon[data-mat-icon-type="svg"] { + order: 0 !important; + width: 24px !important; + height: 24px !important; + margin-right: 6px !important; + margin-left: 0 !important; + flex: 0 0 auto; + + svg { + width: 24px !important; + height: 24px !important; + } + } + + // Optional: keep label after icon (normal button behavior) + > .mdc-button__label { + order: 1 !important; + display: inline-flex; + align-items: center; + } + } +} + +// Fix for menu items with icons +.mat-menu-item, +.mat-mdc-menu-item { + .mat-icon { + margin-right: 16px; + vertical-align: middle; + } +} + +// Fix for list items with icons +.mat-list-item, +.mat-mdc-list-item { + .mat-icon { + margin-right: 16px; + flex-shrink: 0; + } +} + +.mat-mdc-form-field .mat-mdc-form-field-focus-overlay { + background: none !important; + box-shadow: none !important; +} + +.mat-mdc-tab-header { + margin-top: -10px; + border-bottom: 1px solid var(--grey-border-color); +} + +// ============================================================================ +// TEXTAREA FIXES +// ============================================================================ + +// Override the global align-items: center (correct for inputs, wrong for textareas) +// and reduce the excess padding-bottom on the infix for textarea form fields. +.mat-mdc-form-field .mdc-text-field--textarea { + .mat-mdc-form-field-flex { + align-items: flex-start; + } + + .mat-mdc-form-field-infix { + padding-bottom: 8px; + } +} + +// .mat-button, +// .matButton, +// .mat-mdc-button { +// color: var(--link-color) !important; +// } + +// .mat-mdc-button-disabled { +// color: #00000042 !important; +// } + +// ============================================================================ +// IMPURITIES FORM — custom-themed form fields inside .mat-form-field-style +// These were previously dead legacy selectors in impurities-substance-form.component.scss. +// CSS custom properties cascade through component boundaries without ::ng-deep. +// ============================================================================ + +.mat-form-field-style { + // Label color (unfocused) + --mdc-filled-text-field-label-text-color: var(--mat-form-field-label-color); + + // Active indicator (underline) + --mdc-filled-text-field-active-indicator-color: var( + --mat-form-field-underline-bg-color + ); + + // Focused states + --mdc-filled-text-field-focus-active-indicator-color: var( + --mat-form-field-focused-color + ); + --mdc-filled-text-field-focus-label-text-color: var( + --mat-form-field-focused-color + ); + + // Hint text color (validation hints shown in red) + mat-hint { + color: var(--regular-red-color) !important; + } + + // Disabled form fields + .mat-mdc-form-field-disabled, + mat-form-field.mat-form-field-disabled { + cursor: not-allowed; + * { + cursor: not-allowed; + } + } +} From a01b81fd8406528bd4346b1b15f32c64d957bbd3 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 15 Apr 2026 16:42:07 +0200 Subject: [PATCH 43/48] refactor dropdown to mat-menu --- src/app/core/base/base.component.scss | 2 +- .../pfda-toolbar/pfda-toolbar.component.html | 22 +++++++---- .../pfda-toolbar/pfda-toolbar.component.scss | 39 ------------------- src/styles/_material-overrides.scss | 2 +- 4 files changed, 16 insertions(+), 49 deletions(-) diff --git a/src/app/core/base/base.component.scss b/src/app/core/base/base.component.scss index 35ff366e1..8dca2150c 100644 --- a/src/app/core/base/base.component.scss +++ b/src/app/core/base/base.component.scss @@ -111,7 +111,7 @@ .mat-toolbar { position: fixed; top: 0; - z-index: 1002; + z-index: 1001; } .logo { diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 1a739eeac..fc475e439 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -17,21 +17,27 @@
-
+ + + + diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index b24eacaba..8803f1450 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -89,46 +89,7 @@ $screenMedium: 1045px; } .gsrs-menu-trigger { - position: relative; display: inline-block; - - .gsrs-dropdown { - display: none; - position: absolute; - top: 100%; - left: 0; - z-index: 1002; - background: #fff; - min-width: 112px; - max-width: 280px; - border-radius: 4px; - box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); - padding: 0; - - a { - display: flex; - align-items: center; - height: 48px; - padding: 0 16px; - color: rgba(0,0,0,0.87); - text-decoration: none; - white-space: nowrap; - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - font-weight: 400; - line-height: 48px; - cursor: pointer; - box-sizing: border-box; - - &:hover { - background: rgba(0,0,0,0.04); - } - } - } - - &:hover .gsrs-dropdown { - display: block; - } } diff --git a/src/styles/_material-overrides.scss b/src/styles/_material-overrides.scss index dac5b3ac6..e270f08a0 100644 --- a/src/styles/_material-overrides.scss +++ b/src/styles/_material-overrides.scss @@ -1025,7 +1025,7 @@ app-loading .mat-mdc-progress-spinner { background-color: var(--primary-color); position: fixed; top: 0; - z-index: 1002; + z-index: 1001; // @media (max-width: 990px) { // height: auto; From 77575afdb71ba8535d1f96c096bd6730a3ba30ed Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 15 Apr 2026 19:08:25 +0200 Subject: [PATCH 44/48] revert --- .../pfda-toolbar/pfda-toolbar.component.html | 22 +- .../pfda-toolbar/pfda-toolbar.component.scss | 39 + src/styles/_material-overrides.scss | 1665 ----------------- 3 files changed, 47 insertions(+), 1679 deletions(-) delete mode 100644 src/styles/_material-overrides.scss diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index fc475e439..1a739eeac 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -17,27 +17,21 @@
-
+ - - diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index 8803f1450..b24eacaba 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -89,7 +89,46 @@ $screenMedium: 1045px; } .gsrs-menu-trigger { + position: relative; display: inline-block; + + .gsrs-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 1002; + background: #fff; + min-width: 112px; + max-width: 280px; + border-radius: 4px; + box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); + padding: 0; + + a { + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; + color: rgba(0,0,0,0.87); + text-decoration: none; + white-space: nowrap; + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 48px; + cursor: pointer; + box-sizing: border-box; + + &:hover { + background: rgba(0,0,0,0.04); + } + } + } + + &:hover .gsrs-dropdown { + display: block; + } } diff --git a/src/styles/_material-overrides.scss b/src/styles/_material-overrides.scss deleted file mode 100644 index e270f08a0..000000000 --- a/src/styles/_material-overrides.scss +++ /dev/null @@ -1,1665 +0,0 @@ -/** - * Global Angular Material Overrides - * - * Project-wide overrides for Angular Material (MDC) components. - * Loaded last in main.scss so these rules take precedence over Material's - * generated theme styles. - */ - -// ============================================================================ -// MAT-CARD FIXES -// ============================================================================ - -// Fix MDC card padding to match legacy behavior -.mat-mdc-card { - // Angular 15 MDC cards have padding: 16px by default - // Ensure consistency with Angular 14 legacy cards - padding: 16px; - margin: 0 auto 20px auto; - max-width: 1228px; - width: 100%; - box-sizing: border-box; - - &:not([class*="mat-elevation-z"]) { - box-shadow: - 0px 2px 1px -1px rgba(0, 0, 0, 0.2), - 0px 1px 1px 0px rgba(0, 0, 0, 0.14), - 0px 1px 3px 0px rgba(0, 0, 0, 0.12); - } -} - -// Fix MDC card header to match legacy -.mat-mdc-card-header { - display: flex; - padding: 0; -} - -// Fix MDC card title spacing -.mat-mdc-card-title { - font-size: 24px; - font-weight: 500; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -// Fix MDC card subtitle -.mat-mdc-card-subtitle { - margin-top: 0 !important; - margin-bottom: 12px !important; -} - -// Fix MDC card content spacing -.mat-mdc-card-content { - display: block; - - &:first-child { - padding-top: 0; - } - - &:last-child { - padding-bottom: 0; - } -} - -// ============================================================================ -// MAT-CHIP FIXES -// ============================================================================ - -// Fix chip styling for consistency with Angular 14 -.mat-mdc-chip { - &.mat-mdc-standard-chip { - min-height: 32px; - --mdc-chip-container-height: 32px; - } - - .mdc-evolution-chip__action--primary { - padding-left: 12px; - padding-right: 12px; - } - - .mdc-evolution-chip__text-label { - font-size: 14px; - } -} - -.mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) { - --mat-chip-elevated-container-color: var(--primary-color); - --mat-chip-label-text-color: #fff; /* optional */ -} - -// Chip spacing for both chip-set and chip-listbox (deprecated) -.mat-mdc-chip-set .mat-mdc-chip, -.mat-mdc-chip-listbox .mat-mdc-chip { - margin: 4px; -} - -// ============================================================================ -// MAT-FORM-FIELD FIXES -// ============================================================================ - -// MDC form fields have different default appearance and structure -.mat-mdc-form-field { - // Match Angular 14 form field appearance - font-family: Roboto, "Helvetica Neue", sans-serif !important; - // font-size: 14px !important; - // line-height: 1.125 !important; - - // Fix the wrapper to not add extra spacing - .mat-mdc-text-field-wrapper { - padding-bottom: 0; - background-color: transparent; - - .mat-mdc-form-field-flex { - align-items: center; - } - } - - // Fix fill appearance to match legacy - &.mat-form-field-appearance-fill { - .mat-mdc-text-field-wrapper { - padding-bottom: 0; - } - - .mdc-text-field { - background-color: transparent; - border-radius: 4px 4px 0 0; - // padding: 0 12px; - } - - .mdc-text-field--filled { - &:not(.mdc-text-field--disabled) { - background-color: transparent; - } - - .mdc-line-ripple::after { - border-bottom-width: 2px; - } - } - - // Fix infix padding - .mat-mdc-form-field-infix { - min-height: auto; - // padding: 25px 0 0.4375em 0; - } - - // Fix label positioning - .mat-mdc-floating-label { - top: 28px; - } - } - - // Fix outline appearance - &.mat-form-field-appearance-outline { - .mdc-text-field { - padding: 0; - } - - .mdc-text-field--outlined { - .mdc-notched-outline { - .mdc-notched-outline__leading, - .mdc-notched-outline__notch, - .mdc-notched-outline__trailing { - border-color: rgba(0, 0, 0, 0.38); - border-width: 1px; - } - } - - &:not(.mdc-text-field--disabled) { - &:hover .mdc-notched-outline { - .mdc-notched-outline__leading, - .mdc-notched-outline__notch, - .mdc-notched-outline__trailing { - border-color: rgba(0, 0, 0, 0.87); - } - } - } - } - - .mat-mdc-form-field-infix { - padding-top: 16px; - padding-bottom: 16px; - } - - .mat-mdc-floating-label { - top: 28px; - } - } - - // Fix input and label alignment - .mat-mdc-input-element { - font: inherit; - } - - // Fix label styling - .mat-mdc-floating-label { - font-size: 14px; - font-weight: 400; - } - - // Beat Material's runtime: .mdc-text-field--filled .mdc-floating-label { font-size: Xrem } - // That rule has specificity (0,2,0). Adding .mat-mdc-form-field parent gives us (0,3,0). - .mdc-text-field .mdc-floating-label { - font-size: var(--floating-label-font-size, 14px); - } - - // Fix subscript wrapper (hints and errors) - .mat-mdc-form-field-subscript-wrapper { - font-size: 12px; - margin-top: 0.66667em; - padding: 0; - - .mat-mdc-form-field-hint-wrapper, - .mat-mdc-form-field-error-wrapper { - padding: 0; - } - } - - // Fix bottom spacing - .mat-mdc-form-field-bottom-align::before { - content: none; - } - - // Fix icon button inside form field - .mat-mdc-icon-button { - width: 36px; - height: 36px; - - .mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - - // Fix prefix and suffix icon alignment - .mat-mdc-form-field-icon-prefix, - .mat-mdc-form-field-icon-suffix { - display: inline-flex; - align-items: center; - justify-content: center; - - .mat-icon { - display: flex; - align-items: center; - justify-content: center; - } - } -} - -// Legacy form field icon fixes -.mat-form-field-prefix, -.mat-form-field-suffix { - .mat-icon, - .mat-icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - } -} - -// ============================================================================ -// MAT-BUTTON FIXES -// ============================================================================ - -// Fix button styling for consistency with Angular 14 -.mat-mdc-button .mdc-button__label, -.mat-mdc-raised-button .mdc-button__label, -.mat-mdc-unelevated-button .mdc-button__label, -.mat-mdc-outlined-button .mdc-button__label { - white-space: nowrap; -} -.mat-mdc-button, -.mat-mdc-raised-button, -.mat-mdc-unelevated-button, -.mat-mdc-outlined-button { - // Match Angular 14 button heights and appearance - --mdc-text-button-container-height: 36px; - --mdc-filled-button-container-height: 36px; - --mdc-outlined-button-container-height: 36px; - --mdc-protected-button-container-height: 36px; - - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - font-weight: 500; - line-height: 36px; - min-width: 64px; - padding: 0 16px; - - // CRITICAL: Ensure button content respects DOM order - display: inline-flex; - flex-direction: row; - align-items: center; - - // .mdc-button__label { - // font-size: 16px; - // font-weight: 500; - // line-height: normal; - // display: flex; - // flex-direction: row; - // align-items: center; - // order: 0; // Ensure label stays in natural order - // } - - // Ensure mat-icons stay in their HTML order - .mat-icon { - order: 0; // Don't reorder - } - - // Fix the persistent ripple that can cause visual issues - .mat-mdc-button-persistent-ripple { - border-radius: 4px; - } - - // Ensure touch target doesn't break layout - .mat-mdc-button-touch-target { - height: 100%; - } -} - -// Fix raised button elevation -.mat-mdc-raised-button:not(:disabled) { - box-shadow: - 0px 3px 1px -2px rgba(0, 0, 0, 0.2), - 0px 2px 2px 0px rgba(0, 0, 0, 0.14), - 0px 1px 5px 0px rgba(0, 0, 0, 0.12); - - &:hover { - box-shadow: - 0px 2px 4px -1px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.14), - 0px 1px 10px 0px rgba(0, 0, 0, 0.12); - } -} - -// Fix flat button (now called unelevated in MDC) -.mat-mdc-unelevated-button { - --mdc-filled-button-container-height: 36px; -} - -// Ensure icon buttons have consistent size -.mat-mdc-icon-button { - --mdc-icon-button-state-layer-size: 40px; - width: 40px; - height: 40px; - padding: 8px; - line-height: 24px; - - .mat-mdc-button-touch-target { - width: 48px; - height: 48px; - } - - .mat-icon { - width: 24px; - height: 24px; - font-size: 24px; - line-height: 24px; - } -} - -// Fix FAB button -.mat-mdc-fab, -.mat-mdc-mini-fab { - .mat-mdc-button-touch-target { - width: 100%; - height: 100%; - } -} - -// ============================================================================ -// MAT-ICON FIXES -// ============================================================================ - -// Fix icon sizing and alignment -.mat-icon { - width: 24px; - height: 24px; - font-size: 24px; - line-height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - vertical-align: middle; - flex-shrink: 0; - - // Critical: Fix SVG positioning to center - svg { - width: 100%; - height: 100%; - fill: currentColor; - display: block; - margin: auto; - } -} - -// Specific fix for mat-icons inside buttons -button .mat-icon, -.mat-button .mat-icon, -.mat-raised-button .mat-icon, -.mat-flat-button .mat-icon, -.mat-stroked-button .mat-icon, -.mat-mdc-button .mat-icon, -.mat-mdc-raised-button .mat-icon, -.mat-mdc-unelevated-button .mat-icon, -.mat-mdc-outlined-button .mat-icon { - display: inline-flex; - align-items: center; - justify-content: center; - vertical-align: middle; -} - -// Fix icon button sizing (already covered above but ensure consistency) -.mat-mdc-icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - - .mat-icon { - position: relative; - left: 0; - right: 0; - top: 0; - bottom: 0; - margin: auto; - } - - .mat-mdc-button-touch-target { - position: absolute; - } -} - -.mat-icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - - .mat-icon { - position: relative; - margin: auto; - } -} - -// ============================================================================ -// MAT-MENU FIXES -// ============================================================================ - -// Fix menu panel -.mat-mdc-menu-panel { - min-width: 112px; - max-width: 280px; - border-radius: 4px; - box-shadow: - 0px 2px 4px -1px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.14), - 0px 1px 10px 0px rgba(0, 0, 0, 0.12); -} - -.mat-mdc-menu-content { - padding: 0; - width: 100%; - box-sizing: border-box; - - // Ensure all menu items have consistent vertical rhythm - > * { - margin: 0 !important; - display: block !important; - } - - // Fix for wrapper divs (common pattern but not recommended) - > div { - display: contents !important; - margin: 0 !important; - padding: 0 !important; - height: auto !important; - } -} - -// Fix menu item styling -.mat-mdc-menu-item { - font-family: Roboto, "Helvetica Neue", sans-serif !important; - font-size: 14px !important; - font-weight: 400 !important; - min-height: 48px !important; - height: 48px !important; - padding: 0 16px !important; - display: flex !important; - align-items: center !important; - position: relative !important; - pointer-events: auto !important; - cursor: pointer !important; - width: 100% !important; - box-sizing: border-box !important; - margin: 0 !important; - line-height: 48px !important; - - .mat-icon { - margin-right: 16px; - line-height: normal !important; - } - - .mat-mdc-menu-item-text { - flex-grow: 1; - line-height: normal !important; - } - - // Ensure the MDC button inside menu item is clickable - .mat-mdc-menu-item-text, - .mdc-list-item__primary-text { - pointer-events: auto !important; - line-height: normal !important; - } - - // Fix for anchor tag menu items - &[href] { - pointer-events: auto !important; - cursor: pointer !important; - } - - // Fix internal content wrapper that Angular Material adds - .mdc-list-item__content { - display: flex !important; - align-items: center !important; - height: 48px !important; - padding: 0 !important; - margin: 0 !important; - } -} - -// Additional fixes for submenu trigger menu items (menu items with matMenuTriggerFor) -// The base .mat-mdc-menu-item rule above handles most of it, but ensure no overrides -.mat-mdc-menu-item.mat-mdc-menu-trigger, -a.mat-mdc-menu-item[ng-reflect-menu], -button.mat-mdc-menu-item[ng-reflect-menu], -.mat-mdc-menu-item.cdk-menu-trigger { - // Ensure submenu triggers don't have any extra spacing - vertical-align: middle !important; - - // Ensure the submenu indicator icon aligns properly - &::after { - line-height: normal !important; - } -} - -// Fix for nested menu positioning in Angular Material 19 MDC -// Ensure the CDK overlay positioning works correctly -.cdk-overlay-connected-position-bounding-box { - // Don't interfere with the calculated position - .mat-mdc-menu-panel { - // Ensure nested menus align properly with their trigger - &.mat-mdc-menu-nested { - margin-top: 0 !important; - } - } -} - -// ============================================================================ -// MAT-LIST FIXES -// ============================================================================ - -.mat-mdc-list, -.mat-mdc-list-base { - padding: 8px 0; - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -.mat-mdc-list-item { - font-size: 14px; - font-weight: 400; - height: 48px; - - .mdc-list-item__primary-text { - font-size: 14px; - font-weight: 400; - color: var(--link-color); - } - - .mdc-list-item__secondary-text { - font-size: 12px; - font-weight: 400; - } - - // Prevent mat-icons from inheriting link-color from primary text - .mat-icon { - color: var(--mat-list-color); - } -} - -.mat-mdc-list-item-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - margin-right: 16px; -} - -.mat-mdc-list-item-icon { - width: 24px; - height: 24px; - font-size: 24px; - margin-right: 16px; -} - -// Two-line list items -.mat-mdc-list-item.mdc-list-item--with-two-lines { - height: 64px; -} - -// Three-line list items -.mat-mdc-list-item.mdc-list-item--with-three-lines { - height: 88px; -} - -// ============================================================================ -// MAT-TABLE FIXES -// ============================================================================ - -// Fix table styling for consistency with Angular 14 -.mat-mdc-table { - background-color: inherit; - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -.mat-mdc-header-row { - min-height: 56px; -} - -.mat-mdc-row { - min-height: 48px; -} - -.mat-mdc-header-cell { - font-size: 12px; - font-weight: 500; - color: rgba(0, 0, 0, 0.54); -} - -.mat-mdc-cell { - font-size: 14px; - color: rgba(0, 0, 0, 0.87); -} - -// ============================================================================ -// MAT-PAGINATOR FIXES -// ============================================================================ - -// Fix paginator styling to match Angular 14 -.mat-mdc-paginator { - background-color: transparent; - display: block; - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -.mat-mdc-paginator-container { - display: flex; - align-items: center; - justify-content: flex-end; - min-height: 56px; - padding: 0 8px; -} - -.mat-mdc-paginator-page-size { - display: flex; - align-items: center; -} - -.mat-mdc-paginator-range-label { - margin: 0 32px 0 24px; -} - -.mat-mdc-paginator-page-size .mdc-notched-outline__leading, -.mat-mdc-paginator-page-size .mdc-notched-outline__trailing, -.mat-mdc-paginator-page-size .mdc-notched-outline__notch { - border-bottom: 1px solid currentColor !important; - border-radius: 0 !important; -} - -/* Adjust the overall infix height if needed, as padding affects height */ -.mat-mdc-paginator-page-size .mat-mdc-form-field-infix { - padding: 5px 0 !important; - min-height: auto !important; /* Ensure min-height does not enforce extra space */ -} - -/* Adjust the select's value text container height/line-height for vertical alignment */ -.mat-mdc-paginator-page-size .mat-mdc-select-value-text { - line-height: unset !important; - display: flex; - align-items: center; -} - -// ============================================================================ -// PAGE SELECTOR (GLOBAL) -// ============================================================================ - -// Page selector styles used across browse components -.page-selector { - display: flex; - flex-direction: row; - align-items: center; - margin-left: 20px; -} - -.page-label { - color: var(--dark-label-color); - display: block; - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - padding-right: 12px; -} - -// Page selector form field adjustments -// Increase specificity to override the general mat-form-field-appearance-fill styles above -.page-selector > .mat-mdc-form-field.mat-form-field-appearance-fill { - .mat-mdc-form-field-infix { - min-height: auto; - padding: 10px 0 0 0 !important; - } - - .mdc-text-field { - padding: 0; - } -} - -// Responsive behavior - hide page selector on small screens -@media (max-width: 730px) { - .page-selector { - display: none !important; - } -} - -// ============================================================================ -// MAT-SELECT FIXES -// ============================================================================ - -// Fix select styling to match Angular 14 -.mat-mdc-select { - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; -} - -.mat-mdc-select-value { - font-size: 14px; -} - -.mat-mdc-select-trigger { - height: auto; -} - -.mat-mdc-select-panel { - max-height: 256px; -} - -.mat-mdc-option { - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - min-height: 48px; - - .mdc-list-item__primary-text { - font-size: 14px !important; - } -} - -// ============================================================================ -// MAT-CHECKBOX FIXES -// ============================================================================ - -.mat-mdc-checkbox { - --mdc-checkbox-state-layer-size: 40px; - - .mdc-checkbox { - padding: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); - } - - .mdc-checkbox__background { - width: 18px; - height: 18px; - top: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); - left: calc((var(--mdc-checkbox-state-layer-size) - 18px) / 2); - } - - .mdc-form-field { - font-size: 14px; - } -} - -// ============================================================================ -// MAT-RADIO FIXES -// ============================================================================ - -.mat-mdc-radio-button { - .mdc-radio { - padding: 10px; - } - - .mdc-form-field { - font-size: 14px; - } -} - -// ============================================================================ -// MAT-EXPANSION-PANEL FIXES -// ============================================================================ - -.mat-expansion-panel { - box-shadow: - 0px 2px 1px -1px rgba(0, 0, 0, 0.2), - 0px 1px 1px 0px rgba(0, 0, 0, 0.14), - 0px 1px 3px 0px rgba(0, 0, 0, 0.12); -} - -.mat-expansion-panel-header { - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - height: 48px; -} - -.mat-expansion-panel-content { - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -// ============================================================================ -// MAT-DIALOG FIXES -// ============================================================================ - -.mat-mdc-dialog-container { - --mat-dialog-supporting-text-color: black; - - .mdc-dialog__surface { - border-radius: 4px; - padding: 24px; - } -} - -// User Edit dialog -.user-edit-dialog { - .mat-mdc-dialog-title { - font-size: 40px !important; - font-weight: bold !important; - } - - .mat-mdc-dialog-content { - padding: 10px 24px !important; - max-height: none !important; - overflow-y: unset !important; - } - - .mat-mdc-dialog-container, - .mdc-dialog__surface { - height: unset !important; - max-height: 90vh !important; - overflow-y: auto !important; - } -} - -// Cross Entity Search dialog — MDC caps surface at 560px by default; override per panel class -.cross-entity-search-dialog { - .mdc-dialog__surface { - max-width: none !important; - } -} - -// Advanced Selector dialog — override MDC's 560px max-width CSS variable at the pane level, -// which is where var(--mat-dialog-container-max-width, 560px) is resolved. Children that use -// max-width: inherit will then inherit none instead of 560px. -.advanced-selector-dialog { - --mat-dialog-container-max-width: none; - - .mat-mdc-dialog-inner-container, - .mdc-dialog__surface { - max-width: none !important; - } -} - -.mat-mdc-dialog-title { - font-size: 20px; - font-weight: 500; - margin: 0 0 16px; - padding: 24px 24px 0; -} - -.mat-mdc-dialog-content { - font-size: 14px; - padding: 0 24px; -} - -.mat-mdc-dialog-inner-container { - height: fit-content !important; - max-height: 90vh !important; - overflow-y: auto !important; -} - -.mat-mdc-dialog-actions { - padding: 0 24px 8px 24px !important; - min-height: 52px; -} - -// ============================================================================ -// MAT-TAB FIXES -// ============================================================================ - -.mat-mdc-tab-group { - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -.mat-mdc-tab-list { - flex-grow: 0 !important; -} - -.mat-mdc-tab { - font-size: 14px; - font-weight: 500; - min-width: 160px; - height: 48px; -} - -.mat-mdc-tab-list .mat-mdc-tab, -.mat-tab-list .mat-tab-label { - letter-spacing: normal; - - .mdc-tab__text-label { - letter-spacing: normal; - line-height: 1.3; - } -} - -.mat-mdc-tab-body-content { - padding: 16px 0; -} - -// Admin tab group: 18px tab labels (beats Material runtime at 0,2,0) -.tab-group .mat-mdc-tab .mdc-tab__text-label { - font-size: 18px; -} - -// ============================================================================ -// MAT-SLIDER FIXES -// ============================================================================ - -.mat-mdc-slider { - .mdc-slider__track { - height: 2px; - } - - .mdc-slider__thumb-knob { - width: 12px; - height: 12px; - } -} - -// ============================================================================ -// MAT-PROGRESS-BAR FIXES -// ============================================================================ - -.mat-mdc-progress-bar { - --mdc-linear-progress-track-height: 4px; -} - -// ============================================================================ -// MAT-PROGRESS-SPINNER FIXES -// ============================================================================ - -.mat-mdc-progress-spinner { - circle { - stroke-width: 10%; - } -} - -// Scoped to app-loading only — prevents ALL spinners from being fixed/centered -app-loading .mat-mdc-progress-spinner { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1003; - display: block; -} - -// ============================================================================ -// MAT-TOOLTIP FIXES -// ============================================================================ - -.mat-mdc-tooltip { - .mdc-tooltip__surface { - font-size: 10px; - padding: 4px 8px; - max-width: 200px; - } -} - -// ============================================================================ -// MAT-SNACKBAR FIXES -// ============================================================================ - -.mat-mdc-snack-bar-container { - .mdc-snackbar__surface { - background-color: #323232; - } - - .mdc-snackbar__label { - color: white; - font-size: 14px; - } -} - -// ============================================================================ -// MAT-TOOLBAR FIXES -// ============================================================================ - -.mat-toolbar, -.mat-mdc-toolbar { - font-family: Roboto, "Helvetica Neue", sans-serif; - font-size: 14px; - font-weight: 400; - display: flex; - box-sizing: border-box; - width: 100%; - flex-direction: row; - align-items: center; - white-space: nowrap; - padding: 0 16px; - min-height: 64px; - position: relative; - - // Single row toolbar (default) - &:not(.mat-toolbar-multiple-rows) { - flex-direction: row; - height: 64px; - background-color: var(--primary-color); - position: fixed; - top: 0; - z-index: 1001; - - // @media (max-width: 990px) { - // height: auto; - // flex-wrap: wrap; - // } - } - - // Multiple rows toolbar - &.mat-toolbar-multiple-rows { - flex-direction: column; - min-height: 64px; - } -} - -// Fix toolbar row heights to match Angular 14 -.mat-toolbar-row, -.mat-mdc-toolbar-row { - display: flex; - box-sizing: border-box; - width: 100%; - height: 64px; - flex-direction: row; - align-items: center; - white-space: nowrap; - padding: 0 16px; -} - -.mat-toolbar-single-row, -.mat-mdc-toolbar-single-row { - display: flex; - box-sizing: border-box; - width: 100%; - height: 64px; - flex-direction: row; - align-items: center; - white-space: nowrap; - padding: 0 16px; -} - -// Fix spacer/middle-fill pattern (flex-grow to push items to sides) -.mat-toolbar .middle-fill, -.mat-mdc-toolbar .middle-fill, -.mat-toolbar .spacer, -.mat-mdc-toolbar .spacer, -.mat-toolbar .fill, -.mat-mdc-toolbar .fill { - flex: 1 1 auto; -} - -// ============================================================================ -// RESPONSIVE TOOLBAR ADJUSTMENTS -// ============================================================================ - -// Tablet/Mobile: Hide most navigation buttons at 1350px, show only Logo, Menu, Search, and Login -// Note: scoped to :not(.pfda-toolbar) to avoid hiding pfda-toolbar buttons -@media (max-width: 1350px) { - .mat-toolbar:not(.pfda-toolbar), - .mat-mdc-toolbar:not(.pfda-toolbar) { - // Logo container - always visible - > .logo-container { - display: flex !important; - } - - // Menu button (nav-small span) - always visible - > span.nav-small { - display: inline-block !important; - } - - // Hide all div containers EXCEPT logo-container and the one containing login - > div { - // Hide by default - display: none !important; - - // But keep logo container visible - &.logo-container { - display: flex !important; - } - - // Keep the div that contains login button or logged-in user visible - &:has(.login-link), - &:has(.logged-in) { - display: block !important; - } - - // If logged-in is inside, ensure it displays properly - .logged-in { - display: flex !important; - } - } - - // Middle-fill spacer - keep visible - > span.middle-fill { - display: block !important; - flex: 1 1 auto; - } - - // Search component - keep visible and maintain flex behavior - > app-substance-text-search { - display: block !important; - flex-grow: 1; - max-width: 600px; - } - - // Classic view container - hide - .classic-view-container { - display: none !important; - } - } -} - -// ============================================================================ -// MAT-SIDENAV FIXES -// ============================================================================ - -.mat-drawer-container, -.mat-sidenav-container { - background-color: inherit; - color: inherit; -} - -.mat-drawer, -.mat-sidenav { - box-shadow: - 0px 8px 10px -5px rgba(0, 0, 0, 0.2), - 0px 16px 24px 2px rgba(0, 0, 0, 0.14), - 0px 6px 30px 5px rgba(0, 0, 0, 0.12); - background-color: white; -} - -.mat-drawer-backdrop, -.mat-sidenav-backdrop { - background-color: rgba(0, 0, 0, 0.6); -} - -.mat-drawer-content, -.mat-sidenav-content { - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -// Fix sidenav positioning and transitions -.mat-drawer-side { - border-right: solid 1px rgba(0, 0, 0, 0.12); -} - -.mat-drawer.mat-drawer-side { - z-index: 2; -} - -// Global export button styles in sidenav content -mat-sidenav-content .controls-container .export-button { - color: var(--regular-black-color); - background-color: white; - border-radius: 4px; - box-shadow: - 0 3px 1px -2px rgba(0, 0, 0, 0.2), - 0 2px 2px rgba(0, 0, 0, 0.1411764706), - 0 1px 5px rgba(0, 0, 0, 0.1215686275); - padding: 16px 16px; -} - -button.mat-mdc-button.export-button > .mat-icon, -button.mat-mdc-button.export-button .mat-icon, -.mat-icon[data-mat-icon-name="chevron_down"] { - height: 24px !important; - width: 24px !important; - font-size: 24px !important; - line-height: 24px !important; -} - -// ============================================================================ -// GLOBAL BUTTON ICON SIZING + SPACING (MDC) -// ============================================================================ - -// Apply to "text buttons" (buttons that can have icon + label) -button.mat-mdc-button, -button.mat-mdc-raised-button, -button.mat-mdc-unelevated-button, -button.mat-mdc-outlined-button { - .mat-icon { - width: 24px; - height: 24px; - font-size: 24px; - line-height: 24px; - - // space between icon and label - margin-right: 6px; - margin-left: 0; - } - - // If svgIcon renders sizing on the SVG element - .mat-icon svg { - width: var(--button-icon-svg-size, 24px); - height: var(--button-icon-svg-size, 24px); - } -} - -// Do NOT add label spacing to icon-only buttons -button.mat-mdc-icon-button .mat-icon { - margin-right: 0; -} - -// ============================================================================ -// EXPORT BUTTON ONLY: keep svg mat-icon LEFT of label + spacing -// ============================================================================ - -button.export-button { - // Only for SVG icons inside export buttons - .mat-icon[data-mat-icon-type="svg"] { - order: 0 !important; - margin-right: 6px !important; - margin-left: 0 !important; - - width: 24px; - height: 24px; - - svg { - width: 24px; - height: 24px; - } - } - - // Ensure label stays after the icon - .mdc-button__label { - order: 1 !important; - display: inline-flex; - align-items: center; - } -} - -// button.mat-mdc-button >.mat-icon[data-mat-icon-name="chevron_down"]{ -// height: 24px !important; -// width: 24px !important; -// font-size: 24px !important; -// line-height: 24px !important; -// } - -// ============================================================================ -// GENERAL MDC FIXES -// ============================================================================ - -// Fix elevation classes if needed -.mat-elevation-z0 { - box-shadow: none; -} - -.mat-elevation-z1 { - box-shadow: - 0px 2px 1px -1px rgba(0, 0, 0, 0.2), - 0px 1px 1px 0px rgba(0, 0, 0, 0.14), - 0px 1px 3px 0px rgba(0, 0, 0, 0.12); -} - -.mat-elevation-z2 { - box-shadow: - 0px 3px 1px -2px rgba(0, 0, 0, 0.2), - 0px 2px 2px 0px rgba(0, 0, 0, 0.14), - 0px 1px 5px 0px rgba(0, 0, 0, 0.12); -} - -.mat-elevation-z3 { - box-shadow: - 0px 3px 3px -2px rgba(0, 0, 0, 0.2), - 0px 3px 4px 0px rgba(0, 0, 0, 0.14), - 0px 1px 8px 0px rgba(0, 0, 0, 0.12); -} - -.mat-elevation-z4 { - box-shadow: - 0px 2px 4px -1px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.14), - 0px 1px 10px 0px rgba(0, 0, 0, 0.12); -} - -.mat-elevation-z6 { - box-shadow: - 0px 3px 5px -1px rgba(0, 0, 0, 0.2), - 0px 6px 10px 0px rgba(0, 0, 0, 0.14), - 0px 1px 18px 0px rgba(0, 0, 0, 0.12); -} - -.mat-elevation-z8 { - box-shadow: - 0px 5px 5px -3px rgba(0, 0, 0, 0.2), - 0px 8px 10px 1px rgba(0, 0, 0, 0.14), - 0px 3px 14px 2px rgba(0, 0, 0, 0.12); -} - -// Override MDC ripple behavior if it interferes with existing styles -.mat-mdc-button-ripple, -.mat-mdc-icon-button-ripple, -.mat-ripple, -.mat-mdc-button-persistent-ripple { - position: absolute; - pointer-events: none; -} - -// Ensure proper focus indicators -.mat-mdc-focus-indicator { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - border: 2px solid transparent; -} - -// Fix button touch targets that can cause layout issues -.mat-mdc-button-touch-target { - position: absolute; - top: 50%; - height: 48px; - left: 0; - right: 0; - transform: translateY(-50%); -} - -// ============================================================================ -// SPECIFIC COMPONENT FIXES -// ============================================================================ - -// Fix for search buttons in substance-text-search component -.search-button, -.close-button, -.activate-search-button { - &[mat-icon-button] { - // Ensure these buttons work properly with icon centering - .mat-icon { - display: inline-flex; - align-items: center; - justify-content: center; - } - } -} - -// Fix for login button -.login-link { - &[mat-button] { - display: inline-flex; - align-items: center; - justify-content: center; - } -} - -// Fix for user account buttons -.user-button { - &[mat-icon-button] { - // These have custom sizing, ensure icons still center - .mat-icon, - .user-icon { - display: inline-flex; - align-items: center; - justify-content: center; - } - } -} - -// ============================================================================ -// COMPREHENSIVE ICON CENTERING FIX -// ============================================================================ -// This ensures all mat-icons are properly centered regardless of context - -// Force all icon containers to use flexbox centering -button[mat-icon-button], -button[mat-mini-fab], -button[mat-fab], -a[mat-icon-button], -a[mat-mini-fab], -a[mat-fab], -.mat-icon-button, -.mat-mdc-icon-button, -.mat-mini-fab, -.mat-mdc-mini-fab, -.mat-fab, -.mat-mdc-fab { - display: inline-flex; - align-items: center !important; - justify-content: center !important; - - .mat-icon { - position: relative !important; - left: auto !important; - right: auto !important; - top: auto !important; - bottom: auto !important; - transform: none !important; - // Don't force margin to 0 - allow component-specific margins - } - - // Ensure SVG icons are centered within the mat-icon - .mat-icon svg, - svg { - position: relative; - left: auto; - right: auto; - top: auto; - bottom: auto; - transform: none; - display: block; - margin: auto; - } -} - -// Exception: substance-hierarchy tree toggle buttons — keep icon in static flow -.tree-button.mat-mdc-icon-button .mat-icon { - position: static !important; - top: auto !important; - left: auto !important; - right: auto !important; - bottom: auto !important; - margin: 0 !important; - transform: none !important; -} - -// Fix for icons in regular buttons (not icon buttons) -.mat-button, -.mat-raised-button, -.mat-flat-button, -.mat-stroked-button, -.mat-mdc-button, -.mat-mdc-raised-button, -.mat-mdc-unelevated-button, -.mat-mdc-outlined-button { - // Override Angular Material's DOM reordering - &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { - // CRITICAL: Angular Material wraps content in .mdc-button__label - // and places mat-icon OUTSIDE of it, but before it in the DOM - // We need to visually reorder them - - display: inline-flex !important; - flex-direction: row !important; - align-items: center !important; - - // The mat-icon appears FIRST in DOM (Angular moves it) - > .mat-icon { - order: 10 !important; // Force it to appear LAST visually - // margin-left: 8px !important; - margin-right: 0 !important; - vertical-align: middle; - display: inline-flex; - align-items: center; - justify-content: center; - - svg { - vertical-align: top; - } - } - - // The .mdc-button__label contains the text content - > .mdc-button__label { - order: 1 !important; // Make it appear FIRST visually - display: inline-flex; - align-items: center; - - // // If there's a mat-icon inside the label (shouldn't happen, but just in case) - // > .mat-icon { - // margin-left: 8px; - // order: inherit; - // } - } - - // Handle ripple and touch target (they should be positioned absolutely) - > .mat-mdc-button-persistent-ripple, - > .mat-mdc-button-ripple, - > .mat-mdc-button-touch-target, - > .mat-mdc-focus-indicator { - order: 0 !important; - position: absolute !important; - } - } -} - -// ============================================================================ -// EXPORT BUTTON: put SVG icon left, keep dropdown icon right -// (Matches the specificity of the global reorder rule) -// ============================================================================ - -.mat-mdc-button.export-button, -.mat-mdc-raised-button.export-button, -.mat-mdc-unelevated-button.export-button, -.mat-mdc-outlined-button.export-button { - &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { - display: inline-flex !important; - flex-direction: row !important; - align-items: center !important; - - // SVG icon (get_app) must be LEFT of label - > .mat-icon[data-mat-icon-type="svg"] { - order: 0 !important; - margin-right: 6px !important; - margin-left: 0 !important; - } - - // Label in the middle - > .mdc-button__label { - order: 1 !important; - } - - // Font icon (arrow_drop_down) stays on the RIGHT - > .mat-icon[data-mat-icon-type="font"] { - order: 2 !important; - margin-left: 6px !important; - margin-right: 0 !important; - } - } -} - -// ============================================================================ -// ANCHOR "BUTTONS" (a[mat-button], a[mat-flat-button], etc.) -// Restore icon sizing (24px) + spacing for svg icons inside anchors -// ============================================================================ - -a.mat-mdc-button, -a.mat-mdc-raised-button, -a.mat-mdc-unelevated-button, -a.mat-mdc-outlined-button { - &:not([mat-icon-button]):not([mat-fab]):not([mat-mini-fab]) { - display: inline-flex !important; - flex-direction: row !important; - align-items: center !important; - - // SVG icons should have proper size + spacing - > .mat-icon[data-mat-icon-type="svg"] { - order: 0 !important; - width: 24px !important; - height: 24px !important; - margin-right: 6px !important; - margin-left: 0 !important; - flex: 0 0 auto; - - svg { - width: 24px !important; - height: 24px !important; - } - } - - // Optional: keep label after icon (normal button behavior) - > .mdc-button__label { - order: 1 !important; - display: inline-flex; - align-items: center; - } - } -} - -// Fix for menu items with icons -.mat-menu-item, -.mat-mdc-menu-item { - .mat-icon { - margin-right: 16px; - vertical-align: middle; - } -} - -// Fix for list items with icons -.mat-list-item, -.mat-mdc-list-item { - .mat-icon { - margin-right: 16px; - flex-shrink: 0; - } -} - -.mat-mdc-form-field .mat-mdc-form-field-focus-overlay { - background: none !important; - box-shadow: none !important; -} - -.mat-mdc-tab-header { - margin-top: -10px; - border-bottom: 1px solid var(--grey-border-color); -} - -// ============================================================================ -// TEXTAREA FIXES -// ============================================================================ - -// Override the global align-items: center (correct for inputs, wrong for textareas) -// and reduce the excess padding-bottom on the infix for textarea form fields. -.mat-mdc-form-field .mdc-text-field--textarea { - .mat-mdc-form-field-flex { - align-items: flex-start; - } - - .mat-mdc-form-field-infix { - padding-bottom: 8px; - } -} - -// .mat-button, -// .matButton, -// .mat-mdc-button { -// color: var(--link-color) !important; -// } - -// .mat-mdc-button-disabled { -// color: #00000042 !important; -// } - -// ============================================================================ -// IMPURITIES FORM — custom-themed form fields inside .mat-form-field-style -// These were previously dead legacy selectors in impurities-substance-form.component.scss. -// CSS custom properties cascade through component boundaries without ::ng-deep. -// ============================================================================ - -.mat-form-field-style { - // Label color (unfocused) - --mdc-filled-text-field-label-text-color: var(--mat-form-field-label-color); - - // Active indicator (underline) - --mdc-filled-text-field-active-indicator-color: var( - --mat-form-field-underline-bg-color - ); - - // Focused states - --mdc-filled-text-field-focus-active-indicator-color: var( - --mat-form-field-focused-color - ); - --mdc-filled-text-field-focus-label-text-color: var( - --mat-form-field-focused-color - ); - - // Hint text color (validation hints shown in red) - mat-hint { - color: var(--regular-red-color) !important; - } - - // Disabled form fields - .mat-mdc-form-field-disabled, - mat-form-field.mat-form-field-disabled { - cursor: not-allowed; - * { - cursor: not-allowed; - } - } -} From 5ef17877a4aa743317accf780ec2ae3a678a8c66 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 15 Apr 2026 19:45:24 +0200 Subject: [PATCH 45/48] update z-index of pfda toolbar --- src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss index b24eacaba..7bd619c6e 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.scss @@ -18,6 +18,7 @@ $screenMedium: 1045px; line-height: 16px; padding: 0 8px; height: auto; + z-index: 1002; a { color: inherit; From 7a21d8c7f3f85e561cafbff27a899f2019aec7dd Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 22 Apr 2026 13:11:06 +0200 Subject: [PATCH 46/48] fix: export delete before cancel --- .../download-monitor/download-monitor.component.html | 2 +- .../download-monitor/download-monitor.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html index 065e72d03..9b79e0faa 100644 --- a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html +++ b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html @@ -126,7 +126,7 @@
- diff --git a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.ts b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.ts index 2cc8343f6..68bf17a19 100644 --- a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.ts +++ b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.ts @@ -79,7 +79,7 @@ export class DownloadMonitorComponent implements OnInit, OnDestroy { } deleteDownload() { - this.authService.deleteDownload(this.download.removeUrl.url).pipe(take(1)).subscribe(response => { + this.authService.deleteDownload(this.download.removeUrl?.url || this.download.cancelUrl.url.replace('/@cancel', '')).pipe(take(1)).subscribe(response => { this.deleted = true; }); } From 72ca8d5f2216448876d7fbb7581110e5b7d81935 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Tue, 5 May 2026 11:13:02 +0200 Subject: [PATCH 47/48] fix deleting export when finished --- .../download-monitor/download-monitor.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html index 9b79e0faa..5e78eef0f 100644 --- a/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html +++ b/src/app/core/auth/user-downloads/download-monitor/download-monitor.component.html @@ -126,7 +126,7 @@
- From add637a8fec0db0743a01f8918d32383aaac0291 Mon Sep 17 00:00:00 2001 From: Jaroslav Iha Date: Wed, 20 May 2026 13:19:56 +0200 Subject: [PATCH 48/48] fix: link to profile/account page --- src/app/core/base/pfda-toolbar/pfda-toolbar.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html index 1a739eeac..a5015c698 100644 --- a/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html +++ b/src/app/core/base/pfda-toolbar/pfda-toolbar.component.html @@ -84,8 +84,8 @@
- - Profile + + Account