Merge pull request #3948 from FinnStutzenstein/changeIdClient

ChangeId for the client
This commit is contained in:
Finn Stutzenstein 2018-11-08 08:38:42 +01:00 committed by GitHub
commit 85b91e7101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 484 additions and 362 deletions

View File

@ -10,7 +10,7 @@
"e2e": "ng e2e",
"compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port",
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json",
"format:fix": "pretty-quick --staged"
"prettify": "pretty-quick"
},
"private": true,
"dependencies": {

View File

@ -1,13 +1,12 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { OperatorService } from 'app/core/services/operator.service';
import { OpenSlidesComponent } from '../../openslides.component';
import { environment } from 'environments/environment';
import { User } from '../../shared/models/users/user';
import { OpenSlidesService } from './openslides.service';
import { Router } from '@angular/router';
import { HttpService } from './http.service';
/**
* The data returned by a post request to the login route.
@ -28,13 +27,16 @@ export class AuthService extends OpenSlidesComponent {
* Initializes the httpClient and the {@link OperatorService}.
*
* Calls `super()` from the parent class.
* @param http HttpClient
* @param operator who is using OpenSlides
* @param http HttpService to send requests to the server
* @param operator Who is using OpenSlides
* @param OpenSlides The openslides service
* @param router To navigate
*/
public constructor(
private http: HttpClient,
private http: HttpService,
private operator: OperatorService,
private OpenSlides: OpenSlidesService
private OpenSlides: OpenSlidesService,
private router: Router
) {
super();
}
@ -48,18 +50,16 @@ export class AuthService extends OpenSlidesComponent {
*
* @param username
* @param password
* @returns The login response.
*/
public login(username: string, password: string): Observable<LoginResponse> {
public async login(username: string, password: string): Promise<LoginResponse> {
const user = {
username: username,
password: password
};
return this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user).pipe(
tap((response: LoginResponse) => {
this.operator.user = new User(response.user);
}),
catchError(this.handleError())
) as Observable<LoginResponse>;
const response = await this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user);
this.operator.user = new User(response.user);
return response;
}
/**
@ -68,10 +68,14 @@ export class AuthService extends OpenSlidesComponent {
* Will clear the current {@link OperatorService} and
* send a `post`-request to `/apps/users/logout/'`
*/
public logout(): void {
public async logout(): Promise<void> {
this.operator.user = null;
this.http.post<any>(environment.urlPrefix + '/users/logout/', {}).subscribe(() => {
this.OpenSlides.reboot();
});
try {
await this.http.post(environment.urlPrefix + '/users/logout/', {});
} catch (e) {
// We do nothing on failures. Reboot OpenSlides anyway.
}
this.router.navigate(['/']);
this.OpenSlides.reboot();
}
}

View File

@ -5,6 +5,7 @@ import { WebsocketService } from './websocket.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { DataStoreService } from './data-store.service';
import { BaseModel } from '../../shared/models/base/base-model';
interface AutoupdateFormat {
/**
@ -22,9 +23,19 @@ interface AutoupdateFormat {
};
/**
* The current change id for this autoupdate
* The lower change id bond for this autoupdate
*/
change_id: number;
from_change_id: number;
/**
* The upper change id bound for this autoupdate
*/
to_change_id: number;
/**
* Flag, if this autoupdate contains all data. If so, the DS needs to be resetted.
*/
all_data: boolean;
}
/**
@ -41,14 +52,16 @@ export class AutoupdateService extends OpenSlidesComponent {
/**
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
* @param websocketService
* @param DS
* @param modelMapper
*/
public constructor(
websocketService: WebsocketService,
private websocketService: WebsocketService,
private DS: DataStoreService,
private modelMapper: CollectionStringModelMapperService
) {
super();
websocketService.getOberservable<AutoupdateFormat>('autoupdate').subscribe(response => {
this.websocketService.getOberservable<AutoupdateFormat>('autoupdate').subscribe(response => {
this.storeResponse(response);
});
}
@ -56,37 +69,87 @@ export class AutoupdateService extends OpenSlidesComponent {
/**
* Handle the answer of incoming data via {@link WebsocketService}.
*
* Bundles the data per action and collection. THis speeds up the caching in the DataStore.
*
* Detects the Class of an incomming model, creates a new empty object and assigns
* the data to it using the deserialize function.
* the data to it using the deserialize function. Also models that are flagged as deleted
* will be removed from the data store.
*
* Saves models in DataStore.
* Handles the change ids of all autoupdates.
*/
public storeResponse(autoupdate: AutoupdateFormat): void {
// Delete the removed objects from the DataStore
Object.keys(autoupdate.deleted).forEach(collection => {
this.DS.remove(collection, autoupdate.deleted[collection], autoupdate.change_id);
});
private async storeResponse(autoupdate: AutoupdateFormat): Promise<void> {
console.log('got autoupdate', autoupdate);
// Add the objects to the DataStore.
if (autoupdate.all_data) {
await this.storeAllData(autoupdate);
} else {
await this.storePartialAutoupdate(autoupdate);
}
}
/**
* Stores all data from the autoupdate. This means, that the DS is resettet and filled with just the
* given data from the autoupdate.
* @param autoupdate The autoupdate
*/
private async storeAllData(autoupdate: AutoupdateFormat): Promise<void> {
let elements: BaseModel[] = [];
Object.keys(autoupdate.changed).forEach(collection => {
const targetClass = this.modelMapper.getModelConstructor(collection);
if (!targetClass) {
throw new Error(`Unregistered resource ${collection}`);
}
this.DS.add(autoupdate.changed[collection].map(model => new targetClass(model)), autoupdate.change_id);
elements = elements.concat(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection]));
});
await this.DS.set(elements, autoupdate.to_change_id);
}
/**
* handles a normal autoupdate that is not a full update (all_data=false).
* @param autoupdate The autoupdate
*/
private async storePartialAutoupdate(autoupdate: AutoupdateFormat): Promise<void> {
const maxChangeId = this.DS.maxChangeId;
if (autoupdate.from_change_id <= maxChangeId && autoupdate.to_change_id <= maxChangeId) {
console.log('ignore');
return; // Ignore autoupdates, that lay full behind our changeid.
}
// Normal autoupdate
if (autoupdate.from_change_id <= maxChangeId + 1 && autoupdate.to_change_id > maxChangeId) {
// Delete the removed objects from the DataStore
for (const collection of Object.keys(autoupdate.deleted)) {
await this.DS.remove(collection, autoupdate.deleted[collection]);
}
// Add the objects to the DataStore.
for (const collection of Object.keys(autoupdate.changed)) {
await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection]));
}
console.log('new max change id', autoupdate.to_change_id);
await this.DS.flushToStorage(autoupdate.to_change_id);
} else {
// autoupdate fully in the future. we are missing something!
this.requestChanges();
}
}
/**
* Creates baseModels for each plain object
* @param collection The collection all models have to be from.
* @param models All models that should be mapped to BaseModels
* @returns A list of basemodels constructed from the given models.
*/
private mapObjectsToBaseModels(collection: string, models: object[]): BaseModel[] {
const targetClass = this.modelMapper.getModelConstructor(collection);
if (!targetClass) {
throw new Error(`Unregistered resource ${collection}`);
}
return models.map(model => new targetClass(model));
}
/**
* Sends a WebSocket request to the Server with the maxChangeId of the DataStore.
* The server should return an autoupdate with all new data.
*
* TODO: Wait for changeIds to be implemented on the server.
*/
public requestChanges(): void {
console.log('requesting changed objects');
// this.websocketService.send('changeIdRequest', this.DS.maxChangeId);
console.log('requesting changed objects with DS max change id', this.DS.maxChangeId + 1);
this.websocketService.send('getElements', { change_id: this.DS.maxChangeId + 1 });
}
}

