Skip to content

Commit d00923d

Browse files
feat[frontend](federation_server): added federation server front handling
1 parent b2688b1 commit d00923d

26 files changed

Lines changed: 845 additions & 14 deletions

frontend/src/app/app.module.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {AppComponent} from './app.component';
2323
import {AuthExpiredInterceptor} from './blocks/interceptor/auth-expired.interceptor';
2424
import {AuthInterceptor} from './blocks/interceptor/auth.interceptor';
2525
import {ErrorHandlerInterceptor} from './blocks/interceptor/errorhandler.interceptor';
26+
import {FederationInstanceInterceptor} from './blocks/interceptor/federation-instance.interceptor';
2627
import {ManageHttpInterceptor} from './blocks/interceptor/managehttp.interceptor';
2728
import {NotificationInterceptor} from './blocks/interceptor/notification.interceptor';
2829
import {HttpCancelService} from './blocks/service/httpcancel.service';
@@ -36,6 +37,8 @@ import {AlertIncidentStatusChangeBehavior} from './shared/behaviors/alert-incide
3637
import {GettingStartedBehavior} from './shared/behaviors/getting-started.behavior';
3738
import {NavBehavior} from './shared/behaviors/nav.behavior';
3839
import {NewAlertBehavior} from './shared/behaviors/new-alert.behavior';
40+
import {initFederationMode} from './federation/hooks/federation-bootstrap';
41+
import {FederationModeService} from './federation/services/federation-mode.service';
3942
import {TimezoneFormatService} from './shared/services/utm-timezone.service';
4043
import {AppVersionService} from './shared/services/version/app-version.service';
4144
import {UtmSharedModule} from './shared/utm-shared.module';
@@ -109,6 +112,11 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService,
109112
deps: [SessionStorageService, LocalStorageService],
110113
multi: true
111114
},
115+
{
116+
provide: HTTP_INTERCEPTORS,
117+
useClass: FederationInstanceInterceptor,
118+
multi: true
119+
},
112120
{
113121
provide: HTTP_INTERCEPTORS,
114122
useClass: AuthExpiredInterceptor,
@@ -132,6 +140,12 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService,
132140
useClass: ManageHttpInterceptor,
133141
multi: true,
134142
},
143+
{
144+
provide: APP_INITIALIZER,
145+
useFactory: initFederationMode,
146+
deps: [FederationModeService],
147+
multi: true
148+
},
135149
{
136150
provide: APP_INITIALIZER,
137151
useFactory: initTimezoneFormat,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
2+
import {Injectable} from '@angular/core';
3+
import {Observable} from 'rxjs';
4+
import {SERVER_API_URL} from '../../app.constants';
5+
import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service';
6+
import {FederationModeService} from '../../federation/services/federation-mode.service';
7+
8+
const AUTH_PATH_PREFIXES = [
9+
'api/authenticate',
10+
'api/v1/auth/',
11+
'api/tfa/',
12+
'auth/'
13+
];
14+
15+
@Injectable()
16+
export class FederationInstanceInterceptor implements HttpInterceptor {
17+
constructor(private modeService: FederationModeService,
18+
private instanceState: FederationInstanceStateService) {}
19+
20+
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
21+
if (!this.modeService.isActive) {
22+
return next.handle(request);
23+
}
24+
if (!this.isApiRequest(request.url)) {
25+
return next.handle(request);
26+
}
27+
if (this.isAuthEndpoint(request.url)) {
28+
return next.handle(request);
29+
}
30+
const instance = this.instanceState.current;
31+
if (!instance) {
32+
return next.handle(request);
33+
}
34+
const cloned = request.clone({
35+
setHeaders: {'X-UTM-Instance': String(instance.id)}
36+
});
37+
return next.handle(cloned);
38+
}
39+
40+
private isApiRequest(url: string): boolean {
41+
if (!url) {
42+
return false;
43+
}
44+
if (/^https?:/i.test(url)) {
45+
return !!SERVER_API_URL && url.startsWith(SERVER_API_URL);
46+
}
47+
return true;
48+
}
49+
50+
private isAuthEndpoint(url: string): boolean {
51+
return AUTH_PATH_PREFIXES.some(prefix => url.includes(prefix));
52+
}
53+
}

frontend/src/app/core/auth/account.service.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {Observable, Subject} from 'rxjs';
44

