diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 13b1d7528..de3c83aff 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,6 +1,12 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {UserRouteAccessService} from './core/auth/user-route-access-service'; +import {FederationDisabledGuard} from './federation/guards/federation-disabled.guard'; +import { + FederationEmailConfigPageComponent +} from './federation/pages/email-config/federation-email-config.page.component'; +import {TeamMembersPageComponent} from './federation/pages/team-members/team-members.page.component'; +import {WelcomeComponent as FederationWelcomeComponent} from './federation/pages/welcome/welcome.component'; import {ConfirmIdentityComponent} from './shared/components/auth/confirm-identity/confirm-identity.component'; import {LoginComponent} from './shared/components/auth/login/login.component'; import { @@ -69,7 +75,8 @@ const routes: Routes = [ { path: 'app-management', loadChildren: './app-management/app-management.module#AppManagementModule', - canActivate: [UserRouteAccessService], + canActivate: [UserRouteAccessService, FederationDisabledGuard], + canActivateChild: [FederationDisabledGuard], data: {authorities: [USER_ROLE, ADMIN_ROLE]} }, { @@ -152,9 +159,26 @@ const routes: Routes = [ canActivate: [UserRouteAccessService], data: {authorities: [ADMIN_ROLE, USER_ROLE]} }, + { + path: 'federation', + canActivate: [UserRouteAccessService], + data: {authorities: [ADMIN_ROLE, USER_ROLE]}, + children: [ + {path: '', redirectTo: 'welcome', pathMatch: 'full'}, + {path: 'welcome', component: FederationWelcomeComponent}, + {path: 'instances', component: FederationWelcomeComponent}, + {path: 'team-members', component: TeamMembersPageComponent}, + { + path: 'email-config', + component: FederationEmailConfigPageComponent, + data: {authorities: [ADMIN_ROLE]} + } + ] + }, {path: '', component: LoginComponent}, {path: 'totp', component: TotpComponent}, {path: 'enroll-tfa', component: TfaSetupComponent}, + {path: 'auth/login', component: PasswordResetFinishComponent}, {path: 'reset/finish', component: PasswordResetFinishComponent}, {path: 'page-not-found', component: NotFoundComponent}, {path: 'confirm-identity/:id', component: ConfirmIdentityComponent}, diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index fe002398c..11ad044b3 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,16 +1,19 @@
-
+
-
-
- + +
+
+ +
-
+
; constructor( private translate: TranslateService, @@ -42,7 +45,10 @@ export class AppComponent implements OnInit { private apiServiceCheckerService: ApiServiceCheckerService, private accountService: AccountService, private checkForUpdatesService: AppVersionService, - private versionTypeService: VersionInfoService) { + private versionTypeService: VersionInfoService, + private federationInstanceState: FederationInstanceStateService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; this.translate.setDefaultLang('en'); @@ -82,6 +88,10 @@ export class AppComponent implements OnInit { } }); + this.federationInstanceState.switch$ + .pipe(skip(1)) + .subscribe(() => this.getReportLogo()); + this.accountService.getAuthenticationState() .pipe(distinctUntilChanged((prev, next) => prev && next && prev.id === next.id)) .subscribe(identity => { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index b2020ec51..e58b7036a 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -23,6 +23,7 @@ import {AppComponent} from './app.component'; import {AuthExpiredInterceptor} from './blocks/interceptor/auth-expired.interceptor'; import {AuthInterceptor} from './blocks/interceptor/auth.interceptor'; import {ErrorHandlerInterceptor} from './blocks/interceptor/errorhandler.interceptor'; +import {FederationInstanceInterceptor} from './blocks/interceptor/federation-instance.interceptor'; import {ManageHttpInterceptor} from './blocks/interceptor/managehttp.interceptor'; import {NotificationInterceptor} from './blocks/interceptor/notification.interceptor'; import {HttpCancelService} from './blocks/service/httpcancel.service'; @@ -36,6 +37,8 @@ import {AlertIncidentStatusChangeBehavior} from './shared/behaviors/alert-incide import {GettingStartedBehavior} from './shared/behaviors/getting-started.behavior'; import {NavBehavior} from './shared/behaviors/nav.behavior'; import {NewAlertBehavior} from './shared/behaviors/new-alert.behavior'; +import {initFederationMode} from './federation/hooks/federation-bootstrap'; +import {FederationModeService} from './federation/services/federation-mode.service'; import {TimezoneFormatService} from './shared/services/utm-timezone.service'; import {AppVersionService} from './shared/services/version/app-version.service'; import {UtmSharedModule} from './shared/utm-shared.module'; @@ -109,6 +112,11 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService, deps: [SessionStorageService, LocalStorageService], multi: true }, + { + provide: HTTP_INTERCEPTORS, + useClass: FederationInstanceInterceptor, + multi: true + }, { provide: HTTP_INTERCEPTORS, useClass: AuthExpiredInterceptor, @@ -132,6 +140,12 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService, useClass: ManageHttpInterceptor, multi: true, }, + { + provide: APP_INITIALIZER, + useFactory: initFederationMode, + deps: [FederationModeService], + multi: true + }, { provide: APP_INITIALIZER, useFactory: initTimezoneFormat, diff --git a/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts b/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts new file mode 100644 index 000000000..ccefa58c1 --- /dev/null +++ b/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts @@ -0,0 +1,53 @@ +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; + +const AUTH_PATH_PREFIXES = [ + 'api/authenticate', + 'api/v1/auth/', + 'api/tfa/', + 'auth/' +]; + +@Injectable() +export class FederationInstanceInterceptor implements HttpInterceptor { + constructor(private modeService: FederationModeService, + private instanceState: FederationInstanceStateService) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.modeService.isActive) { + return next.handle(request); + } + if (!this.isApiRequest(request.url)) { + return next.handle(request); + } + if (this.isAuthEndpoint(request.url)) { + return next.handle(request); + } + const instance = this.instanceState.current; + if (!instance) { + return next.handle(request); + } + const cloned = request.clone({ + setHeaders: {'X-UTM-Instance': String(instance.id)} + }); + return next.handle(cloned); + } + + private isApiRequest(url: string): boolean { + if (!url) { + return false; + } + if (/^https?:/i.test(url)) { + return !!SERVER_API_URL && url.startsWith(SERVER_API_URL); + } + return true; + } + + private isAuthEndpoint(url: string): boolean { + return AUTH_PATH_PREFIXES.some(prefix => url.includes(prefix)); + } +} diff --git a/frontend/src/app/core/auth/account.service.ts b/frontend/src/app/core/auth/account.service.ts index e117bf6f0..0771dafd6 100644 --- a/frontend/src/app/core/auth/account.service.ts +++ b/frontend/src/app/core/auth/account.service.ts @@ -4,6 +4,9 @@ import {Observable, Subject} from 'rxjs'; import {SERVER_API_URL} from '../../app.constants'; import {HttpCancelService} from '../../blocks/service/httpcancel.service'; +import {FederationInstancesService} from '../../federation/services/federation-instances.service'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; import {Account} from '../user/account.model'; import {AuthServerProvider} from './auth-jwt.service'; import {extractQueryParamsForNavigation} from "../../shared/util/query-params-to-filter.util"; @@ -18,6 +21,7 @@ export class AccountService { private userIdentity: Account; private authenticated = false; private authenticationState = new Subject(); + private instancesFetchInFlight = false; constructor(private http: HttpClient, private authServerProvider: AuthServerProvider, @@ -25,7 +29,10 @@ export class AccountService { private stateStorageService: StateStorageService, private router: Router, private spinner: NgxSpinnerService, - private utmToast: UtmToastService) { + private utmToast: UtmToastService, + private federationModeService: FederationModeService, + private federationInstancesService: FederationInstancesService, + private federationInstanceState: FederationInstanceStateService) { } fetch(): Observable> { @@ -100,10 +107,13 @@ export class AccountService { if (account) { this.userIdentity = account; this.authenticated = true; - } else { - this.userIdentity = null; - this.authenticated = false; + return this.ensureFederationInstancesLoaded().then(() => { + this.authenticationState.next(this.userIdentity); + return this.userIdentity; + }); } + this.userIdentity = null; + this.authenticated = false; this.authenticationState.next(this.userIdentity); return this.userIdentity; }) @@ -141,9 +151,36 @@ export class AccountService { } } + private ensureFederationInstancesLoaded(): Promise { + if (!this.federationModeService.isActive) { + return Promise.resolve(); + } + if (this.federationInstanceState.instances.length > 0) { + return Promise.resolve(); + } + if (this.instancesFetchInFlight) { + return Promise.resolve(); + } + this.instancesFetchInFlight = true; + return this.federationInstancesService.list() + .toPromise() + .then(instances => { + this.instancesFetchInFlight = false; + this.federationInstanceState.setInstances(instances || []); + }) + .catch(() => { + this.instancesFetchInFlight = false; + }); + } + startNavigation() { this.identity(true).then(account => { if (account) { + if (this.federationModeService.isActive && this.federationInstanceState.instances.length === 0) { + this.router.navigate(['/federation/welcome']) + .then(() => this.spinner.hide()); + return; + } const { path, queryParams } = extractQueryParamsForNavigation(this.stateStorageService.getUrl() ? this.stateStorageService.getUrl() : '' ); if (path) { @@ -151,7 +188,6 @@ export class AccountService { } const redirectTo = (account.authorities.includes(ADMIN_ROLE) && account.email === ADMIN_DEFAULT_EMAIL) ? '/getting-started' : !!path ? path : '/dashboard/overview'; - console.log(redirectTo); this.router.navigate([redirectTo], {queryParams}) .then(() => this.spinner.hide()); } else { diff --git a/frontend/src/app/core/auth/user-route-access-service.ts b/frontend/src/app/core/auth/user-route-access-service.ts index 90fc38195..cb5116578 100644 --- a/frontend/src/app/core/auth/user-route-access-service.ts +++ b/frontend/src/app/core/auth/user-route-access-service.ts @@ -2,15 +2,21 @@ import {Injectable, isDevMode} from '@angular/core'; import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router'; import {ModalService} from '../modal/modal.service'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; import {AccountService} from './account.service'; import {StateStorageService} from './state-storage.service'; +const FEDERATION_WELCOME_URL = '/federation/welcome'; + @Injectable({providedIn: 'root'}) export class UserRouteAccessService implements CanActivate { constructor( private router: Router, private accountService: AccountService, - private stateStorageService: StateStorageService + private stateStorageService: StateStorageService, + private federationModeService: FederationModeService, + private federationInstanceState: FederationInstanceStateService ) { } @@ -28,6 +34,14 @@ export class UserRouteAccessService implements CanActivate { return false; } if (account) { + if ( + this.federationModeService.isActive && + this.federationInstanceState.instances.length === 0 && + !url.startsWith(FEDERATION_WELCOME_URL) + ) { + this.router.navigate([FEDERATION_WELCOME_URL]); + return false; + } const hasAnyAuthority = this.accountService.hasAnyAuthority(authorities); if (hasAnyAuthority) { return true; diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html new file mode 100644 index 000000000..94a220440 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html @@ -0,0 +1,50 @@ +
+
+
+
Loading federation overview…
+
+ +
+ +
{{ errorMessage }}
+ +
+ +
+ +
No active instances are reporting data.
+
+ +
+ +
+ + +
+
+ +
+ + add a new instance +
+ + +
+
diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss new file mode 100644 index 000000000..cd12eeac6 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss @@ -0,0 +1,157 @@ +:host { display: block; width: 100%; } + +.overview-grid { + width: 100%; + padding: 16px; + + &__items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + } + + &__state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 280px; + color: #6b7280; + } + + &__state-label { + font-size: 14px; + color: #475569; + text-align: center; + max-width: 360px; + } + + &__state-icon { + font-size: 48px; + + &--error { color: #dc3545; font-size: 36px; } + &--muted { color: #232f3e; opacity: 0.4; } + } + + &__spinner { + width: 36px; + height: 36px; + border-radius: 50%; + border: 3px solid #e5e7eb; + border-top-color: #232f3e; + animation: federation-overview-spin 0.9s linear infinite; + } +} + +@keyframes federation-overview-spin { + to { transform: rotate(360deg); } +} + + + +.overview-create-card { + cursor:pointer; + position:relative; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + height: 100%; + min-height: 320px; + + &__icon { + font-size: 48px; + color: #6b7280; + opacity: 0.6; + } + + &__title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + text-align: center; + } + + &__rows { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__row { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__row-label { + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__row-meters { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + min-width: 0; + justify-items: center; + } + + &__gauge { + display: block; + min-width: 0; + + ::ng-deep .reading-block { + font-size: 14px !important; + font-weight: 600; + } + + ::ng-deep .reading-label { + font-size: 10px !important; + color: #475569; + } + } + + &__empty { + flex: 1 1 auto; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6b7280; + text-align: center; + padding: 16px; + } + + &__empty-title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + margin-bottom: 6px; + } + + &__empty-msg { + font-size: 12px; + color: #9ca3af; + } + + &__nav { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + border-top: 1px solid #f1f3f5; + padding-top: 10px; + } +} diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts new file mode 100644 index 000000000..18acd9aa8 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -0,0 +1,200 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {forkJoin, of, Subject} from 'rxjs'; +import {catchError, takeUntil} from 'rxjs/operators'; +import {resolveRangeByTime} from '../../../shared/util/resolve-date'; +import {CountAlertsBySeverityEntry} from '../../domain/count-alerts-by-severity.model'; +import {CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; +import {FederationOverviewService} from '../../services/federation-overview.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstancesService} from '../../services/federation-instances.service'; +import {InstanceFormModalComponent} from '../instance-form-modal/instance-form-modal.component'; +import { + ModalConfirmationComponent +} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; + +@Component({ + selector: 'app-federation-overview-grid', + templateUrl: './federation-overview-grid.component.html', + styleUrls: ['./federation-overview-grid.component.scss'] +}) +export class FederationOverviewGridComponent implements OnInit, OnDestroy { + entries: CountAlertsByStatusEntry[] = []; + severityByInstance: Record = {}; + loading = false; + errorMessage: string | null = null; + pendingDeleteId: number | null = null; + + private destroy$ = new Subject(); + + constructor( + private overviewService: FederationOverviewService, + private instanceState: FederationInstanceStateService, + private router: Router, + private instancesService: FederationInstancesService, + private modalService: NgbModal + ) {} + + ngOnInit(): void { + this.loadData(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadData(): void { + const range = resolveRangeByTime('week'); + if (!range.timeFrom || !range.timeTo) { + return; + } + this.loading = true; + this.errorMessage = null; + forkJoin([ + this.overviewService.countAlertsByStatus(range.timeFrom, range.timeTo), + this.overviewService.countAlertsBySeverity(range.timeFrom, range.timeTo) + .pipe(catchError(() => of([] as CountAlertsBySeverityEntry[]))) + ]) + .pipe(takeUntil(this.destroy$)) + .subscribe( + ([status, severity]: [CountAlertsByStatusEntry[], CountAlertsBySeverityEntry[]]) => { + this.entries = status || []; + this.severityByInstance = {}; + (severity || []).forEach(entry => { + this.severityByInstance[entry.instanceId] = entry; + }); + this.loading = false; + }, + () => { + this.entries = []; + this.severityByInstance = {}; + this.errorMessage = 'Could not load federation overview.'; + this.loading = false; + } + ); + } + + severityFor(entry: CountAlertsByStatusEntry): CountAlertsBySeverityEntry | null { + return this.severityByInstance[entry.instanceId] || null; + } + + onSelect(entry: CountAlertsByStatusEntry, route: string): void { + const target = this.instanceState.instances.find(i => i.id === entry.instanceId); + if (target) { + this.instanceState.setActive(target, true); + } + + this.router.navigateByUrl(route); + } + + trackByInstance(_index: number, entry: CountAlertsByStatusEntry): number { + return entry.instanceId; + } + + + + openCreate(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.saved.subscribe((instance: FederationInstance) => { + ref.close(); + this.reload(instance.id, true); + }); + } + + openEdit(instance: CountAlertsByStatusEntry, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + const target = this.instanceState.instances.find(i => i.id === instance.instanceId); + ref.componentInstance.instance = target; + ref.componentInstance.saved.subscribe((updated: FederationInstance) => { + ref.close(); + this.reload(updated.id, false); + }); + } + + remove(instance: CountAlertsByStatusEntry, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (this.pendingDeleteId !== null) { + return; + } + + const target = this.instanceState.instances.find(i => i.id === instance.instanceId); + if(!target)return + const modalRef = this.modalService.open(ModalConfirmationComponent, {backdrop: 'static', centered: true}); + modalRef.componentInstance.header = 'Remove federation instance'; + modalRef.componentInstance.message = `Are you sure that you want to remove the instance: \n${target.name}?`; + modalRef.componentInstance.confirmBtnText = 'Remove'; + modalRef.componentInstance.confirmBtnIcon = 'icon-cancel-circle2'; + modalRef.componentInstance.confirmBtnType = 'delete'; + modalRef.componentInstance.textDisplay = 'Requests routed to this instance will stop working' + + ' and its locally stored selection will be cleared.'; + modalRef.componentInstance.textType = 'warning'; + modalRef.result.then(() => this.confirmRemove(target), () => undefined); + } + + trackById(_index: number, item: FederationInstance): number { + return item.id; + } + + private confirmRemove(instance: FederationInstance): void { + this.pendingDeleteId = instance.id; + this.errorMessage = null; + this.instancesService.delete(instance.id).subscribe({ + next: () => { + this.pendingDeleteId = null; + this.reload(null, false); + }, + error: err => { + this.pendingDeleteId = null; + this.errorMessage = (err && err.error && err.error.message) + || 'Failed to remove instance.'; + } + }); + } + + private reload(preferredId: number | null, forceSwitch: boolean): void { + this.instancesService.list().subscribe({ + next: items => { + const list = items || []; + this.instanceState.setInstances(list); + if (list.length === 0) { + window.location.reload(); + return; + } + this.loadData(); + if (preferredId !== null) { + const target = list.find(i => i.id === preferredId); + if (target) { + this.instanceState.setActive(target, false); + return; + } + } + if (forceSwitch) { + window.location.reload(); + } + }, + error: () => { + this.errorMessage = 'Failed to refresh instances.'; + } + }); + } + +} diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html new file mode 100644 index 000000000..05ceb7322 --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -0,0 +1,50 @@ +
+ + +
+
Instances
+ +
{{ errorMessage }}
+ +
+ No instances connected. +
+ + + +
+ + +
+
diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss new file mode 100644 index 000000000..1708006d8 --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss @@ -0,0 +1,147 @@ +@import "src/assets/styles/var"; + +.federation-instance-selector { + display: inline-flex; + align-items: center; + height: 100%; + + &__toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 180px; + max-width: 260px; + padding: 6px 10px; + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 4px; + color: #ffffff; + font-size: 13px; + line-height: 1.2; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover, + &:focus { + background-color: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.28); + outline: none; + } + + &::after { + display: none; + } + } + + &__lead { + font-size: 16px; + color: #ffffff; + flex: 0 0 auto; + } + + &__label { + flex: 1 1 auto; + text-align: left; + overflow: hidden; + white-space: nowrap; + } + + &__caret { + font-size: 12px; + color: rgba(255, 255, 255, 0.75); + flex: 0 0 auto; + margin-left: auto; + } +} + +::ng-deep .federation-instance-menu { + min-width: 260px; + max-width: 320px; + padding: 6px 0; + + .dropdown-header { + font-size: 11px; + letter-spacing: 0.04em; + color: #6b7280; + padding: 6px 12px; + } + + .federation-instance-row { + cursor: pointer; + padding: 8px 12px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + color: #232f3e; + gap: 8px; + + &:hover, + &:focus { + background-color: rgba(35, 47, 62, 0.06); + outline: none; + } + + &.active { + background-color: rgba(35, 47, 62, 0.08); + color: #0a6ebd; + font-weight: 500; + } + } + + .federation-instance-row__check { + width: 14px; + font-size: 12px; + flex: 0 0 auto; + } + + .federation-instance-row__name { + font-size: 13px; + } + + .federation-instance-row__version { + font-size: 11px; + color: #6b7280; + background-color: rgba(35, 47, 62, 0.06); + border-radius: 10px; + padding: 2px 8px; + flex: 0 0 auto; + margin-left: 8px; + } + + .invisible { + visibility: hidden; + } + + .dropdown-divider { + margin: 6px 0; + } + + .federation-instance-manage { + cursor: pointer; + padding: 8px 12px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + color: #232f3e; + gap: 8px; + font-weight: 500; + + &:hover, + &:focus { + background-color: rgba(35, 47, 62, 0.06); + outline: none; + } + } + + .federation-instance-manage__icon { + font-size: 14px; + flex: 0 0 auto; + color: #475569; + } + + .federation-instance-manage__label { + font-size: 13px; + } +} diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts new file mode 100644 index 000000000..c1ae4130f --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts @@ -0,0 +1,61 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; + +@Component({ + selector: 'app-federation-sidebar-instances', + templateUrl: './federation-sidebar-instances.component.html', + styleUrls: ['./federation-sidebar-instances.component.scss'] +}) +export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { + iconPath = SYSTEM_MENU_ICONS_PATH; + instances: FederationInstance[] = []; + active: FederationInstance | null = null; + pendingDeleteId: number | null = null; + errorMessage: string | null = null; + private destroy$ = new Subject(); + + constructor(private instanceState: FederationInstanceStateService, + private router: Router) {} + + + ngOnInit(): void { + this.instanceState.instances$ + .pipe(takeUntil(this.destroy$)) + .subscribe(items => this.instances = items); + this.instanceState.active$ + .pipe(takeUntil(this.destroy$)) + .subscribe(item => this.active = item); + } + + select(instance: FederationInstance, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (this.active && this.active.id === instance.id) { + return; + } + this.instanceState.setActive(instance); + window.location.reload(); + } + + trackById(_index: number, instance: FederationInstance): number { + return instance.id; + } + + manageInstances(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.router.navigate(['/federation/welcome']); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html new file mode 100644 index 000000000..89bd498cb --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html @@ -0,0 +1,8 @@ +
+
    + +
  • + +
  • +
+
diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss new file mode 100644 index 000000000..7bfaafce4 --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss @@ -0,0 +1,77 @@ +@import "src/assets/styles/theme"; +@import "src/assets/styles/var"; + +.federation-sidebar { + width: 100%; + height: 100%; + padding: 0; + overflow: visible; +} + +.federation-sidebar-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: row; + align-items: center; +} + +.federation-sidebar-footer { + margin-left: auto; +} + +.federation-sidebar-item { + padding: 0 4px; + cursor: pointer; + display: flex; + align-items: center; + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + + ::ng-deep svg { + fill: #ffffff !important; + } + } + + &.active { + background-color: rgba(255, 255, 255, 0.12); + + ::ng-deep svg { + fill: $active !important; + } + } +} + +.federation-sidebar-link { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 8px 10px; + + &:hover, + &:focus { + text-decoration: none; + outline: none; + } + + ::ng-deep svg { + fill: #ffffff !important; + } +} + +.federation-sidebar-fa { + color: #ffffff; + font-size: 18px; + line-height: 1; +} + +.federation-sidebar-divider { + width: 1px; + height: 24px; + margin: 0 8px; + background-color: rgba(255, 255, 255, 0.12); + list-style: none; +} diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.ts b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.ts new file mode 100644 index 000000000..407cf852d --- /dev/null +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.ts @@ -0,0 +1,18 @@ +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; +import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; + +@Component({ + selector: 'app-federation-sidebar', + templateUrl: './federation-sidebar.component.html', + styleUrls: ['./federation-sidebar.component.scss'] +}) +export class FederationSidebarComponent { + iconPath = SYSTEM_MENU_ICONS_PATH; + + constructor(private router: Router) {} + + isActive(link: string): boolean { + return this.router.url.startsWith(link); + } +} diff --git a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html new file mode 100644 index 000000000..129948c51 --- /dev/null +++ b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html @@ -0,0 +1,36 @@ +
+
+
+
+
+ + + Profile Settings + + + + Team Members + + + + Email Configuration + + + + Change Password + + + + Sign Out + +
+
diff --git a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.scss b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.scss new file mode 100644 index 000000000..2e32a8fe5 --- /dev/null +++ b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.scss @@ -0,0 +1,16 @@ +:host { + display: inline-flex; + align-items: center; + height: 100%; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + + i { + margin-right: 8px; + } +} diff --git a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts new file mode 100644 index 000000000..e1d6dbecc --- /dev/null +++ b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts @@ -0,0 +1,42 @@ +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {LoginService} from '../../../core/login/login.service'; +import { + PasswordComponent +} from '../../../shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component'; +import { + SettingsComponent +} from '../../../shared/components/layout/header/shared/notification/utm-notification-user-setting/settings/settings.component'; + +@Component({ + selector: 'app-federation-user-menu', + templateUrl: './federation-user-menu.component.html', + styleUrls: ['./federation-user-menu.component.scss'] +}) +export class FederationUserMenuComponent { + constructor(private router: Router, + private modalService: NgbModal, + private loginService: LoginService) {} + + goToTeamMembers(): void { + this.router.navigate(['/federation/team-members']); + } + + goToEmailConfig(): void { + this.router.navigate(['/federation/email-config']); + } + + openProfileSettings(): void { + this.modalService.open(SettingsComponent, {centered: true}); + } + + openChangePassword(): void { + this.modalService.open(PasswordComponent, {centered: true}); + } + + signOut(): void { + this.router.navigate(['/']); + this.loginService.logout(); + } +} diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html new file mode 100644 index 000000000..9e7e3ecfa --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html @@ -0,0 +1,44 @@ +
+
{{ title }}
+ +
+
+
+
{{ errorMessage }}
+ +
+ + +
+ +
+ + +
+ +
+ + + {{ apiKeyHint }} +
+ +
+ + +
+
+
+ + +
+
diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts new file mode 100644 index 000000000..38e9c4d82 --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts @@ -0,0 +1,91 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceInput, FederationInstancesService} from '../../services/federation-instances.service'; + +interface InstanceFormValue { + name: string; + baseUrl: string; + apiKey: string; + tlsSkipVerify: boolean; +} + +@Component({ + selector: 'app-federation-instance-form-modal', + templateUrl: './instance-form-modal.component.html', + styleUrls: ['./instance-form-modal.component.scss'] +}) +export class InstanceFormModalComponent { + @Input() instance: FederationInstance | null = null; + @Output() saved = new EventEmitter(); + submitting = false; + errorMessage: string | null = null; + model: InstanceFormValue = { + name: '', + baseUrl: '', + apiKey: '', + tlsSkipVerify: false + }; + + constructor(public activeModal: NgbActiveModal, + private instancesService: FederationInstancesService) {} + + ngOnInit(): void { + if (this.instance) { + this.model = { + name: this.instance.name, + baseUrl: this.instance.baseUrl, + apiKey: '', + tlsSkipVerify: this.instance.tlsSkipVerify + }; + } + } + + get isEditMode(): boolean { + return this.instance !== null; + } + + get title(): string { + return this.isEditMode ? 'Edit instance' : 'Connect instance'; + } + + get apiKeyHint(): string { + return this.isEditMode + ? 'Leave empty to keep the existing API key.' + : ''; + } + + submit(): void { + if (this.submitting) { + return; + } + this.submitting = true; + this.errorMessage = null; + const payload: FederationInstanceInput = { + name: this.model.name, + baseUrl: this.model.baseUrl, + tlsSkipVerify: this.model.tlsSkipVerify + }; + if (this.model.apiKey) { + payload.apiKey = this.model.apiKey; + } + const action$ = this.isEditMode && this.instance + ? this.instancesService.update(this.instance.id, payload) + : this.instancesService.create(payload); + action$.subscribe({ + next: instance => { + this.submitting = false; + this.saved.emit(instance); + }, + error: err => { + this.submitting = false; + this.errorMessage = (err && err.error && err.error.message) + || 'Failed to save instance.'; + } + }); + } + + cancel(): void { + this.activeModal.dismiss(); + } +} diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html new file mode 100644 index 000000000..f90286ea8 --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -0,0 +1,67 @@ +
+
{{ entry.instanceName }}
+ +
+ +
+ +
+
+
Severity
+
+ +
+
+ +
+
Status
+
+ +
+
+
+ +
+
No alerts in window
+
+ +
+ +
+
diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss new file mode 100644 index 000000000..c52a4818d --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss @@ -0,0 +1,167 @@ +:host { display: block; height: 100%; } + +.overview-card__actions{ + position:absolute; + right:2%; + top:2%; +} + +.overview-card { + position:relative; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + height: 100%; + min-height: 320px; + + &__title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + text-align: center; + } + + &__rows { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__row { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__row-label { + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__row-meters { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + min-width: 0; + justify-items: center; + } + + &__gauge { + display: block; + min-width: 0; + + ::ng-deep .reading-block { + font-size: 14px !important; + font-weight: 600; + } + + ::ng-deep .reading-label { + font-size: 10px !important; + color: #475569; + } + } + + &__empty { + flex: 1 1 auto; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6b7280; + text-align: center; + padding: 16px; + } + + &__empty-title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + margin-bottom: 6px; + } + + &__empty-msg { + font-size: 12px; + color: #9ca3af; + } + + &__nav { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + border-top: 1px solid #f1f3f5; + padding-top: 10px; + } +} + +.nav-icon { + appearance: none; + background: none; + border: 1px solid transparent; + border-radius: 6px; + padding: 8px 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: #475569; + cursor: pointer; + font-size: 10px; + transition: background 0.15s, color 0.15s, border-color 0.15s; + min-width: 0; + position: relative; + + i { + font-size: 18px; + } + + &:hover { + background: #f1f5f9; + color: #232f3e; + border-color: #e2e8f0; + } +} + +.nav-icon__graphic { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.nav-icon__badge { + position: absolute; + top: -6px; + right: -10px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background-color: #dc3545; + color: #ffffff; + font-size: 4px; + font-weight: 600; + line-height: 16px; + text-align: center; + transform: translateX(50%); + border-radius: 8px; + box-shadow: 0 0 0 2px #ffffff; + white-space: nowrap; +} + +.nav-icon__label { + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + text-align: center; +} diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts new file mode 100644 index 000000000..6228b3459 --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -0,0 +1,86 @@ +import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import { + AlertSeveritySerie, + CountAlertsBySeverityEntry +} from '../../domain/count-alerts-by-severity.model'; +import {AlertStatusSerie, CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; +import {FEDERATION_NAV_ACTIONS, NavAction} from '../../domain/federation-nav-actions'; +import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; + +interface MeterDatum { + label: string; + value: number; + color: string; +} + +const STATUS_COLORS: Record = { + 'Open': '#28a745', + 'In review': '#ffc107', + 'Completed': '#17a2b8' +}; + +const SEVERITY_COLORS: Record = { + 'High': '#dc3545', + 'Medium': '#ffc107', + 'Low': '#0a6ebd' +}; + +const SEVERITY_ORDER: ReadonlyArray = ['High', 'Medium', 'Low']; +const STATUS_ORDER: ReadonlyArray = ['Open', 'In review', 'Completed']; + +@Component({ + selector: 'app-instance-overview-card', + templateUrl: './instance-overview-card.component.html', + styleUrls: ['./instance-overview-card.component.scss'] +}) +export class InstanceOverviewCardComponent implements OnChanges { + @Input() entry!: CountAlertsByStatusEntry; + @Input() severityEntry: CountAlertsBySeverityEntry | null = null; + @Output() select = new EventEmitter(); + iconPath=SYSTEM_MENU_ICONS_PATH + + readonly actions: ReadonlyArray = FEDERATION_NAV_ACTIONS; + severityMeters: MeterDatum[] = []; + statusMeters: MeterDatum[] = []; + severityMax = 0; + statusMax = 0; + totalAlerts = 0; + totalSeverity = 0; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.entry && this.entry) { + const buckets = this.entry.data || []; + this.totalAlerts = buckets.reduce((sum, bucket) => sum + bucket.value, 0); + this.statusMeters = STATUS_ORDER.map(serie => { + const bucket = buckets.find(b => b.serie === serie); + return { + label: serie, + value: bucket ? bucket.value : 0, + color: STATUS_COLORS[serie] + }; + }); + this.statusMax = this.statusMeters.reduce((m, d) => m+d.value, 0); + } + if (changes.severityEntry) { + const buckets = this.severityEntry && this.severityEntry.data ? this.severityEntry.data.value : []; + this.totalSeverity = buckets.reduce((sum, bucket) => sum + bucket.value, 0); + this.severityMeters = SEVERITY_ORDER.map(serie => { + const bucket = buckets.find(b => b.name === serie); + return { + label: serie, + value: bucket ? bucket.value : 0, + color: SEVERITY_COLORS[serie] + }; + }); + this.severityMax = this.severityMeters.reduce((m, d) => Math.max(m, d.value), 0); + } + } + + get hasData(): boolean { + return this.totalAlerts > 0 || this.totalSeverity > 0; + } + + onSelect(route: string): void { + this.select.emit(route); + } +} diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html new file mode 100644 index 000000000..df7dd0163 --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html @@ -0,0 +1,52 @@ +
+
{{ title }}
+ +
+
+
+
{{ errorMessage }}
+ +
+ + + + Unique identifier used to sign in. + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

+ An invitation email with a link to set a password will be sent. +

+
+
+ + +
+
diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss new file mode 100644 index 000000000..bf7567209 --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss @@ -0,0 +1,5 @@ +.modal-body { + .alert { + margin-bottom: 1rem; + } +} diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts new file mode 100644 index 000000000..98208391b --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts @@ -0,0 +1,111 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import { + TeamUser, + TeamUserCreatePayload, + TeamUserUpdatePayload +} from '../../domain/team-user.model'; +import {FederationTeamService} from '../../services/federation-team.service'; + +interface TeamUserFormValue { + login: string; + email: string; + first_name: string; + last_name: string; +} + +@Component({ + selector: 'app-federation-team-user-form-modal', + templateUrl: './team-user-form-modal.component.html', + styleUrls: ['./team-user-form-modal.component.scss'] +}) +export class TeamUserFormModalComponent implements OnInit { + @Input() user: TeamUser | null = null; + @Output() saved = new EventEmitter(); + + submitting = false; + errorMessage: string | null = null; + model: TeamUserFormValue = { + login: '', + email: '', + first_name: '', + last_name: '' + }; + + constructor(public activeModal: NgbActiveModal, + private teamService: FederationTeamService) {} + + ngOnInit(): void { + if (this.user) { + this.model = { + login: this.user.login, + email: this.user.email || '', + first_name: this.user.first_name || '', + last_name: this.user.last_name || '' + }; + } + } + + get isEditMode(): boolean { + return this.user !== null; + } + + get title(): string { + return this.isEditMode ? 'Edit team member' : 'Invite team member'; + } + + submit(): void { + if (this.submitting) { + return; + } + this.submitting = true; + this.errorMessage = null; + + const action$ = this.isEditMode && this.user + ? this.teamService.update(this.user.id, this.buildUpdatePayload()) + : this.teamService.create(this.buildCreatePayload()); + + action$.subscribe({ + next: result => { + this.submitting = false; + this.saved.emit(result); + }, + error: err => { + this.submitting = false; + this.errorMessage = this.extractError(err); + } + }); + } + + cancel(): void { + this.activeModal.dismiss(); + } + + private buildCreatePayload(): TeamUserCreatePayload { + return { + login: this.model.login.trim(), + email: this.model.email.trim(), + first_name: this.model.first_name.trim(), + last_name: this.model.last_name.trim(), + lang_key: 'en' + }; + } + + private buildUpdatePayload(): TeamUserUpdatePayload { + return { + email: this.model.email.trim(), + first_name: this.model.first_name.trim(), + last_name: this.model.last_name.trim() + }; + } + + private extractError(err: {status?: number; error?: {message?: string}}): string { + if (err && err.status === 409) { + return 'Login or email is already in use.'; + } + if (err && err.error && err.error.message) { + return err.error.message; + } + return 'Failed to save team member.'; + } +} diff --git a/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts new file mode 100644 index 000000000..35ed68c5c --- /dev/null +++ b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts @@ -0,0 +1,12 @@ +export type AlertSeveritySerie = 'Low' | 'Medium' | 'High'; + +export interface CountAlertsBySeverityBucket { + name: AlertSeveritySerie; + value: number; +} + +export interface CountAlertsBySeverityEntry { + instanceId: number; + instanceName: string; + data: {value:CountAlertsBySeverityBucket[]}; +} diff --git a/frontend/src/app/federation/domain/count-alerts-by-status.model.ts b/frontend/src/app/federation/domain/count-alerts-by-status.model.ts new file mode 100644 index 000000000..5293522a1 --- /dev/null +++ b/frontend/src/app/federation/domain/count-alerts-by-status.model.ts @@ -0,0 +1,12 @@ +export type AlertStatusSerie = 'Open' | 'In review' | 'Completed'; + +export interface CountAlertsByStatusBucket { + serie: AlertStatusSerie; + value: number; +} + +export interface CountAlertsByStatusEntry { + instanceId: number; + instanceName: string; + data: CountAlertsByStatusBucket[]; +} diff --git a/frontend/src/app/federation/domain/federation-instance.model.ts b/frontend/src/app/federation/domain/federation-instance.model.ts new file mode 100644 index 000000000..cdc65e3cf --- /dev/null +++ b/frontend/src/app/federation/domain/federation-instance.model.ts @@ -0,0 +1,9 @@ +export interface FederationInstance { + id: number; + name: string; + baseUrl: string; + tlsSkipVerify: boolean; + createdAt: string; + updatedAt: string; + version?: string; +} diff --git a/frontend/src/app/federation/domain/federation-mode.model.ts b/frontend/src/app/federation/domain/federation-mode.model.ts new file mode 100644 index 000000000..317f2050e --- /dev/null +++ b/frontend/src/app/federation/domain/federation-mode.model.ts @@ -0,0 +1,4 @@ +export interface FederationMode { + federation: boolean; + version: string; +} diff --git a/frontend/src/app/federation/domain/federation-nav-actions.ts b/frontend/src/app/federation/domain/federation-nav-actions.ts new file mode 100644 index 000000000..0c3bc57b5 --- /dev/null +++ b/frontend/src/app/federation/domain/federation-nav-actions.ts @@ -0,0 +1,13 @@ +export interface NavAction { + readonly label: string; + readonly iconClass: string; + readonly route: string; + readonly counter: boolean +} + +export const FEDERATION_NAV_ACTIONS: ReadonlyArray = [ + {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view?alertType=ALERT', counter:true}, + {label: 'Log Explorer', iconClass: 'ANALYTICS.svg', route: '/discover/log-analyzer',counter:false}, + {label: 'SOAR Flows', iconClass: 'VISION.svg', route: '/soar/flows',counter:false}, + {label: 'Data Sources', iconClass: 'NETWORK.svg', route: '/data-sources',counter:false} +]; diff --git a/frontend/src/app/federation/domain/team-user.model.ts b/frontend/src/app/federation/domain/team-user.model.ts new file mode 100644 index 000000000..4366842f5 --- /dev/null +++ b/frontend/src/app/federation/domain/team-user.model.ts @@ -0,0 +1,45 @@ +export interface TeamUser { + id: number; + login: string; + email?: string; + first_name?: string; + last_name?: string; + image_url?: string; + activated: boolean; + pending: boolean; + tfa_enabled: boolean; + created_by?: string; + last_modified_by?: string; + last_modified_date?: string; + created_at: string; +} + +export interface TeamUserPageInfo { + page: number; + page_size: number; + total_items: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} + +export interface TeamUserCreatePayload { + login: string; + email: string; + first_name: string; + last_name: string; + lang_key: string; +} + +export interface TeamUserUpdatePayload { + email: string; + first_name: string; + last_name: string; + activated?: boolean; +} + +export interface TeamUserListQuery { + page?: number; + page_size?: number; + search?: string; +} diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts new file mode 100644 index 000000000..ee224995e --- /dev/null +++ b/frontend/src/app/federation/federation.module.ts @@ -0,0 +1,60 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {InlineSVGModule} from 'ng-inline-svg'; +import {NgxEchartsModule} from 'ngx-echarts'; +import {NgxGaugeModule} from 'ngx-gauge'; +import {FederationOverviewGridComponent} from './components/federation-overview-grid/federation-overview-grid.component'; +import {FederationSidebarComponent} from './components/federation-sidebar/federation-sidebar.component'; +import { + FederationSidebarInstancesComponent +} from './components/federation-sidebar-instances/federation-sidebar-instances.component'; +import {FederationUserMenuComponent} from './components/federation-user-menu/federation-user-menu.component'; +import {InstanceFormModalComponent} from './components/instance-form-modal/instance-form-modal.component'; +import {InstanceOverviewCardComponent} from './components/instance-overview-card/instance-overview-card.component'; +import {TeamUserFormModalComponent} from './components/team-user-form-modal/team-user-form-modal.component'; +import { + TeamMembersPaginationComponent +} from './pages/team-members/team-members-pagination.component'; +import {TeamMembersSearchComponent} from './pages/team-members/team-members-search.component'; +import {TeamMembersTableComponent} from './pages/team-members/team-members-table.component'; +import {TeamMembersPageComponent} from './pages/team-members/team-members.page.component'; +import {WelcomeComponent} from './pages/welcome/welcome.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + RouterModule, + NgbModule, + InlineSVGModule, + NgxEchartsModule, + NgxGaugeModule + ], + declarations: [ + FederationSidebarComponent, + FederationSidebarInstancesComponent, + FederationUserMenuComponent, + InstanceFormModalComponent, + TeamUserFormModalComponent, + WelcomeComponent, + FederationOverviewGridComponent, + InstanceOverviewCardComponent, + TeamMembersPageComponent, + TeamMembersTableComponent, + TeamMembersSearchComponent, + TeamMembersPaginationComponent + ], + exports: [ + FederationSidebarComponent, + FederationUserMenuComponent, + FederationOverviewGridComponent + ], + entryComponents: [ + InstanceFormModalComponent, + TeamUserFormModalComponent + ] +}) +export class FederationModule {} diff --git a/frontend/src/app/federation/guards/federation-disabled.guard.ts b/frontend/src/app/federation/guards/federation-disabled.guard.ts new file mode 100644 index 000000000..0b2515530 --- /dev/null +++ b/frontend/src/app/federation/guards/federation-disabled.guard.ts @@ -0,0 +1,21 @@ +import {Injectable} from '@angular/core'; +import {CanActivate, CanActivateChild, Router} from '@angular/router'; +import {FederationModeService} from '../services/federation-mode.service'; + +@Injectable({providedIn: 'root'}) +export class FederationDisabledGuard implements CanActivate, CanActivateChild { + constructor(private federationModeService: FederationModeService, + private router: Router) {} + + canActivate(): boolean { + if (this.federationModeService.isActive) { + this.router.navigate(['/federation/welcome']); + return false; + } + return true; + } + + canActivateChild(): boolean { + return this.canActivate(); + } +} diff --git a/frontend/src/app/federation/hooks/federation-bootstrap.ts b/frontend/src/app/federation/hooks/federation-bootstrap.ts new file mode 100644 index 000000000..ca16c3942 --- /dev/null +++ b/frontend/src/app/federation/hooks/federation-bootstrap.ts @@ -0,0 +1,10 @@ +import {FederationModeService} from '../services/federation-mode.service'; + +export function initFederationMode(modeService: FederationModeService): () => Promise { + return () => new Promise(resolve => { + modeService.detect().subscribe({ + next: () => resolve(), + error: () => resolve() + }); + }); +} diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts b/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts new file mode 100644 index 000000000..78d67db73 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts @@ -0,0 +1,129 @@ +import { + ConfigDataTypeEnum, + SectionConfigParamType +} from '../../../shared/types/configuration/section-config-param.type'; +import {SectionConfigType} from '../../../shared/types/configuration/section-config.type'; + +const EMAIL_SECTION = ({ + id: 2, + section: 'email', + shortName: 'EMAIL', + description: 'Here you can configure all necessary parameters for email notifications' +} as unknown) as SectionConfigType; + +export const FEDERATION_EMAIL_CONFIG_SECTION: SectionConfigType = EMAIL_SECTION; + +export const FEDERATION_EMAIL_CONFIG_PARAMS: SectionConfigParamType[] = ([ + { + id: 4, + sectionId: 2, + confParamShort: 'utmstack.mail.password', + confParamLarge: 'Mail Server Password', + confParamDescription: 'Login password of the SMTP server', + confParamValue: null, + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Password, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 5, + sectionId: 2, + confParamShort: 'utmstack.mail.from', + confParamLarge: 'Utmstack email address', + confParamDescription: 'Address from which emails are sent', + confParamValue: null, + confParamRegexp: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Email, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 6, + sectionId: 2, + confParamShort: 'utmstack.mail.baseUrl', + confParamLarge: 'Utmstack base url', + confParamDescription: 'Base url of Utmstack', + confParamValue: 'https://v11dev2.utmstack.com', + confParamRegexp: '^(?:https?:\\/\\/)?(?:\\d{1,3}(?:\\.\\d{1,3}){3}|(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,})(?:\\/[^\\s]*)?$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 7, + sectionId: 2, + confParamShort: 'utmstack.mail.host', + confParamLarge: 'Mail Server Host', + confParamDescription: 'SMTP server host. For instance, `smtp.example.com`.', + confParamValue: 'example.hostmail.com', + confParamRegexp: '^(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}|(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 8, + sectionId: 2, + confParamShort: 'utmstack.mail.port', + confParamLarge: 'Mail Server Port', + confParamDescription: 'SMTP server port', + confParamValue: '587', + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Number, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 9, + sectionId: 2, + confParamShort: 'utmstack.mail.username', + confParamLarge: 'Mail Server Username', + confParamDescription: 'Login user of the SMTP server', + confParamValue: null, + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 10, + sectionId: 2, + confParamShort: 'utmstack.mail.organization', + confParamLarge: 'Organization Name', + confParamDescription: 'This field helps identify the organization name in incident and alert notification emails.', + confParamValue: '', + confParamRegexp: null, + confParamRequired: false, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 13, + sectionId: 2, + confParamShort: 'utmstack.mail.properties.mail.smtp.auth', + confParamLarge: 'Encryption type', + confParamDescription: 'Select the encryption type used by the SMTP server', + confParamValue: 'STARTTLS', + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Radio, + confParamRestartRequired: false, + confParamOption: 'STARTTLS,SSL/TLS,NONE', + section: EMAIL_SECTION + } +] as unknown) as SectionConfigParamType[]; diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html new file mode 100644 index 000000000..94c85d0fa --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html @@ -0,0 +1,7 @@ +
+
+

Email Configuration

+

Configure SMTP parameters used for federation notifications.

+
+ +
diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts new file mode 100644 index 000000000..e56b735e7 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts @@ -0,0 +1,56 @@ +import {Component, OnInit} from '@angular/core'; +import {UtmConfigParamsService} from '../../../shared/services/config/utm-config-params.service'; +import {SectionConfigParamType} from '../../../shared/types/configuration/section-config-param.type'; +import { + FEDERATION_EMAIL_CONFIG_PARAMS, + FEDERATION_EMAIL_CONFIG_SECTION +} from './federation-email-config-params.const'; + +@Component({ + selector: 'app-federation-email-config-page', + templateUrl: './federation-email-config.page.component.html', + styleUrls: ['./federation-email-config.page.component.scss'] +}) +export class FederationEmailConfigPageComponent implements OnInit { + readonly section = FEDERATION_EMAIL_CONFIG_SECTION; + readonly params: SectionConfigParamType[] = FEDERATION_EMAIL_CONFIG_PARAMS.map(p => ({...p})); + + constructor(private paramsService: UtmConfigParamsService) {} + + ngOnInit(): void { + this.paramsService.query({ + page: 0, + size: 10000, + 'sectionId.equals': this.section.id, + sort: 'id,asc' + }).subscribe(response => { + const byShort = new Map(); + (response.body || []).forEach(p => byShort.set(p.confParamShort, p)); + this.params.forEach(p => { + const server = byShort.get(p.confParamShort); + if (server) { + p.id = server.id; + p.confParamValue = server.confParamValue; + } + }); + this.maskPasswordIfPreviouslySet(); + }); + } + + private maskPasswordIfPreviouslySet(): void { + const password = this.params.find(p => p.confParamShort === 'utmstack.mail.password'); + if (!password || !this.isEmpty(password.confParamValue)) { + return; + } + const others = this.params.filter(p => p !== password); + const allOthersFilled = others.length > 0 && others.every(p => !this.isEmpty(p.confParamValue)); + if (allOthersFilled) { + password.confParamPlaceholder = '••••••••'; + password.confParamRequired = false; + } + } + + private isEmpty(value: unknown): boolean { + return value === null || value === undefined || value === ''; + } +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html new file mode 100644 index 000000000..a47a06e96 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html @@ -0,0 +1,17 @@ +
+ + Page {{ pageInfo.page }} of {{ pageInfo.total_pages }} + ({{ pageInfo.total_items }} total) + +
+ + +
+
diff --git a/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts new file mode 100644 index 000000000..d6218a5f8 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts @@ -0,0 +1,13 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {TeamUserPageInfo} from '../../domain/team-user.model'; + +@Component({ + selector: 'app-team-members-pagination', + templateUrl: './team-members-pagination.component.html' +}) +export class TeamMembersPaginationComponent { + @Input() pageInfo: TeamUserPageInfo | null = null; + @Input() loading = false; + @Output() prev = new EventEmitter(); + @Output() next = new EventEmitter(); +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-search.component.html b/frontend/src/app/federation/pages/team-members/team-members-search.component.html new file mode 100644 index 000000000..9f040c552 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-search.component.html @@ -0,0 +1,5 @@ + diff --git a/frontend/src/app/federation/pages/team-members/team-members-search.component.ts b/frontend/src/app/federation/pages/team-members/team-members-search.component.ts new file mode 100644 index 000000000..da53a86e0 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-search.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {Subject} from 'rxjs'; +import {debounceTime, distinctUntilChanged, takeUntil} from 'rxjs/operators'; + +@Component({ + selector: 'app-team-members-search', + templateUrl: './team-members-search.component.html' +}) +export class TeamMembersSearchComponent implements OnInit, OnDestroy { + @Input() placeholder = 'Search by name, login or email'; + @Output() searchChange = new EventEmitter(); + + value = ''; + private input$ = new Subject(); + private destroy$ = new Subject(); + + ngOnInit(): void { + this.input$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(term => this.searchChange.emit(term)); + } + + onInput(value: string): void { + this.value = value; + this.input$.next(value); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.html b/frontend/src/app/federation/pages/team-members/team-members-table.component.html new file mode 100644 index 000000000..877c140ac --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.html @@ -0,0 +1,62 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameLoginEmailStatus2FAActions
Loading...
No team members found.
{{ displayName(user) }}{{ user.login }}{{ user.email || '—' }} + Pending + Active + Inactive + + On + — + + + + + +
+
diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.scss b/frontend/src/app/federation/pages/team-members/team-members-table.component.scss new file mode 100644 index 000000000..3c2b45cbb --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.scss @@ -0,0 +1,24 @@ +.federation-team-table { + th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6c757d; + } + + td { + vertical-align: middle; + } + + .federation-team-actions { + white-space: nowrap; + + .btn { + margin-left: 0.15rem; + } + } +} + +.badge { + font-weight: 500; +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.ts b/frontend/src/app/federation/pages/team-members/team-members-table.component.ts new file mode 100644 index 000000000..68fe34389 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.ts @@ -0,0 +1,27 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {TeamUser} from '../../domain/team-user.model'; + +@Component({ + selector: 'app-team-members-table', + templateUrl: './team-members-table.component.html', + styleUrls: ['./team-members-table.component.scss'] +}) +export class TeamMembersTableComponent { + @Input() users: TeamUser[] = []; + @Input() loading = false; + @Input() pendingActionId: number | null = null; + + @Output() editUser = new EventEmitter(); + @Output() resendInvite = new EventEmitter(); + @Output() disableTfa = new EventEmitter(); + @Output() toggleActivation = new EventEmitter(); + + trackById(_index: number, item: TeamUser): number { + return item.id; + } + + displayName(user: TeamUser): string { + const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim(); + return composed || user.login; + } +} diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.html b/frontend/src/app/federation/pages/team-members/team-members.page.component.html new file mode 100644 index 000000000..b7351d2a4 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.html @@ -0,0 +1,38 @@ +
+
+
+

Team Members

+

Manage federation team members across instances.

+
+ +
+ +
+
+ +
+
+ +
{{ errorMessage }}
+ + + + + + +
diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.scss b/frontend/src/app/federation/pages/team-members/team-members.page.component.scss new file mode 100644 index 000000000..e818051ee --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.scss @@ -0,0 +1,3 @@ +.team-members-toolbar { + gap: 1rem; +} diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.ts b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts new file mode 100644 index 000000000..bb2f0b9d6 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts @@ -0,0 +1,241 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {HttpResponse} from '@angular/common/http'; +import {Subject} from 'rxjs'; +import {UtmToastService} from '../../../shared/alert/utm-toast.service'; +import { + ModalConfirmationComponent +} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; +import {TeamUser, TeamUserPageInfo} from '../../domain/team-user.model'; +import {FederationTeamService} from '../../services/federation-team.service'; +import {TeamUserFormModalComponent} from '../../components/team-user-form-modal/team-user-form-modal.component'; + +const DEFAULT_PAGE_SIZE = 20; + +@Component({ + selector: 'app-team-members-page', + templateUrl: './team-members.page.component.html', + styleUrls: ['./team-members.page.component.scss'] +}) +export class TeamMembersPageComponent implements OnInit, OnDestroy { + users: TeamUser[] = []; + pageInfo: TeamUserPageInfo | null = null; + loading = false; + errorMessage: string | null = null; + searchTerm = ''; + pendingActionId: number | null = null; + + private currentPage = 0; + private readonly pageSize = DEFAULT_PAGE_SIZE; + private destroy$ = new Subject(); + + constructor(private teamService: FederationTeamService, + private modalService: NgbModal, + private toast: UtmToastService) {} + + ngOnInit(): void { + this.load(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSearchChange(value: string): void { + this.searchTerm = value; + this.currentPage = 0; + this.load(); + } + + prevPage(): void { + if (!this.pageInfo || !this.pageInfo.has_prev || this.loading) { + return; + } + this.currentPage = this.pageInfo.page - 1; + this.load(); + } + + nextPage(): void { + if (!this.pageInfo || !this.pageInfo.has_next || this.loading) { + return; + } + this.currentPage = this.pageInfo.page + 1; + this.load(); + } + + openInvite(): void { + const ref = this.modalService.open(TeamUserFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.saved.subscribe(() => { + ref.close(); + this.toast.showSuccessBottom('Invitation sent.'); + this.currentPage = 0; + this.load(); + }); + } + + openEdit(user: TeamUser): void { + const ref = this.modalService.open(TeamUserFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.user = user; + ref.componentInstance.saved.subscribe(() => { + ref.close(); + this.toast.showSuccessBottom('Team member updated.'); + this.load(); + }); + } + + resendInvite(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + this.pendingActionId = user.id; + this.teamService.resendInvite(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Invitation re-sent.'); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Resend invite failed', this.extractError(err)); + } + }); + } + + disableTfa(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + const ref = this.modalService.open(ModalConfirmationComponent, { + backdrop: 'static', + centered: true + }); + ref.componentInstance.header = 'Disable 2FA'; + ref.componentInstance.message = `Disable 2FA for ${this.displayName(user)}?`; + ref.componentInstance.confirmBtnText = 'Disable'; + ref.componentInstance.confirmBtnIcon = 'icon-shield-cross'; + ref.componentInstance.confirmBtnType = 'delete'; + ref.componentInstance.textDisplay = + 'The user will be able to sign in without a 2FA code until they re-enroll.'; + ref.componentInstance.textType = 'warning'; + ref.result.then(() => this.runDisableTfa(user), () => undefined); + } + + toggleActivation(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + if (user.activated) { + this.confirmAndDeactivate(user); + } else { + this.activate(user); + } + } + + displayName(user: TeamUser): string { + const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim(); + return composed || user.login; + } + + private confirmAndDeactivate(user: TeamUser): void { + const ref = this.modalService.open(ModalConfirmationComponent, { + backdrop: 'static', + centered: true + }); + ref.componentInstance.header = 'Deactivate team member'; + ref.componentInstance.message = `Deactivate ${this.displayName(user)}?`; + ref.componentInstance.confirmBtnText = 'Deactivate'; + ref.componentInstance.confirmBtnIcon = 'icon-cancel-circle2'; + ref.componentInstance.confirmBtnType = 'delete'; + ref.componentInstance.textDisplay = 'The user will lose access to this federation server.'; + ref.componentInstance.textType = 'warning'; + ref.result.then(() => this.runDeactivate(user), () => undefined); + } + + private runDeactivate(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.deactivate(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Team member deactivated.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Deactivation failed', this.extractError(err)); + } + }); + } + + private activate(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.update(user.id, { + email: user.email || '', + first_name: user.first_name || '', + last_name: user.last_name || '', + activated: true + }).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Team member activated.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Activation failed', this.extractError(err)); + } + }); + } + + private runDisableTfa(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.disableTfa(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('2FA disabled.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Disable 2FA failed', this.extractError(err)); + } + }); + } + + private load(): void { + this.loading = true; + this.errorMessage = null; + this.teamService.list({ + page: this.currentPage, + page_size: this.pageSize, + search: this.searchTerm ? this.searchTerm.trim() : undefined + }).subscribe({ + next: (res: HttpResponse) => { + this.loading = false; + this.users = res.body || []; + const totalItems = Number(res.headers.get('X-Total-Count') || 0); + const totalPages = Math.ceil(totalItems / this.pageSize); + const page = Math.max(this.currentPage, 0); + this.pageInfo = {page, page_size: this.pageSize, total_items: totalItems, total_pages: totalPages, has_next: page < totalPages, has_prev: page > 1}; + }, + error: err => { + this.loading = false; + this.users = []; + this.pageInfo = null; + this.errorMessage = this.extractError(err); + } + }); + } + + private extractError(err: {error?: {message?: string}}): string { + if (err && err.error && err.error.message) { + return err.error.message; + } + return 'Operation failed. Please try again.'; + } +} diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.html b/frontend/src/app/federation/pages/welcome/welcome.component.html new file mode 100644 index 000000000..aef12215c --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.html @@ -0,0 +1,24 @@ + +
+ +
+
+ + +
+
+
+ +
+

Welcome to UTMStack Federation

+

+ No instances are connected yet. Connect your first instance to start managing it from this console. +

+ +
+
+
diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.scss b/frontend/src/app/federation/pages/welcome/welcome.component.scss new file mode 100644 index 000000000..9acbfda40 --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.scss @@ -0,0 +1,5 @@ +:host { display: block; } + +.welcome-overview { + padding: 16px; +} diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.ts b/frontend/src/app/federation/pages/welcome/welcome.component.ts new file mode 100644 index 000000000..7cc036a34 --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.ts @@ -0,0 +1,46 @@ +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {InstanceFormModalComponent} from '../../components/instance-form-modal/instance-form-modal.component'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; +import {FederationInstancesService} from '../../services/federation-instances.service'; + +@Component({ + selector: 'app-federation-welcome', + templateUrl: './welcome.component.html', + styleUrls: ['./welcome.component.scss'] +}) +export class WelcomeComponent { + readonly hasInstances$: Observable = this.instanceState.instances$ + .pipe(map(instances => instances.length > 0)); + + constructor( + private modalService: NgbModal, + private router: Router, + private instanceState: FederationInstanceStateService, + private instancesService: FederationInstancesService + ) {} + + openCreateModal(): void { + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static', + keyboard: false + }); + ref.componentInstance.saved.subscribe((created: FederationInstance) => { + ref.close(); + this.instancesService.list().subscribe(instances => { + const list = instances || []; + this.instanceState.setInstances(list); + const next = list.find(i => i.id === created.id) || list[0]; + if (next) { + this.instanceState.setActive(next, false); + } + this.router.navigate(['/dashboard/overview']); + }); + }); + } +} diff --git a/frontend/src/app/federation/services/federation-instance-state.service.ts b/frontend/src/app/federation/services/federation-instance-state.service.ts new file mode 100644 index 000000000..26aab4455 --- /dev/null +++ b/frontend/src/app/federation/services/federation-instance-state.service.ts @@ -0,0 +1,85 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {FederationInstance} from '../domain/federation-instance.model'; + +const STORAGE_KEY = 'utm.federation.activeInstanceId'; + +@Injectable({providedIn: 'root'}) +export class FederationInstanceStateService { + private instancesSubject = new BehaviorSubject([]); + private activeSubject = new BehaviorSubject(null); + private switchSubject = new BehaviorSubject(0); + + readonly instances$: Observable = this.instancesSubject.asObservable(); + readonly active$: Observable = this.activeSubject.asObservable(); + readonly switch$: Observable = this.switchSubject.asObservable(); + + setInstances(instances: FederationInstance[]): void { + this.instancesSubject.next(instances); + if (instances.length === 0) { + this.activeSubject.next(null); + return; + } + const persistedId = this.readPersistedId(); + const fromStorage = persistedId !== null ? instances.find(i => i.id === persistedId) : undefined; + const current = this.activeSubject.getValue(); + const stillThere = current ? instances.find(i => i.id === current.id) : undefined; + const next = stillThere || fromStorage || instances[0]; + this.setActive(next, false); + } + + setActive(instance: FederationInstance, broadcastSwitch: boolean = true): void { + const current = this.activeSubject.getValue(); + if (current && current.id === instance.id) { + return; + } + this.activeSubject.next(instance); + this.persistId(instance.id); + if (broadcastSwitch) { + this.switchSubject.next(this.switchSubject.getValue() + 1); + } + } + + clear(): void { + this.instancesSubject.next([]); + this.activeSubject.next(null); + this.clearPersistedId(); + } + + get current(): FederationInstance | null { + return this.activeSubject.getValue(); + } + + get instances(): FederationInstance[] { + return this.instancesSubject.getValue(); + } + + private readPersistedId(): number | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } + } + + private persistId(id: number): void { + try { + window.localStorage.setItem(STORAGE_KEY, String(id)); + } catch { + // ignore + } + } + + private clearPersistedId(): void { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + } +} diff --git a/frontend/src/app/federation/services/federation-instances.service.ts b/frontend/src/app/federation/services/federation-instances.service.ts new file mode 100644 index 000000000..4507ce028 --- /dev/null +++ b/frontend/src/app/federation/services/federation-instances.service.ts @@ -0,0 +1,39 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationInstance} from '../domain/federation-instance.model'; + +export interface FederationInstanceInput { + name: string; + baseUrl: string; + apiKey?: string; + tlsSkipVerify: boolean; +} + +@Injectable({providedIn: 'root'}) +export class FederationInstancesService { + private readonly endpoint = SERVER_API_URL + 'api/v1/instances'; + + constructor(private http: HttpClient) {} + + list(): Observable { + return this.http.get(this.endpoint); + } + + get(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(payload: FederationInstanceInput): Observable { + return this.http.post(this.endpoint, payload); + } + + update(id: number, payload: FederationInstanceInput): Observable { + return this.http.put(`${this.endpoint}/${id}`, payload); + } + + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/frontend/src/app/federation/services/federation-mode.service.ts b/frontend/src/app/federation/services/federation-mode.service.ts new file mode 100644 index 000000000..117e4f508 --- /dev/null +++ b/frontend/src/app/federation/services/federation-mode.service.ts @@ -0,0 +1,38 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable, of} from 'rxjs'; +import {catchError, map, shareReplay, tap} from 'rxjs/operators'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationMode} from '../domain/federation-mode.model'; + +@Injectable({providedIn: 'root'}) +export class FederationModeService { + private readonly endpoint = SERVER_API_URL + 'api/v1/mode'; + private modeSubject = new BehaviorSubject(null); + private detect$: Observable | null = null; + + readonly mode$ = this.modeSubject.asObservable(); + readonly active$ = this.modeSubject.pipe(map(m => !!m && m.federation === true)); + + constructor(private http: HttpClient) {} + + detect(): Observable { + if (!this.detect$) { + this.detect$ = this.http.get(this.endpoint).pipe( + catchError(() => of({federation: false, version: ''})), + tap(mode => this.modeSubject.next(mode)), + shareReplay(1) + ); + } + return this.detect$; + } + + get current(): FederationMode | null { + return this.modeSubject.getValue(); + } + + get isActive(): boolean { + const current = this.modeSubject.getValue(); + return !!current && current.federation === true; + } +} diff --git a/frontend/src/app/federation/services/federation-overview.service.ts b/frontend/src/app/federation/services/federation-overview.service.ts new file mode 100644 index 000000000..1ebef3a74 --- /dev/null +++ b/frontend/src/app/federation/services/federation-overview.service.ts @@ -0,0 +1,24 @@ +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {CountAlertsBySeverityEntry} from '../domain/count-alerts-by-severity.model'; +import {CountAlertsByStatusEntry} from '../domain/count-alerts-by-status.model'; + +@Injectable({providedIn: 'root'}) +export class FederationOverviewService { + private readonly statusEndpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-status'; + private readonly severityEndpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-severity'; + + constructor(private http: HttpClient) {} + + countAlertsByStatus(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get(this.statusEndpoint, {params}); + } + + countAlertsBySeverity(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to).set('top','100000'); + return this.http.get(this.severityEndpoint, {params}); + } +} diff --git a/frontend/src/app/federation/services/federation-team.service.ts b/frontend/src/app/federation/services/federation-team.service.ts new file mode 100644 index 000000000..3fad9d0ab --- /dev/null +++ b/frontend/src/app/federation/services/federation-team.service.ts @@ -0,0 +1,55 @@ +import {HttpClient, HttpResponse, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import { + TeamUser, + TeamUserCreatePayload, + TeamUserListQuery, + TeamUserUpdatePayload +} from '../domain/team-user.model'; + +@Injectable({providedIn: 'root'}) +export class FederationTeamService { + private readonly endpoint = SERVER_API_URL + 'api/v1/users'; + + constructor(private http: HttpClient) {} + + list(query: TeamUserListQuery = {}): Observable> { + let params = new HttpParams(); + if (query.page !== undefined) { + params = params.set('page', String(query.page)); + } + if (query.page_size !== undefined) { + params = params.set('page_size', String(query.page_size)); + } + if (query.search) { + params = params.set('search', query.search); + } + return this.http.get(this.endpoint, {params, observe: 'response'}); + } + + get(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(payload: TeamUserCreatePayload): Observable { + return this.http.post(this.endpoint, payload); + } + + update(id: number, payload: TeamUserUpdatePayload): Observable { + return this.http.put(`${this.endpoint}/${id}`, payload); + } + + deactivate(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } + + resendInvite(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/resend-invite`, {}); + } + + disableTfa(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/tfa/disable`, {}); + } +} diff --git a/frontend/src/app/shared/components/auth/login/login.component.ts b/frontend/src/app/shared/components/auth/login/login.component.ts index 879319dcb..fea6f2677 100644 --- a/frontend/src/app/shared/components/auth/login/login.component.ts +++ b/frontend/src/app/shared/components/auth/login/login.component.ts @@ -60,6 +60,7 @@ export class LoginComponent implements OnInit, OnDestroy { } ngOnInit() { + this.initForm(); this.apiServiceCheckerService.isOnlineApi$ .pipe( diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 7688cc08f..6aa33aa24 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -11,26 +11,51 @@ > + + + + + + + + + + + +
+ + + + +
    -
  • + +
  • -
  • - -
  • +
  • + +
  • + +
  • + +
  • -
  • + +
  • + +
@@ -39,7 +64,8 @@ -
+
diff --git a/frontend/src/app/shared/components/layout/header/header.component.scss b/frontend/src/app/shared/components/layout/header/header.component.scss index 6bc068532..5472544ff 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.scss +++ b/frontend/src/app/shared/components/layout/header/header.component.scss @@ -181,6 +181,10 @@ overflow-scrolling: auto; } +.utm-navbar-secondary--federation { + padding-left: 0 !important; +} + .dropdown-menu-right { z-index: 999; @@ -226,3 +230,11 @@ app-utm-notification-ad, app-utm-notification-alert, app-utm-notification-assets, app-utm-notification-user-setting { height: 100% !important; } + +.federation-header-nav { + flex: 1 1 auto; + display: flex; + align-items: center; + margin: 0 1rem; + height: 100%; +} diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index b6ae1c83d..4d4310148 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -1,15 +1,19 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; +import {NavigationEnd, Router} from '@angular/router'; +import {Observable, Subject} from 'rxjs'; +import {filter, map, startWith, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {User} from '../../../../core/user/user.model'; +import {FederationModeService} from '../../../../federation/services/federation-mode.service'; import {ThemeChangeBehavior} from '../../../behaviors/theme-change.behavior'; import {ADMIN_ROLE} from '../../../constants/global.constant'; import {AppThemeLocationEnum} from '../../../enums/app-theme-location.enum'; import {VersionInfoService} from '../../../services/version/version-info.service'; import {AppVersionInfo} from '../../../types/updates/updates.type'; +const FEDERATION_WELCOME_ROUTE = '/federation/welcome'; + @Component({ selector: 'app-header', templateUrl: './header.component.html', @@ -23,15 +27,29 @@ export class HeaderComponent implements OnInit, OnDestroy { logoImage: string; altImage: string; versionInfo: AppVersionInfo; + federationActive$: Observable; + federationWelcomeRoute$: Observable; destroy$: Subject = new Subject(); constructor(private accountService: AccountService, public sanitizer: DomSanitizer, + private router: Router, private themeChangeBehavior: ThemeChangeBehavior, - private versionTypeService: VersionInfoService) { + private versionTypeService: VersionInfoService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; + } ngOnInit() { + + this.federationWelcomeRoute$ = this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map((event: NavigationEnd) => this.isFederationWelcome(event.urlAfterRedirects)), + startWith(this.isFederationWelcome(this.router.url)) + ); + + this.themeChangeBehavior.$themeNavbarIcon .pipe( takeUntil(this.destroy$), @@ -52,4 +70,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private isFederationWelcome(url: string): boolean { + return url.startsWith(FEDERATION_WELCOME_ROUTE); + } } diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html index 9ab49ebb5..a6e75ea47 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html @@ -20,12 +20,15 @@
S
-
  • +
  • -
  • - -
  • + + +
  • + +
  • +
    diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts index 0d9bba9b9..99dcc01a4 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts @@ -1,7 +1,9 @@ import {Component, OnInit} from '@angular/core'; +import {Observable} from 'rxjs'; import {AccountService} from '../../../../../core/auth/account.service'; import {LoginService} from '../../../../../core/login/login.service'; import {User} from '../../../../../core/user/user.model'; +import {FederationModeService} from '../../../../../federation/services/federation-mode.service'; import {ADMIN_ROLE} from '../../../../constants/global.constant'; import {ActiveAdModuleActiveService} from '../../../../services/active-modules/active-ad-module.service'; @@ -16,11 +18,14 @@ export class MobileHeaderComponent implements OnInit { menuActive = false; isAdActive: boolean; roleAdmin = ADMIN_ROLE; + federationActive$: Observable; constructor(private loginService: LoginService, private accountService: AccountService, - private adModuleActiveService: ActiveAdModuleActiveService) { + private adModuleActiveService: ActiveAdModuleActiveService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; } ngOnInit() { diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html index 71605d676..e4b1afd44 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html @@ -26,7 +26,7 @@
    - Your password is required to be at least 4 characters. + Your password is required to be at least 8 characters. @@ -48,7 +48,8 @@
    - Your confirmation password is required to be at least 4 characters. + Your confirmation password is required to be at least 8 characters. diff --git a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html index d48caf357..08131d102 100644 --- a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html +++ b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html @@ -36,7 +36,9 @@
    {{section.section | titlecase}}
    *ngIf="section.shortName === sectionType[sectionType.TFA]">