View File

@ -1,15 +0,0 @@
import { TestBed, inject } from '@angular/core/testing';
import { CacheService } from './cache.service';
describe('WebsocketService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CacheService]
});
});
it('should be created', inject([CacheService], (service: CacheService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -1,151 +0,0 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';
import { Observable } from 'rxjs';
/**
* Container objects for the setQueue.
*/
interface SetContainer {
key: string;
item: any;
callback: (value: boolean) => void;
}
/**
* Container objects for the removeQueue.
*/
interface RemoveContainer {
key: string;
callback: (value: boolean) => void;
}
/**
* Provides an async API to an key-value store using ngx-pwa which is internally
* using IndexedDB or localStorage as a fallback.
*/
@Injectable({
providedIn: 'root'
})
export class CacheService {
/**
* The queue of waiting set requests. Just one request (with the same key, which is
* an often case) at the time can be handeled. The SetContainer encapsulates the key,
* item and callback.
*/
private setQueue: SetContainer[] = [];
/**
* The queue of waiting remove requests. Same reason for the queue es the @name _setQueue.
*/
private removeQueue: RemoveContainer[] = [];
/**
* Constructor to create the CacheService. Needs the localStorage service.
* @param localStorage
*/
public constructor(private localStorage: LocalStorage) {}
/**
* Sets the item into the store asynchronously.
* @param key
* @param item
* @param callback An optional callback that is called on success
*/
public set(key: string, item: any, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the set request into the queue
const queueObj: SetContainer = {
key: key,
item: item,
callback: callback
};
this.setQueue.unshift(queueObj);
// If this is the only object, put it into the cache.
if (this.setQueue.length === 1) {
this.localStorage.setItem(key, item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* gets called, if a set of the first item in the queue was successful.
* @param value success
*/
private _setCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.setQueue[0].callback(success);
this.setQueue.pop();
// If there are objects left, insert the first one into the cache.
if (this.setQueue.length > 0) {
const queueObj = this.setQueue[0];
this.localStorage.setItem(queueObj.key, queueObj.item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* get a value from the store. You need to subscribe to the request to retrieve the value.
* @param key The key to get the value from
*/
public get<T>(key: string): Observable<T> {
return this.localStorage.getItem<T>(key);
}
/**
* Remove the key from the store.
* @param key The key to remove the value from
* @param callback An optional callback that is called on success
*/
public remove(key: string, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the remove request into the queue
const queueObj: RemoveContainer = {
key: key,
callback: callback
};
this.removeQueue.unshift(queueObj);
// If this is the only object, remove it from the cache.
if (this.removeQueue.length === 1) {
this.localStorage.removeItem(key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* gets called, if a remove of the first item in the queue was successfull.
* @param value success
*/
private _removeCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.removeQueue[0].callback(success);
this.removeQueue.pop();
// If there are objects left, remove the first one from the cache.
if (this.removeQueue.length > 0) {
const queueObj = this.removeQueue[0];
this.localStorage.removeItem(queueObj.key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* Clear the whole cache
* @param callback An optional callback that is called on success
*/
public clear(callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
this.localStorage.clear().subscribe(callback, this._error);
}
/**
* First error catching function.
*/
private _error(): void {
console.error('caching error', arguments);
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
import { CacheService } from './cache.service';
import { StorageService } from './storage.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
/**
@ -64,7 +64,7 @@ export class DataStoreService {
* all cases equal!
*/
private modelStore: ModelStorage = {};
private JsonStore: JsonStorage = {};
private jsonStore: JsonStorage = {};
/**
* Observable subject for changed models in the datastore.
@ -107,42 +107,51 @@ export class DataStoreService {
}
/**
* Empty constructor for dataStore
* @param cacheService use CacheService to cache the DataStore.
* @param storageService use StorageService to preserve the DataStore.
* @param modelMapper
*/
public constructor(private cacheService: CacheService, private modelMapper: CollectionStringModelMapperService) {}
public constructor(
private storageService: StorageService,
private modelMapper: CollectionStringModelMapperService
) {}
/**
* Gets the DataStore from cache and instantiate all models out of the serialized version.
* @returns The max change id.
*/
public initFromCache(): Promise<number> {
public async initFromStorage(): Promise<number> {
// This promise will be resolved with the maximal change id of the cache.
return new Promise<number>(resolve => {
this.cacheService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS').subscribe((store: JsonStorage) => {
if (store != null) {
// There is a store. Deserialize it
this.JsonStore = store;
this.modelStore = this.deserializeJsonStore(this.JsonStore);
// Get the maxChangeId from the cache
this.cacheService
.get<number>(DataStoreService.cachePrefix + 'maxChangeId')
.subscribe((maxChangeId: number) => {
if (maxChangeId == null) {
maxChangeId = 0;
}
this._maxChangeId = maxChangeId;
resolve(maxChangeId);
});
} else {
// No store here, so get all data from the server.
resolve(0);
}
const store = await this.storageService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS');
if (store) {
console.log('init from storage:', store);
// There is a store. Deserialize it
this.jsonStore = store;
this.modelStore = this.deserializeJsonStore(this.jsonStore);
// Get the maxChangeId from the cache
let maxChangeId = await this.storageService.get<number>(DataStoreService.cachePrefix + 'maxChangeId');
if (!maxChangeId) {
maxChangeId = 0;
}
this._maxChangeId = maxChangeId;
// update observers
Object.keys(this.modelStore).forEach(collection => {
Object.keys(this.modelStore[collection]).forEach(id => {
this.changedSubject.next(this.modelStore[collection][id]);
});
});
});
} else {
this.jsonStore = {};
this.modelStore = {};
this._maxChangeId = 0;
}
return this.maxChangeId;
}
/**
* Deserialze the given serializedStorage and returns a Storage.
* @param serializedStore The store to deserialize
* @returns The serialized storage
*/
private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage {
const storage: ModelStorage = {};
@ -161,17 +170,21 @@ export class DataStoreService {
/**
* Clears the complete DataStore and Cache.
* @param callback
*/
public clear(callback?: (value: boolean) => void): void {
public async clear(): Promise<void> {
console.log('DS clear');
this.modelStore = {};
this.JsonStore = {};
this.jsonStore = {};
this._maxChangeId = 0;
this.cacheService.remove(DataStoreService.cachePrefix + 'DS', () => {
this.cacheService.remove(DataStoreService.cachePrefix + 'maxChangeId', callback);
});
await this.storageService.remove(DataStoreService.cachePrefix + 'DS');
await this.storageService.remove(DataStoreService.cachePrefix + 'maxChangeId');
}
/**
* Returns the collection _string_ based on the model given. If a string is given, it's just returned.
* @param collectionType Either a Model constructor or a string.
* @returns the collection string
*/
private getCollectionString<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): string {
if (typeof collectionType === 'string') {
return collectionType;
@ -262,62 +275,78 @@ export class DataStoreService {
* Add one or multiple models to dataStore.
*
* @param models BaseModels to add to the store
* @param changeId The changeId of this update
* @example this.DS.add([new User(1)], changeId)
* @example this.DS.add([new User(2), new User(3)], changeId)
* @param changeId The changeId of this update. If given, the storage will be flushed to the
* cache. Else one can call {@method flushToStorage} to do this manually.
* @example this.DS.add([new User(1)])
* @example this.DS.add([new User(2), new User(3)])
* @example this.DS.add(arrayWithUsers, changeId)
*/
public add(models: BaseModel[], changeId: number): void {
public async add(models: BaseModel[], changeId?: number): Promise<void> {
models.forEach(model => {
const collectionString = model.collectionString;
if (this.modelStore[collectionString] === undefined) {
this.modelStore[collectionString] = {};
const collection = model.collectionString;
if (this.modelStore[collection] === undefined) {
this.modelStore[collection] = {};
}
this.modelStore[collectionString][model.id] = model;
this.modelStore[collection][model.id] = model;
if (this.JsonStore[collectionString] === undefined) {
this.JsonStore[collectionString] = {};
if (this.jsonStore[collection] === undefined) {
this.jsonStore[collection] = {};
}
this.JsonStore[collectionString][model.id] = JSON.stringify(model);
this.jsonStore[collection][model.id] = JSON.stringify(model);
this.changedSubject.next(model);
});
this.storeToCache(changeId);
if (changeId) {
await this.flushToStorage(changeId);
}
}
/**
* removes one or multiple models from dataStore.
*
* @param Type The desired BaseModel type to be read from the datastore
* @param collectionString The desired BaseModel type to be removed from the datastore
* @param ids A list of IDs of BaseModels to remove from the datastore
* @param changeId The changeId of this update
* @example this.DS.remove('users/user', [myUser.id, 3, 4], 38213)
* @param changeId The changeId of this update. If given, the storage will be flushed to the
* cache. Else one can call {@method flushToStorage} to do this manually.
* @example this.DS.remove('users/user', [myUser.id, 3, 4])
*/
public remove(collectionString: string, ids: number[], changeId: number): void {
public async remove(collectionString: string, ids: number[], changeId?: number): Promise<void> {
ids.forEach(id => {
if (this.modelStore[collectionString]) {
delete this.modelStore[collectionString][id];
}
if (this.JsonStore[collectionString]) {
delete this.JsonStore[collectionString][id];
if (this.jsonStore[collectionString]) {
delete this.jsonStore[collectionString][id];
}
this.deletedSubject.next({
collection: collectionString,
id: id
});
});
this.storeToCache(changeId);
if (changeId) {
await this.flushToStorage(changeId);
}
}
/**
* Resets the DataStore and set the given models as the new content.
* @param models A list of models to set the DataStore to.
* @param newMaxChangeId Optional. If given, the max change id will be updated.
*/
public async set(models: BaseModel[], newMaxChangeId?: number): Promise<void> {
this.modelStore = {};
this.jsonStore = {};
await this.add(models, newMaxChangeId);
}
/**
* Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger
* @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache.
*/
private storeToCache(changeId: number): void {
this.cacheService.set(DataStoreService.cachePrefix + 'DS', this.JsonStore);
if (changeId > this._maxChangeId) {
this._maxChangeId = changeId;
this.cacheService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId);
}
public async flushToStorage(changeId: number): Promise<void> {
console.log('flush to storage');
this._maxChangeId = changeId;
await this.storageService.set(DataStoreService.cachePrefix + 'DS', this.jsonStore);
await this.storageService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId);
}
/**

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { OfflineService } from './offline.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('OfflineService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [OfflineService]
});
});
it('should be created', inject([OfflineService], (service: OfflineService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,68 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { DataStoreService } from './data-store.service';
import { WhoAmIResponse } from './operator.service';
/**
* This service handles everything connected with being offline.
*
* TODO: This is just a stub. Needs to be done in the future; Maybe we cancel this whole concept
* of this service. We'll see whats happens here..
*/
@Injectable({
providedIn: 'root'
})
export class OfflineService extends OpenSlidesComponent {
private _offline = false;
public get offline(): boolean {
return this._offline;
}
/**
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
* @param DS
*/
public constructor(private DS: DataStoreService) {
super();
}
/**
* Sets the offline flag. Restores the DataStoreService to the last known configuration.
*/
public async goOfflineBecauseFailedWhoAmI(): Promise<void> {
this._offline = true;
console.log('offline because whoami failed.');
// TODO: Init the DS from cache.
await this.DS.clear();
}
/**
* TODO: Should be somehow connected to the websocket service.
*/
public goOfflineBecauseConnectionLost(): void {
this._offline = true;
console.log('offline because connection lost.');
}
/**
* TODO: Should be somehow connected to the websocket service.
*/
public goOnline(): void {
this._offline = false;
}
/**
* Returns the last cached WhoAmI response.
*/
public getLastWhoAmI(): WhoAmIResponse {
// TODO: use a cached WhoAmI response.
return {
user_id: null,
guest_enabled: false,
user: null
};
}
}

View File

@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service';
import { OperatorService } from './operator.service';
import { CacheService } from './cache.service';
import { StorageService } from './storage.service';
import { AutoupdateService } from './autoupdate.service';
import { DataStoreService } from './data-store.service';
@ -21,15 +21,16 @@ export class OpenSlidesService extends OpenSlidesComponent {
public redirectUrl: string;
/**
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
* @param cacheService
* Constructor to create the OpenSlidesService. Registers itself to the WebsocketService.
* @param storageService
* @param operator
* @param websocketService
* @param router
* @param autoupdateService
* @param DS
*/
public constructor(
private cacheService: CacheService,
private storageService: StorageService,
private operator: OperatorService,
private websocketService: WebsocketService,
private router: Router,
@ -51,54 +52,51 @@ export class OpenSlidesService extends OpenSlidesComponent {
* the bootup-sequence: Do a whoami request and if it was successful, do
* {@method afterLoginBootup}. If not, redirect the user to the login page.
*/
public bootup(): void {
public async bootup(): Promise<void> {
// start autoupdate if the user is logged in:
this.operator.whoAmI().subscribe(resp => {
this.operator.guestsEnabled = resp.guest_enabled;
if (!resp.user && !resp.guest_enabled) {
this.redirectUrl = location.pathname;
// Goto login, if the user isn't login and guests are not allowed
this.router.navigate(['/login']);
} else {
this.afterLoginBootup(resp.user_id);
}
});
const response = await this.operator.whoAmI();
this.operator.guestsEnabled = response.guest_enabled;
if (!response.user && !response.guest_enabled) {
this.redirectUrl = location.pathname;
// Goto login, if the user isn't login and guests are not allowed
this.router.navigate(['/login']);
} else {
await this.afterLoginBootup(response.user_id);
}
}
/**
* the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore
* and websocket.
* and websocket. This "login" also may be the "login" of an anonymous when he is using
* OpenSlides as a guest.
* @param userId
*/
public afterLoginBootup(userId: number): void {
public async afterLoginBootup(userId: number): Promise<void> {
// Else, check, which user was logged in last time
this.cacheService.get<number>('lastUserLoggedIn').subscribe((id: number) => {
// if the user id changed, reset the cache.
if (userId !== id) {
this.DS.clear((value: boolean) => {
this.setupDataStoreAndWebSocket();
});
this.cacheService.set('lastUserLoggedIn', userId);
} else {
this.setupDataStoreAndWebSocket();
}
});
const lastUserId = await this.storageService.get<number>('lastUserLoggedIn');
console.log('user transition:', lastUserId, '->', userId);
// if the user changed, reset the cache and save the new user.
if (userId !== lastUserId) {
await this.DS.clear();
await this.storageService.set('lastUserLoggedIn', userId);
}
await this.setupDataStoreAndWebSocket();
}
/**
* Init DS from cache and after this start the websocket service.
*/
private setupDataStoreAndWebSocket(): void {
this.DS.initFromCache().then((changeId: number) => {
this.websocketService.connect(
false,
changeId
);
});
private async setupDataStoreAndWebSocket(): Promise<void> {
let changeId = await this.DS.initFromStorage();
console.log('change ID on DS setup', changeId);
if (changeId > 0) {
changeId += 1;
}
this.websocketService.connect({ changeId: changeId }); // Request changes after changeId.
}
/**
* SHuts down OpenSlides. The websocket is closed and the operator is not set.
* Shuts OpenSlides down. The websocket is closed and the operator is not set.
*/
public shutdown(): void {
this.websocketService.close();
@ -108,32 +106,31 @@ export class OpenSlidesService extends OpenSlidesComponent {
/**
* Shutdown and bootup.
*/
public reboot(): void {
public async reboot(): Promise<void> {
this.shutdown();
this.bootup();
await this.bootup();
}
/**
* Verify that the operator is the same as it was before a reconnect.
* Verify that the operator is the same as it was before. Should be alled on a reconnect.
*/
private checkOperator(): void {
this.operator.whoAmI().subscribe(resp => {
// User logged off.
if (!resp.user && !resp.guest_enabled) {
this.shutdown();
this.router.navigate(['/login']);
private async checkOperator(): Promise<void> {
const response = await this.operator.whoAmI();
// User logged off.
if (!response.user && !response.guest_enabled) {
this.shutdown();
this.router.navigate(['/login']);
} else {
if (
(this.operator.user && this.operator.user.id !== response.user_id) ||
(!this.operator.user && response.user_id)
) {
// user changed
await this.reboot();
} else {
if (
(this.operator.user && this.operator.user.id !== resp.user_id) ||
(!this.operator.user && resp.user_id)
) {
// user changed
this.reboot();
} else {
// User is still the same, but check for missed autoupdates.
this.autoupdateService.requestChanges();
}
// User is still the same, but check for missed autoupdates.
this.autoupdateService.requestChanges();
}
});
}
}
}

View File

@ -1,12 +1,12 @@
import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { tap, catchError } from 'rxjs/operators';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Group } from 'app/shared/models/users/group';
import { User } from '../../shared/models/users/user';
import { environment } from 'environments/environment';
import { DataStoreService } from './data-store.service';
import { OfflineService } from './offline.service';
/**
* Permissions on the client are just strings. This makes clear, that
@ -17,7 +17,7 @@ export type Permission = string;
/**
* Response format of the WHoAMI request.
*/
interface WhoAmIResponse {
export interface WhoAmIResponse {
user_id: number;
guest_enabled: boolean;
user: User;
@ -77,9 +77,14 @@ export class OperatorService extends OpenSlidesComponent {
private operatorSubject: BehaviorSubject<User> = new BehaviorSubject<User>(null);
/**
* Sets up an observer for watching changes in the DS. If the operator user or groups are changed,
* the operator's permissions are updated.
*
* @param http HttpClient
* @param DS
* @param offlineService
*/
public constructor(private http: HttpClient, private DS: DataStoreService) {
public constructor(private http: HttpClient, private DS: DataStoreService, private offlineService: OfflineService) {
super();
this.DS.changeObservable.subscribe(newModel => {
@ -101,16 +106,21 @@ export class OperatorService extends OpenSlidesComponent {
/**
* Calls `/apps/users/whoami` to find out the real operator.
* @returns The response of the WhoAmI request.
*/
public whoAmI(): Observable<WhoAmIResponse> {
return this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').pipe(
tap((response: WhoAmIResponse) => {
if (response && response.user_id) {
this.user = new User(response.user);
}
}),
catchError(this.handleError())
) as Observable<WhoAmIResponse>;
public async whoAmI(): Promise<WhoAmIResponse> {
try {
const response = await this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').toPromise();
if (response && response.user) {
this.user = new User(response.user);
}
return response;
} catch (e) {
// TODO: Implement the offline service. Currently a guest-whoami response is returned and
// the DS cleared.
this.offlineService.goOfflineBecauseFailedWhoAmI();
return this.offlineService.getLastWhoAmI();
}
}
/**

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [StorageService]
});
});
it('should be created', inject([StorageService], (service: StorageService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';
/**
* Provides an async API to an key-value store using ngx-pwa which is internally
* using IndexedDB or localStorage as a fallback.
*/
@Injectable({
providedIn: 'root'
})
export class StorageService {
/**
* Constructor to create the StorageService. Needs the localStorage service.
* @param localStorage
*/
public constructor(private localStorage: LocalStorage) {}
/**
* Sets the item into the store asynchronously.
* @param key
* @param item
*/
public async set(key: string, item: any): Promise<void> {
if (item === null || item === undefined) {
await this.remove(key); // You cannot do a setItem with null or undefined...
} else {
if (!(await this.localStorage.setItem(key, item).toPromise())) {
throw new Error('Could not set the item.');
}
}
}
/**
* get a value from the store. You need to subscribe to the request to retrieve the value.
* @param key The key to get the value from
* @returns The requested value to the key
*/
public async get<T>(key: string): Promise<T> {
return await this.localStorage.getItem<T>(key).toPromise();
}
/**
* Remove the key from the store.
* @param key The key to remove the value from
*/
public async remove(key: string): Promise<void> {
if (!(await this.localStorage.removeItem(key).toPromise())) {
throw new Error('Could not delete the item.');
}
}
/**
* Clear the whole cache
*/
public async clear(): Promise<void> {
console.log('clear storage');
if (!(await this.localStorage.clear().toPromise())) {
throw new Error('Could not clear the storage.');
}
}
}

View File

@ -69,9 +69,17 @@ export class WebsocketService {
*/
private subjects: { [type: string]: Subject<any> } = {};
/**
* Saves, if the service is in retry mode to get a connection to a previos connection lost.
*/
private retry = false;
/**
* Constructor that handles the router
* @param router the URL Router
* @param matSnackBar
* @param zone
* @param translate
*/
public constructor(
private router: Router,
@ -85,34 +93,46 @@ export class WebsocketService {
*
* Uses NgZone to let all callbacks run in the angular context.
*/
public connect(retry: boolean = false, changeId?: number): void {
public connect(
options: {
changeId?: number;
enableAutoupdates?: boolean;
} = {}
): void {
if (this.websocket) {
return;
this.close();
}
// set defaults
options = Object.assign(options, {
enableAutoupdates: true
});
const queryParams: QueryParams = {
'change_id': 0,
'autoupdate': true,
autoupdate: options.enableAutoupdates
};
// comment-in if changes IDs are supported on server side.
/*if (changeId !== undefined) {
queryParams.changeId = changeId.toString();
}*/
if (options.changeId !== undefined) {
queryParams.change_id = options.changeId;
}
// Create the websocket
const socketProtocol = this.getWebSocketProtocol();
const socketServer = window.location.hostname + ':' + window.location.port;
const socketPath = this.getWebSocketPath(queryParams);
console.log('connect to', socketProtocol + socketServer + socketPath);
this.websocket = new WebSocket(socketProtocol + socketServer + socketPath);
// connection established. If this connect attept was a retry,
// The error notice will be removed and the reconnectSubject is published.
this.websocket.onopen = (event: Event) => {
this.zone.run(() => {
if (retry) {
if (this.retry) {
if (this.connectionErrorNotice) {
this.connectionErrorNotice.dismiss();
this.connectionErrorNotice = null;
}
this.retry = false;
this._reconnectEvent.emit();
}
this._connectEvent.emit();
@ -151,7 +171,8 @@ export class WebsocketService {
// A random retry timeout between 2000 and 5000 ms.
const timeout = Math.floor(Math.random() * 3000 + 2000);
setTimeout(() => {
this.connect((retry = true));
this.retry = true;
this.connect({ enableAutoupdates: true });
}, timeout);
}
});
@ -222,11 +243,13 @@ export class WebsocketService {
const keys: string[] = Object.keys(queryParams);
if (keys.length > 0) {
path += '?' + keys
.map(key => {
return key + '=' + queryParams[key].toString();
})
.join('&');
path +=
'?' +
keys
.map(key => {
return key + '=' + queryParams[key].toString();
})
.join('&');
}
return path;
}

View File

@ -127,26 +127,27 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
*
* Send username and password to the {@link AuthService}
*/
public formLogin(): void {
public async formLogin(): Promise<void> {
this.loginErrorMsg = '';
this.inProcess = true;
this.authService.login(this.loginForm.value.username, this.loginForm.value.password).subscribe(res => {
try {
const res = await this.authService.login(this.loginForm.value.username, this.loginForm.value.password);
this.inProcess = false;
if (res instanceof HttpErrorResponse) {
this.OpenSlides.afterLoginBootup(res.user_id);
let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/';
if (redirect.includes('login')) {
redirect = '/';
}
this.router.navigate([redirect]);
} catch (e) {
if (e instanceof HttpErrorResponse) {
this.loginForm.setErrors({
notFound: true
});
this.loginErrorMsg = res.error.detail;
} else {
this.OpenSlides.afterLoginBootup(res.user_id);
let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/';
if (redirect.includes('login')) {
redirect = '/';
}
this.router.navigate([redirect]);
this.loginErrorMsg = e.error.detail;
this.inProcess = false;
}
});
}
}
/**