55
import {SERVER_API_URL} from '../../app.constants';
66
import {HttpCancelService} from '../../blocks/service/httpcancel.service';
7+
import {FederationInstancesService} from '../../federation/services/federation-instances.service';
8+
import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service';
9+
import {FederationModeService} from '../../federation/services/federation-mode.service';
710
import {Account} from '../user/account.model';
811
import {AuthServerProvider} from './auth-jwt.service';
912
import {extractQueryParamsForNavigation} from "../../shared/util/query-params-to-filter.util";
@@ -18,14 +21,18 @@ export class AccountService {
1821
private userIdentity: Account;
1922
private authenticated = false;
2023
private authenticationState = new Subject<any>();
24+
private instancesFetchInFlight = false;
2125

2226
constructor(private http: HttpClient,
2327
private authServerProvider: AuthServerProvider,
2428
private httpCancelService: HttpCancelService,
2529
private stateStorageService: StateStorageService,
2630
private router: Router,
2731
private spinner: NgxSpinnerService,
28-
private utmToast: UtmToastService) {
32+
private utmToast: UtmToastService,
33+
private federationModeService: FederationModeService,
34+
private federationInstancesService: FederationInstancesService,
35+
private federationInstanceState: FederationInstanceStateService) {
2936
}
3037

3138
fetch(): Observable<HttpResponse<Account>> {
@@ -99,10 +106,13 @@ export class AccountService {
99106
if (account) {
100107
this.userIdentity = account;
101108
this.authenticated = true;
102-
} else {
103-
this.userIdentity = null;
104-
this.authenticated = false;
109+
return this.ensureFederationInstancesLoaded().then(() => {
110+
this.authenticationState.next(this.userIdentity);
111+
return this.userIdentity;
112+
});
105113
}
114+
this.userIdentity = null;
115+
this.authenticated = false;
106116
this.authenticationState.next(this.userIdentity);
107117
return this.userIdentity;
108118
})
@@ -140,9 +150,36 @@ export class AccountService {
140150
}
141151
}
142152

