Skip to content

Commit

Permalink
feat(admin-ui): Redirect to last route on log in after session expires
Browse files Browse the repository at this point in the history
Relates to #19
  • Loading branch information
michaelbromley committed Sep 21, 2018
1 parent e6ed20b commit 9a58320
Show file tree
Hide file tree
Showing 9 changed files with 90 additions and 47 deletions.
Expand Up @@ -85,7 +85,6 @@ export class AdminDetailComponent extends BaseDetailComponent<Administrator> imp
err => {
this.notificationService.error(_('common.notify-create-error'), {
entity: 'Administrator',
error: err.message,
});
},
);
Expand Down Expand Up @@ -119,7 +118,6 @@ export class AdminDetailComponent extends BaseDetailComponent<Administrator> imp
err => {
this.notificationService.error(_('common.notify-update-error'), {
entity: 'Administrator',
error: err.message,
});
},
);
Expand Down
Expand Up @@ -82,7 +82,6 @@ export class RoleDetailComponent extends BaseDetailComponent<Role> implements On
err => {
this.notificationService.error(_('common.notify-create-error'), {
entity: 'Role',
error: err.message,
});
},
);
Expand Down Expand Up @@ -113,7 +112,6 @@ export class RoleDetailComponent extends BaseDetailComponent<Role> implements On
err => {
this.notificationService.error(_('common.notify-update-error'), {
entity: 'Role',
error: err.message,
});
},
);
Expand Down
Expand Up @@ -128,7 +128,6 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues> i
err => {
this.notificationService.error(_('common.notify-create-error'), {
entity: 'Facet',
error: err.message,
});
},
);
Expand Down Expand Up @@ -181,7 +180,6 @@ export class FacetDetailComponent extends BaseDetailComponent<FacetWithValues> i
},
err => {
this.notificationService.error(_('common.notify-update-error'), {
error: err.message,
entity: 'Facet',
});
},
Expand Down
Expand Up @@ -151,7 +151,6 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
err => {
this.notificationService.error(_('common.notify-create-error'), {
entity: 'Product',
error: err.message,
});
},
);
Expand Down Expand Up @@ -198,7 +197,6 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
},
err => {
this.notificationService.error(_('common.notify-update-error'), {
error: err.message,
entity: 'Product',
});
},
Expand Down
63 changes: 48 additions & 15 deletions admin-ui/src/app/data/providers/interceptor.ts
Expand Up @@ -7,50 +7,83 @@ import {
HttpResponse,
} from '@angular/common/http';
import { Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

import { API_URL } from '../../app.config';
import { AuthService } from '../../core/providers/auth/auth.service';
import { _ } from '../../core/providers/i18n/mark-for-extraction';
import { NotificationService } from '../../core/providers/notification/notification.service';

import { DataService } from './data.service';

export const AUTH_REDIRECT_PARAM = 'redirectTo';

/**
* The default interceptor examines all HTTP requests & responses and automatically updates the requesting state
* and shows error notifications.
*/
@Injectable()
export class DefaultInterceptor implements HttpInterceptor {
constructor(private dataService: DataService, private injector: Injector) {}
constructor(
private dataService: DataService,
private injector: Injector,
private authService: AuthService,
private router: Router,
) {}

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
this.dataService.client.startRequest().subscribe();
return next.handle(req).pipe(
tap(
event => {
if (event instanceof HttpResponse) {
this.notifyOnGraphQLErrors(event);
this.notifyOnError(event);
this.dataService.client.completeRequest().subscribe();
}
},
err => {
if (err instanceof HttpErrorResponse) {
this.displayErrorNotification(err.message);
this.notifyOnError(err);
this.dataService.client.completeRequest().subscribe();
}
},
),
);
}

/**
* GraphQL errors still return 200 OK responses, but have the actual error message
* inside the body of the response.
*/
private notifyOnGraphQLErrors(response: HttpResponse<any>): void {
const graqhQLErrors = response.body.errors;
if (graqhQLErrors && Array.isArray(graqhQLErrors)) {
const message = graqhQLErrors.map(err => err.message).join('\n');
this.displayErrorNotification(message);
private notifyOnError(response: HttpResponse<any> | HttpErrorResponse) {
if (response instanceof HttpErrorResponse) {
if (response.status === 0) {
this.displayErrorNotification(_(`error.could-not-connect-to-server`), { url: API_URL });
} else {
this.displayErrorNotification(response.toString());
}
} else {
// GraphQL errors still return 200 OK responses, but have the actual error message
// inside the body of the response.
const graqhQLErrors = response.body.errors;
if (graqhQLErrors && Array.isArray(graqhQLErrors)) {
const firstStatus: number = graqhQLErrors[0].message.statusCode;
switch (firstStatus) {
case 401:
this.displayErrorNotification(_(`error.401-unauthorized`));
break;
case 403:
this.displayErrorNotification(_(`error.403-forbidden`));
this.authService.logOut();
this.router.navigate(['/login'], {
queryParams: {
[AUTH_REDIRECT_PARAM]: btoa(this.router.url),
},
});
break;
default:
const message = graqhQLErrors.map(err => err.message.error).join('\n');
this.displayErrorNotification(message);
}
}
}
}

Expand All @@ -59,8 +92,8 @@ export class DefaultInterceptor implements HttpInterceptor {
* eventually depends on the HttpClient (used to load messages from json files). If we were to
* directly inject NotificationService into the constructor, we get a cyclic dependency.
*/
private displayErrorNotification(message: string): void {
const notificationService = this.injector.get(NotificationService);
notificationService.error(message);
private displayErrorNotification(message: string, vars?: Record<string, any>): void {
const notificationService = this.injector.get<NotificationService>(NotificationService);
notificationService.error(message, vars);
}
}
11 changes: 8 additions & 3 deletions admin-ui/src/app/data/server-config.ts
Expand Up @@ -30,9 +30,14 @@ export class ServerConfigService {
baseDataService
.query<GetServerConfig>(GET_SERVER_CONFIG)
.single$.toPromise()
.then(result => {
this._serverConfig = result.config;
});
.then(
result => {
this._serverConfig = result.config;
},
err => {
// Let the error fall through to be caught by the http interceptor.
},
);
}

get serverConfig(): ServerConfig {
Expand Down
8 changes: 4 additions & 4 deletions admin-ui/src/app/login/components/login/login.component.html
Expand Up @@ -22,10 +22,10 @@
{{ 'common.remember-me' | translate }}
</label>
</div>
<div class="error active" *ngIf="lastError">
{{ lastError }}
</div>
<button type="submit" class="btn btn-primary" (click)="logIn()">{{ 'common.login' | translate }}</button>
<button type="submit"
class="btn btn-primary"
(click)="logIn()"
[disabled]="!username || !password">{{ 'common.login' | translate }}</button>
</div>
</form>
</div>
40 changes: 25 additions & 15 deletions admin-ui/src/app/login/components/login/login.component.ts
@@ -1,9 +1,8 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component } from '@angular/core';
import { Router } from '@angular/router';

import { API_URL } from '../../../app.config';
import { AuthService } from '../../../core/providers/auth/auth.service';
import { AUTH_REDIRECT_PARAM } from '../../../data/providers/interceptor';

@Component({
selector: 'vdr-login',
Expand All @@ -13,25 +12,36 @@ import { AuthService } from '../../../core/providers/auth/auth.service';
export class LoginComponent {
username = '';
password = '';
lastError = '';

constructor(private authService: AuthService, private router: Router) {}

logIn(): void {
this.authService.logIn(this.username, this.password).subscribe(
() => this.router.navigate(['/']),
(err: HttpErrorResponse) => {
switch (err.status) {
case 401:
this.lastError = 'Invalid username or password';
break;
case 0:
this.lastError = `Could not connect to the Vendure server at ${API_URL}`;
break;
default:
this.lastError = err.message;
}
() => {
const redirect = this.getRedirectRoute();
this.router.navigate([redirect ? redirect : '/']);
},
err => {
/* error handled by http interceptor */
},
);
}

/**
* Attemps to read a redirect param from the current url and parse it into a
* route from which the user was redirected after a 401 error.
*/
private getRedirectRoute(): string | undefined {
let redirectTo: string | undefined;
const re = new RegExp(`${AUTH_REDIRECT_PARAM}=(.*)`);
try {
const redirectToParam = window.location.search.match(re);
if (redirectToParam && 1 < redirectToParam.length) {
redirectTo = atob(decodeURIComponent(redirectToParam[1]));
}
} catch (e) {
// ignore
}
return redirectTo;
}
}
7 changes: 5 additions & 2 deletions admin-ui/src/i18n-messages/en.json
Expand Up @@ -91,16 +91,19 @@
"log-out": "Log out",
"login": "Log in",
"next": "Next",
"notify-create-error": "An error occurred, could not create { entity }\n\n { error }",
"notify-create-error": "An error occurred, could not create { entity }",
"notify-create-success": "Created new { entity }",
"notify-update-error": "An error occurred, could not update { entity }\n\n { error }",
"notify-update-error": "An error occurred, could not update { entity }",
"notify-update-success": "Updated { entity }",
"password": "Password",
"remember-me": "Remember me",
"update": "Update",
"username": "Username"
},
"error": {
"401-unauthorized": "Invalid login. Please try again",
"403-forbidden": "Your session has expired. Please log in",
"could-not-connect-to-server": "Could not connect to the Vendure server at { url }",
"facet-value-form-values-do-not-match": "The number of values in the facet form does not match the actual number of values",
"product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants"
},
Expand Down

0 comments on commit 9a58320

Please sign in to comment.