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 }}
+
+ Retry
+
+
+
+
+
+
No active instances are reporting data.
+
+
+
0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+ {{ active ? active.name : 'Select instance' }}
+
+
+
+
+
+
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 @@
+
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 @@
+
+
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 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0"
+ class="nav-icon__badge"
+ [title]="totalAlerts + ' total alerts'">
+ {{ totalAlerts }}
+
+
+
+
+
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 @@
+
+
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 @@
+
+
+
+
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 @@
+ 0"
+ class="d-flex justify-content-between align-items-center mt-2">
+
+ Page {{ pageInfo.page }} of {{ pageInfo.total_pages }}
+ ({{ pageInfo.total_items }} total)
+
+
+
+ Previous
+
+
+ Next
+
+
+
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 @@
+
+
+
+
+ Name
+ Login
+ Email
+ Status
+ 2FA
+ Actions
+
+
+
+
+ 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 @@
+
+
+
+
+
Manage federation team members across instances.
+
+
+ Invite user
+
+
+
+
+
+
{{ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ No instances are connected yet. Connect your first instance to start managing it from this console.
+
+
+ Connect instance
+
+
+
+
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 @@
New password
- Your password is required to be at least 4 characters.
+ Your password is required to be at least 8 characters.
@@ -48,7 +48,8 @@
New password confirmation
- 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]">
Save changes
@@ -82,6 +84,7 @@ {{section.section | titlecase}}
[id]="'sectionParam'+conf.id"
[required]="conf.confParamRequired"
[disabled]="checkDisable(conf)"
+ [placeholder]="conf.confParamPlaceholder || ''"
[type]="conf.confParamDatatype" class="form-control pr-5">
();
@Input() allowDeleteSection = false;
@Output() changesApplied = new EventEmitter();
@@ -66,7 +67,12 @@ export class AppConfigSectionsComponent implements OnInit, OnDestroy {
ngOnInit() {
- this.getConfigurations();
+ if (this.params) {
+ this.applyConfigs(this.params);
+ this.loading = false;
+ } else {
+ this.getConfigurations();
+ }
this.changesApplied.emit(true);
this.networkService.isOnline$
@@ -89,26 +95,28 @@ export class AppConfigSectionsComponent implements OnInit, OnDestroy {
})
.subscribe(response => {
this.loading = false;
- this.configs = response.body;
-
- this.configs = this.configs.map(conf => {
- if(conf.confParamDatatype === ConfigDataTypeEnum.Cron) {
- conf.confParamValue = JSON.parse(conf.confParamValue);
- }
- return conf;
- });
-
- const countryList = this.configs.find(conf => conf.confParamDatatype === ConfigDataTypeEnum.CountryList);
- if (countryList) {
- this.loadSelectOptions(this.getName(countryList.confParamShort));
- }
- this.configToSave=this.configs
- this.validConfigSection.emit(this.checkConfigValid());
+ this.applyConfigs(response.body);
}, error => {
this.toastService.showError('Error', 'Error getting application configurations');
});
}
+ private applyConfigs(params: SectionConfigParamType[]) {
+ this.configs = params.map(conf => {
+ if (conf.confParamDatatype === ConfigDataTypeEnum.Cron) {
+ conf.confParamValue = JSON.parse(conf.confParamValue);
+ }
+ return conf;
+ });
+
+ const countryList = this.configs.find(conf => conf.confParamDatatype === ConfigDataTypeEnum.CountryList);
+ if (countryList) {
+ this.loadSelectOptions(this.getName(countryList.confParamShort));
+ }
+ this.configToSave = this.configs;
+ this.validConfigSection.emit(this.checkConfigValid());
+ }
+
saveConfig() {
this.checkedEmailConfig(false);
this.saving = true;
@@ -145,12 +153,20 @@ export class AppConfigSectionsComponent implements OnInit, OnDestroy {
}
}
+ get saveDisabled(): boolean {
+ return !this.checkConfigValid() || this.saving || this.configToSave.length === 0
+ || (this.section.shortName === this.sectionType[this.sectionType.EMAIL] && !this.isCheckedEmailConfig)
+ || (this.section.shortName === this.sectionType[this.sectionType.TFA] && !this.isCheckedTFAConfig);
+ }
+
detectRequiredRestart(): boolean {
return this.configToSave.findIndex(value => value.confParamRestartRequired === true) > -1;
}
saveSectionConfig(value: any, conf: SectionConfigParamType) {
conf.confParamValue = value;
+ this.isCheckedEmailConfig = false;
+ this.isCheckedTFAConfig = false;
const indexConfig = this.configToSave.findIndex(val => val.id === conf.id);
if (indexConfig !== -1) {
this.configToSave[indexConfig] = conf;
diff --git a/frontend/src/app/shared/types/configuration/section-config-param.type.ts b/frontend/src/app/shared/types/configuration/section-config-param.type.ts
index f9af159af..87744b962 100644
--- a/frontend/src/app/shared/types/configuration/section-config-param.type.ts
+++ b/frontend/src/app/shared/types/configuration/section-config-param.type.ts
@@ -4,7 +4,7 @@ export class SectionConfigParamType {
confParamDatatype?: ConfigDataTypeEnum;
confParamDescription?: string;
confParamLarge?: string;
- confParamRequired?: true;
+ confParamRequired?: boolean;
confParamShort?: string;
confParamValue?: any;
id?: number;
@@ -15,6 +15,7 @@ export class SectionConfigParamType {
confParamRestartRequired: boolean;
confParamOption: string;
confParamRegexp?: string;
+ confParamPlaceholder?: string;
}
export enum ConfigDataTypeEnum {
diff --git a/frontend/src/app/shared/utm-shared.module.ts b/frontend/src/app/shared/utm-shared.module.ts
index d24177924..fe499fc14 100644
--- a/frontend/src/app/shared/utm-shared.module.ts
+++ b/frontend/src/app/shared/utm-shared.module.ts
@@ -18,6 +18,7 @@ import {AssetsGroupAddModule} from '../assets-discover/shared/components/asset-g
import {AssetsApplyNoteModule} from '../assets-discover/shared/components/assets-apply-note/assets-apply-note.module';
import {AssetsApplyTypeModule} from '../assets-discover/shared/components/assets-apply-type/assets-apply-type.module';
import {AuthServerProvider} from '../core/auth/auth-jwt.service';
+import {FederationModule} from '../federation/federation.module';
import {AlertEchoesTimelineComponent} from '../data-management/alert-management/shared/components/alert-echoes-timeline/alert-echoes-timeline.component';
import {UtmToastService} from './alert/utm-toast.service';
import {DashboardBehavior} from './behaviors/dashboard.behavior';
@@ -117,6 +118,9 @@ import {
} from './components/utm/config/app-config-delete-confirm/app-config-delete-confirm.component';
import {AppConfigParamsComponent} from './components/utm/config/app-config-params/app-config-params.component';
import {AppConfigSectionsComponent} from './components/utm/config/app-config-sections/app-config-sections.component';
+import {
+ FederationEmailConfigPageComponent
+} from '../federation/pages/email-config/federation-email-config.page.component';
import {
AppModuleDisabledWarningComponent
} from './components/utm/config/app-module-disabled-warning/app-module-disabled-warning.component';
@@ -269,7 +273,8 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con
InfiniteScrollModule,
NgxEchartsModule,
ResizableModule,
- MonacoEditorModule.forRoot()
+ MonacoEditorModule.forRoot(),
+ FederationModule
],
declarations: [
ElasticFilterComponent,
@@ -346,6 +351,7 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con
GenericFilerSortComponent,
AppConfigParamsComponent,
AppConfigSectionsComponent,
+ FederationEmailConfigPageComponent,
AppModuleDisabledWarningComponent,
AppConfigDeleteConfirmComponent,
UtmScrollTopComponent,
@@ -527,7 +533,8 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con
ResizableFilterContainerComponent,
CodeEditorComponent,
IsEnterpriseModuleDirective,
- ScheduleConfigComponent
+ ScheduleConfigComponent,
+ FederationModule
],
entryComponents: [
LoginComponent,
diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts
index e69de29bb..976a14684 100644
--- a/frontend/src/environments/environment.ts
+++ b/frontend/src/environments/environment.ts
@@ -0,0 +1,10 @@
+export const environment = {
+ production: false,
+ SERVER_API_URL: `http://localhost:8080`,
+ WEBSOCKET_URL: ``,
+ SESSION_AUTH_TOKEN: window.location.host.split(':')[0].toLocaleUpperCase(),
+ SERVER_API_CONTEXT: '/',
+ BUILD_TIMESTAMP: new Date().getTime(),
+ DEBUG_INFO_ENABLED: true,
+ VERSION: '0.0.1'
+};