153+
private ensureFederationInstancesLoaded(): Promise<void> {
154+
if (!this.federationModeService.isActive) {
155+
return Promise.resolve();
156+
}
157+
if (this.federationInstanceState.instances.length > 0) {
158+
return Promise.resolve();
159+
}
160+
if (this.instancesFetchInFlight) {
161+
return Promise.resolve();
162+
}
163+
this.instancesFetchInFlight = true;
164+
return this.federationInstancesService.list()
165+
.toPromise()
166+
.then(instances => {
167+
this.instancesFetchInFlight = false;
168+
this.federationInstanceState.setInstances(instances || []);
169+
})
170+
.catch(() => {
171+
this.instancesFetchInFlight = false;
172+
});
173+
}
174+
143175
startNavigation() {
144176
this.identity(true).then(account => {
145177
if (account) {
178+
if (this.federationModeService.isActive && this.federationInstanceState.instances.length === 0) {
179+
this.router.navigate(['/federation/welcome'])
180+
.then(() => this.spinner.hide());
181+
return;
182+
}
146183
const { path, queryParams } =
147184
extractQueryParamsForNavigation(this.stateStorageService.getUrl() ? this.stateStorageService.getUrl() : '' );
148185
if (path) {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<div class="modal-header">
2+
<h5 class="modal-title">{{ title }}</h5>
3+
<button type="button" class="close" aria-label="Close" (click)="cancel()">
4+
<span aria-hidden="true">&times;</span>
5+
</button>
6+
</div>
7+
<form #form="ngForm" (ngSubmit)="submit()" novalidate>
8+
<div class="modal-body">
9+
<div *ngIf="errorMessage" class="alert alert-danger">{{ errorMessage }}</div>
10+
11+
<div class="form-group">
12+
<label for="instanceName">Name</label>
13+
<input id="instanceName" name="name" type="text" class="form-control"
14+
[(ngModel)]="model.name" required>
15+
</div>
16+
17+
<div class="form-group">
18+
<label for="instanceBaseUrl">Base URL</label>
19+
<input id="instanceBaseUrl" name="baseUrl" type="url" class="form-control"
20+
[(ngModel)]="model.baseUrl" required>
21+
</div>
22+
23+
<div class="form-group">
24+
<label for="instanceApiKey">API key</label>
25+
<input id="instanceApiKey" name="apiKey" type="password" class="form-control"
26+
[(ngModel)]="model.apiKey" [required]="!isEditMode">
27+
<small *ngIf="apiKeyHint" class="form-text text-muted">{{ apiKeyHint }}</small>
28+
</div>
29+
30+
<div class="form-group form-check">
31+
<input id="instanceTlsSkip" name="tlsSkipVerify" type="checkbox" class="form-check-input"
32+
[(ngModel)]="model.tlsSkipVerify">
33+
<label for="instanceTlsSkip" class="form-check-label">Skip TLS verification</label>
34+
</div>
35+
</div>
36+
<div class="modal-footer">
37+
<button type="button" class="btn btn-light" (click)="cancel()" [disabled]="submitting">Cancel</button>
38+
<button type="submit" class="btn utm-button utm-button-primary"
39+
[disabled]="submitting || form.invalid">
40+
<span *ngIf="submitting" class="spinner-border spinner-border-sm mr-2"></span>
41+
Save
42+
</button>
43+
</div>
44+
</form>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:host {
2+
display: block;
3+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {Component, EventEmitter, Input, Output} from '@angular/core';
2+
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
3+
import {FederationInstance} from '../../domain/federation-instance.model';
4+
import {FederationInstanceInput, FederationInstancesService} from '../../services/federation-instances.service';
5+
6+
interface InstanceFormValue {
7+
name: string;
8+
baseUrl: string;
9+
apiKey: string;
10+
tlsSkipVerify: boolean;
11+
}
12+
13+
@Component({
14+
selector: 'app-federation-instance-form-modal',
15+
templateUrl: './instance-form-modal.component.html',
16+
styleUrls: ['./instance-form-modal.component.scss']
17+
})
18+
export class InstanceFormModalComponent {
19+
@Input() instance: FederationInstance | null = null;
20+
@Output() saved = new EventEmitter<FederationInstance>();
21+
submitting = false;
22+
errorMessage: string | null = null;
23+
model: InstanceFormValue = {
24+
name: '',
25+
baseUrl: '',
26+
apiKey: '',
27+
tlsSkipVerify: false
28+
};
29+
30+
constructor(public activeModal: NgbActiveModal,
31+
private instancesService: FederationInstancesService) {}
32+
33+
ngOnInit(): void {
34+
if (this.instance) {
35+
this.model = {
36+
name: this.instance.name,
37+
baseUrl: this.instance.baseUrl,
38+
apiKey: '',
39+
tlsSkipVerify: this.instance.tlsSkipVerify
40+
};
41+
}
42+
}
43+
44+
get isEditMode(): boolean {
45+
return this.instance !== null;
46+
}
47+
48+
get title(): string {
49+
return this.isEditMode ? 'Edit instance' : 'Connect instance';
50+
}
51+
52+
get apiKeyHint(): string {
53+
return this.isEditMode
54+
? 'Leave empty to keep the existing API key.'
55+
: '';
56+
}
57+
58+
submit(): void {
59+
if (this.submitting) {
60+
return;
61+
}
62+
this.submitting = true;
63+
this.errorMessage = null;
64+
const payload: FederationInstanceInput = {
65+
name: this.model.name,
66+
baseUrl: this.model.baseUrl,
67+
tlsSkipVerify: this.model.tlsSkipVerify
68+
};
69+
if (this.model.apiKey) {
70+
payload.apiKey = this.model.apiKey;
71+
}
72+
const action$ = this.isEditMode && this.instance
73+
? this.instancesService.update(this.instance.id, payload)
74+
: this.instancesService.create(payload);
75+
action$.subscribe({
76+
next: instance => {
77+
this.submitting = false;
78+
this.saved.emit(instance);
79+
},
80+
error: err => {
81+
this.submitting = false;
82+
this.errorMessage = (err && err.error && err.error.message)
83+
|| 'Failed to save instance.';
84+
}
85+
});
86+
}
87+
88+
cancel(): void {
89+
this.activeModal.dismiss();
90+
}
91+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<div class="dropdown h-100 cursor-pointer federation-instance-switcher"
2+
container="body"
3+
display="dynamic"
4+
ngbDropdown
5+
autoClose="outside"
6+
placement="auto">
7+
<div class="dropdown-toggle h-100 d-flex justify-content-center align-items-center px-2"
8+
data-toggle="dropdown"
9+
ngbDropdownToggle>
10+
<span class="svg-icon svg-icon-sm svg-icon-white text-white mr-2"
11+
inlineSVG="assets/icons/header/cloud.svg"></span>
12+
<span class="text-white text-truncate federation-instance-name"
13+
[title]="active ? active.name : 'No instance'">
14+
{{ active ? active.name : 'No instance' }}
15+
</span>
16+
</div>
17+
<div class="dropdown-menu federation-instance-menu" ngbDropdownMenu>
18+
<h6 class="dropdown-header text-uppercase">Instances</h6>
19+
20+
<div *ngIf="errorMessage" class="px-3 py-2 small text-danger">{{ errorMessage }}</div>
21+
22+
<div *ngIf="instances.length === 0" class="px-3 py-2 small text-muted">
23+
No instances connected.
24+
</div>
25+
26+
<div *ngFor="let instance of instances; trackBy: trackById"
27+
class="federation-instance-row dropdown-item d-flex align-items-center justify-content-between"
28+
[class.active]="active && instance.id === active.id">
29+
<button type="button"
30+
class="btn btn-link p-0 flex-grow-1 text-left text-truncate federation-instance-name-btn"
31+
(click)="select(instance, $event)">
32+
<i *ngIf="active && instance.id === active.id" class="icon-check mr-1"></i>
33+
{{ instance.name }}
34+
</button>
35+
<div class="federation-instance-actions ml-2 d-flex align-items-center">
36+
<button type="button"
37+
class="btn btn-sm btn-link p-1"
38+
title="Edit"
39+
(click)="openEdit(instance, $event)">
40+
<i class="icon-pencil"></i>
41+
</button>
42+
<button type="button"
43+
class="btn btn-sm btn-link p-1 text-danger"
44+
title="Remove"
45+
[disabled]="pendingDeleteId === instance.id"
46+
(click)="remove(instance, $event)">
47+
<i class="icon-trash"></i>
48+
</button>
49+
</div>
50+
</div>
51+
52+
<div class="dropdown-divider"></div>
53+
<button type="button"
54+
class="dropdown-item d-flex align-items-center federation-instance-add"
55+
(click)="openCreate($event)">
56+
<i class="icon-plus3 mr-2"></i>
57+
Add instance
58+
</button>
59+
</div>
60+
</div>

0 commit comments

Comments
 (0)