import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, BehaviorSubject, AsyncSubject, throwError as observableThrowError, of, EMPTY } from 'rxjs'; import { map, catchError, combineLatest, tap } from 'rxjs/operators'; import { CookieService } from 'ngx-cookie-service'; import { StompConfig, StompRService } from '@stomp/ng2-stompjs'; import { Message } from '@stomp/stompjs'; import { SnackBarService } from './snackbar.service'; import { PreferencesService, Prefs } from './preferences/preferences.service'; import { VO, User, Update, State, Deployment, DeploymentState, SSHKey, NewSSHKey, IdP, Service, Site, JSONObject, IdPInfo } from './types/types.module'; // TODO terrible name export interface Combination { user: User; prefs: Prefs; } @Injectable() export class UserService { private initialized = false; private loggedIn = false; private idpInfo: AsyncSubject = new AsyncSubject(); private observerDebugging = false; // relogin on failed XHR calls // is turned off when the user is deactivated private autoReLogin = true; private user: User; private user$: BehaviorSubject = new BehaviorSubject(undefined); private userEmissions: number = 0; private sshKeys: Map = new Map([]); private sshKeys$ = new BehaviorSubject([]); private deploymentStates: Map = new Map([]); private deploymentStates$ = new BehaviorSubject([]); private deployments: Map = new Map([]); private deployments$ = new BehaviorSubject([]); public voSelector = (user: User) => user ? user.vos : []; public serviceSelector = (user: User) => user ? user.services : []; constructor( private cookieService: CookieService, private http: HttpClient, private snackBar: SnackBarService, private stompService: StompRService, private prefs: PreferencesService, ) { this.connect(); } // PRIVATE API private connect(): void { this.fetch(); this.fetchIdPInfo(); this.userSrc().subscribe( (newUser: User) => { if (newUser == undefined || newUser == null) { // LOGGED OUT // show the logout to the user if (this.loggedIn) { this.snackBar.open('Logged out'); // purge all values this.stompService.disconnect(); this.user = undefined; this.deployments = new Map([]); this.deployments$.next([]); this.sshKeys = new Map([]); this.sshKeys$.next([]); } this.loggedIn = false; } else { // LOGGED IN // show the login to the user if (!this.loggedIn) { this.snackBar.open('Logged in'); } if (newUser.id) { this.connectLiveUpdates(newUser.id); } if (newUser.ssh_keys) { this.sshKeys = new Map([]); newUser.ssh_keys.forEach( (key: SSHKey) => this.sshKeys.set(key.id, key), ); this.sshKeys$.next(Array.from(this.sshKeys.values())); } if (newUser.deployments) { newUser.deployments.forEach( (newDep: Deployment) => { this.deployments.set(newDep.id, newDep); } ); this.deployments$.next(Array.from(this.deployments.values())); } if (newUser.states) { newUser.states.forEach((state: DeploymentState) => { this.deploymentStates.set(state.id, state) }); this.deploymentStates$.next(Array.from(this.deploymentStates.values())); } this.loggedIn = true; this.initialized = true; } }, this.handleError(true), () => console.log('user$ is complete'), ); } private connectLiveUpdates(userID: number): void { // handle with care const login = userID const passcode = this.cookieService.get('sessionid'); const stompConfig: StompConfig = { // Which server? url: 'wss://'+window.location.host+'/ws', // Headers // Typical keys: login, passcode, host headers: { login: 'webpage-client:' + login, passcode: passcode, }, // How often to heartbeat? // Interval in milliseconds, set to 0 to disable heartbeat_in: 0, // Typical value 0 - disabled heartbeat_out: 20000, // Typical value 20000 - every 20 seconds // Wait in milliseconds before attempting auto reconnect // Set to 0 to disable // Typical value 15000 (15 seconds) reconnect_delay: 15000, // Will log diagnostics on console debug: false, }; this.stompService.config = stompConfig; this.stompService.initAndConnect(); const subscription = this.stompService.subscribe( '/exchange/users/' + userID.toString() ); subscription.subscribe( (message: Message) => { let update: Update = JSON.parse(message.body); console.log('update:', update); // TODO rename error to msg if (update.error && update.error != '') { this.snackBar.open(update.error); } if (update.deployment_state) { this.updateDeploymentState(update.deployment_state); } if (update.deployment) { this.updateDeployment(update.deployment); } }, this.logErrorAndFetch, ); } private updateDeployment(dep: Deployment): void { if (dep != undefined && dep != null) { dep.states.forEach((state: DeploymentState) => this.updateDeploymentState(state), ); this.deployments.set(dep.id, dep); this.deployments$.next(Array.from(this.deployments.values())); } } private updateDeploymentState(ds: DeploymentState): void { if (ds != undefined && ds != null) { this.deploymentStates.set(ds.id, ds); this.deploymentStates$.next(Array.from(this.deploymentStates.values())); } } private updateState(update: State): void { if (update) { // report an occured error if (update.msg) { this.snackBar.open(update.msg); } if (update.session) { if (update.session.deactivated) { this.autoReLogin = false; } if (update.session.auth_error && update.session.auth_error !== 'Not authenticated') { this.snackBar.open(update.session.auth_error); } } this.user = update.user; this.user$.next(this.user); } else { this.user$.next(undefined); } } private fetch(): void { this.http.get('/backend/api/state').subscribe( (state: State) => this.updateState(state), this.handleError(false, 'Error fetching state. Try again later'), ); } private handleError(fetch: boolean, msg?: string) { return (error: any) => { if (error.status === 403 && this.autoReLogin) { this.login(); return } if (msg) { this.snackBar.open(msg); } console.log('fetch:', error); if (fetch) { this.fetch(); } return EMPTY } } private logErrorAndFetch(error: any) { if (error.status === 403 && this.autoReLogin) { this.login(); } console.log(error); this.snackBar.open('Error'); this.fetch(); return observableThrowError(error); } private tapLogger(name: string): (e: any) => void { return e => { if (this.observerDebugging) { if (name === 'userSrc') { this.userEmissions++; console.log(name, this.userEmissions, e); } else { console.log(name, e); } } } } private fetchIdPInfo(): void { this.http.get('/backend/auth/v1/info').subscribe( idpInfo => { this.idpInfo.next(idpInfo); this.idpInfo.complete(); } ); } // PUBLIC API public connectIdPInfo(): Observable { return this.idpInfo.asObservable().pipe( combineLatest( this.prefs.connect(), (authInfo, prefs) => { const defaultIdP: IdP = authInfo.idps.find((idp: IdP) => idp.id === authInfo.default); if (prefs != undefined && prefs.preferredIdP != undefined) { authInfo.selectedIdP = prefs.preferredIdP; } else if (defaultIdP) { authInfo.selectedIdP = defaultIdP; } else if (authInfo.idps.length > 0) { authInfo.selectedIdP = authInfo.idps[0]; } else { console.log("No IdPs available. Unable to login"); } return authInfo; }, ), ); } public login(idp?: IdP): void { const redirect = (idp: IdP) => { window.location.href = '/?idphint=' + encodeURIComponent(idp.issuer_uri); }; if (idp != undefined) { this.prefs.setPreferredIdP(idp); redirect(idp); } else { this.prefs.connect().subscribe( prefs => { if (prefs.preferredIdP != undefined) { redirect(prefs.preferredIdP); } } ) } console.log('Unable to login: No IdP'); } public logout(): void { this.http.post('/backend/auth/v1/logout', undefined).subscribe( (state: State) => this.updateState(state), (err: HttpErrorResponse) => { this.updateState(undefined); } ); } public deleteUser() { return this.http.delete('/rest/user').pipe( catchError(this.handleError(true, "Error deleting user")), ).subscribe( _ => { this.user$.next(undefined); this.snackBar.open('Deleted user from server'); }, this.logErrorAndFetch, ); } public uploadSshKey(name: string, key: string | ArrayBuffer): void { this.http.post( '/rest/ssh-keys', {'name': name, 'key': key}, ).pipe( catchError(this.handleError(true, "Error changing deployment")), ).subscribe( (newKey: SSHKey) => { this.sshKeys.set(newKey.id, newKey); this.sshKeys$.next(Array.from(this.sshKeys.values())); }, this.logErrorAndFetch, ); } public removeSshKey(key: SSHKey) { console.log('Deleting key:', key); return this.http.delete( `/rest/ssh-key/${ key.id.toString() }`, ).pipe( catchError(this.handleError(true, "Error changing deployment")), ).subscribe( _ => { this.sshKeys.delete(key.id); this.sshKeys$.next(Array.from(this.sshKeys.values())); }, this.logErrorAndFetch, ); } public patchDeployment(depType: string, stateTarget: string, id: number): void { this.http.patch( `/rest/deployment/${ depType }/${ id.toString() }`, {'state_target': stateTarget}, ).pipe( catchError(this.handleError(true, "Error changing deployment")), ).subscribe( (dep: Deployment) => this.updateDeployment(dep), ); } public sentQuestionnaire(stateItemID: number, answers: JSONObject) { return this.http.patch( `/rest/state/${ stateItemID }`, {'answers': answers}, ).pipe( catchError(this.handleError(true, "Error submitting answers")), ).subscribe( (state: DeploymentState) => this.updateDeploymentState(state), this.logErrorAndFetch, ); } // DATA SERVICE API // public subscribeSpecific(selector: (user: User) => T): Observable { return this.userSrc().pipe( map(selector), tap(this.tapLogger('subscribeSpecific')), ); } public sshKeysSrc(): Observable { return this.sshKeys$.asObservable().pipe( tap(this.tapLogger('sshKeysSrc')), ); } public userSrc(): Observable { return this.user$.asObservable().pipe( tap(this.tapLogger('userSrc')), ); } public combiSrc(): Observable { return this.user$.asObservable().pipe( combineLatest( this.prefs.connect(), (u, p) => { return {user: u, prefs: p}; }, ), tap(this.tapLogger('userSrc')), ); } public deploymentsSrc(): Observable { return this.deployments$.asObservable().pipe( tap(this.tapLogger('deploymentsSrc')), ); } public depStatesSrc(): Observable { return this.deploymentStates$.asObservable().pipe( tap(this.tapLogger('depStatesSrc')), ); } public subscribeStateFor(service: Service): Observable { return this.deploymentStates$.asObservable().pipe( map((states: DeploymentState[]) => states.find( (dsi: DeploymentState) => dsi.service.id == service.id, )), tap(this.tapLogger('subscribeStateFor')), ); } public subscribeDeployment(selector: (dep: Deployment) => boolean): Observable { return this.deploymentsSrc().pipe( map((deployments: Deployment[]) => { return deployments.find( (dep: Deployment) => selector(dep), ); } ), tap(this.tapLogger('subscribeDeployment')), ); } public servicesSrc(): Observable { return this.subscribeSpecific(this.serviceSelector).pipe( tap(this.tapLogger('servicesSrc')), ); } public extractVOs(combi: Combination): VO[] { if (combi.prefs.showEmptyVOs) { return combi.user.vos; } // filter out VOs that have no services return combi.user.vos.filter( (vo: VO) => combi.user.services.some( (s: Service) => s.vos.some( svo => svo.id === vo.id, ), ), ); } }