diff options
author | 杨宇千 <crupest@outlook.com> | 2019-04-11 20:02:33 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-04-11 20:02:33 +0800 |
commit | 5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95 (patch) | |
tree | 695f7fc0bd2f6d940f64739a1f1f500c36806cef | |
parent | 1c9edc5914869a3bbde20742c483182636ee4d43 (diff) | |
parent | c28941c6d86f8ea33521bba49d811bf3ff60b3d1 (diff) | |
download | timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.tar.gz timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.tar.bz2 timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.zip |
Merge pull request #17 from crupest/15-user
Remember me and log out feature.
26 files changed, 382 insertions, 169 deletions
diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json index f725a0f5..7c6d28f0 100644 --- a/Timeline/ClientApp/package.json +++ b/Timeline/ClientApp/package.json @@ -4,7 +4,7 @@ "scripts": { "ng": "ng", "start": "ng serve", - "start-dotnet": "dotnet run --project ..", + "start-dotnet": "dotnet run --project ../Timeline.csproj", "build": "ng build", "build:ssr": "ng run Timeline:server:dev", "test": "ng test", diff --git a/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts index 1743e615..40484387 100644 --- a/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts +++ b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts @@ -1,11 +1,13 @@ -import { ParamMap } from '@angular/router'; +import { ParamMap, ActivatedRouteSnapshot, ActivatedRoute } from '@angular/router'; import { Observable, BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; +import { PartialMock } from './mock'; + export interface ParamMapCreator { [name: string]: string | string[]; } -export class MockActivatedRouteSnapshot { +export class MockActivatedRouteSnapshot implements PartialMock<ActivatedRouteSnapshot> { private paramMapInternal: ParamMap; @@ -44,7 +46,7 @@ export class MockActivatedRouteSnapshot { } } -export class MockActivatedRoute { +export class MockActivatedRoute implements PartialMock<ActivatedRoute> { snapshot$ = new BehaviorSubject<MockActivatedRouteSnapshot>(new MockActivatedRouteSnapshot()); diff --git a/Timeline/ClientApp/src/app/test-utilities/mock.ts b/Timeline/ClientApp/src/app/test-utilities/mock.ts new file mode 100644 index 00000000..c3e368f0 --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/mock.ts @@ -0,0 +1,7 @@ +export type Mock<T> = { + [P in keyof T]: T[P] extends Function ? T[P] : T[P] | Mock<T[P]>; +}; + +export type PartialMock<T> = { + [P in keyof T]?: T[P] extends Function ? T[P] : T[P] | PartialMock<T[P]> | Mock<T[P]>; +}; diff --git a/Timeline/ClientApp/src/app/test-utilities/router-link.mock.ts b/Timeline/ClientApp/src/app/test-utilities/router-link.mock.ts new file mode 100644 index 00000000..7f4cde4d --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/router-link.mock.ts @@ -0,0 +1,9 @@ +import { Directive, Input } from '@angular/core'; + +@Directive({ + /* tslint:disable-next-line:directive-selector*/ + selector: '[routerLink]' +}) +export class RouterLinkStubDirective { + @Input('routerLink') linkParams: any; +} diff --git a/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts b/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts new file mode 100644 index 00000000..0ba5aa35 --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts @@ -0,0 +1,28 @@ +import { Mock } from './mock'; +import { nullIfUndefined } from '../utilities/language-untilities'; + +export function createMockStorage(): Mock<Storage> { + const map: { [key: string]: string } = {}; + return { + get length(): number { + return Object.keys(map).length; + }, + key(index: number): string | null { + const keys = Object.keys(map); + if (index >= keys.length) { return null; } + return keys[index]; + }, + clear() { + Object.keys(map).forEach(key => delete map.key); + }, + getItem(key: string): string | null { + return nullIfUndefined(map[key]); + }, + setItem(key: string, value: string) { + map[key] = value; + }, + removeItem(key: string) { + delete map[key]; + } + }; +} diff --git a/Timeline/ClientApp/src/app/user/auth.guard.spec.ts b/Timeline/ClientApp/src/app/user/auth.guard.spec.ts index 42e35bf7..6a36fea6 100644 --- a/Timeline/ClientApp/src/app/user/auth.guard.spec.ts +++ b/Timeline/ClientApp/src/app/user/auth.guard.spec.ts @@ -1,3 +1,5 @@ +import { Observable, of } from 'rxjs'; + import { AuthGuard, AuthStrategy } from './auth.guard'; import { UserInfo } from './entities'; @@ -8,10 +10,9 @@ describe('AuthGuard', () => { } authStrategy: AuthStrategy = 'all'; - onAuthFailed: () => void = () => { }; } - let mockUserService: { currentUserInfo: UserInfo | null }; + let mockUserService: { userInfo$: Observable<UserInfo | null> }; let guard: ConfiurableAuthGuard; let onAuthFialedSpy: jasmine.Spy; @@ -28,19 +29,27 @@ describe('AuthGuard', () => { return () => { guard.authStrategy = authStrategy; - mockUserService.currentUserInfo = null; - expect(guard.canActivate(<any>null, <any>null)).toBe(result.nologin); + function testWith(userInfo: UserInfo | null, r: boolean) { + mockUserService.userInfo$ = of(userInfo); - mockUserService.currentUserInfo = { username: 'user', roles: [] }; - expect(guard.canActivate(<any>null, <any>null)).toBe(result.loginWithNoRole); + const rawResult = guard.canActivate(<any>null, <any>null); + if (typeof rawResult === 'boolean') { + expect(rawResult).toBe(r); + } else if (rawResult instanceof Observable) { + rawResult.subscribe(next => expect(next).toBe(r)); + } else { + throw new Error('Unsupported return type.'); + } + } - mockUserService.currentUserInfo = { username: 'user', roles: mockRoles }; - expect(guard.canActivate(<any>null, <any>null)).toBe(result.loginWithMockRoles); + testWith(null, result.nologin); + testWith({ username: 'user', roles: [] }, result.loginWithNoRole); + testWith({ username: 'user', roles: mockRoles }, result.loginWithMockRoles); }; } beforeEach(() => { - mockUserService = { currentUserInfo: null }; + mockUserService = { userInfo$: of(null) }; guard = new ConfiurableAuthGuard(mockUserService); onAuthFialedSpy = spyOn(guard, 'onAuthFailed'); }); @@ -54,8 +63,7 @@ describe('AuthGuard', () => { it('auth failed callback should be called', () => { guard.authStrategy = 'requirelogin'; - mockUserService.currentUserInfo = null; - guard.canActivate(<any>null, <any>null); + (<Observable<boolean>>guard.canActivate(<any>null, <any>null)).subscribe(); expect(onAuthFialedSpy).toHaveBeenCalled(); }); }); diff --git a/Timeline/ClientApp/src/app/user/auth.guard.ts b/Timeline/ClientApp/src/app/user/auth.guard.ts index 561a0c53..1fc7a7c0 100644 --- a/Timeline/ClientApp/src/app/user/auth.guard.ts +++ b/Timeline/ClientApp/src/app/user/auth.guard.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Observable } from 'rxjs'; +import { take, map } from 'rxjs/operators'; import { InternalUserService } from './internal-user-service/internal-user.service'; @@ -23,26 +24,26 @@ export abstract class AuthGuard implements CanActivate { return true; } - const { currentUserInfo } = this.internalUserService; - - if (currentUserInfo === null) { - if (authStrategy === 'requirenologin') { - return true; - } - } else { - if (authStrategy === 'requirelogin') { - return true; - } else if (authStrategy instanceof Array) { - const { roles } = currentUserInfo; - if (authStrategy.every(value => roles.includes(value))) { + return this.internalUserService.userInfo$.pipe(take(1), map(userInfo => { + if (userInfo === null) { + if (authStrategy === 'requirenologin') { return true; } + } else { + if (authStrategy === 'requirelogin') { + return true; + } else if (authStrategy instanceof Array) { + const { roles } = userInfo; + if (authStrategy.every(value => roles.includes(value))) { + return true; + } + } } - } - // reach here means auth fails - this.onAuthFailed(); - return false; + // reach here means auth fails + this.onAuthFailed(); + return false; + })); } } diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts index 613a8fa6..6906ed60 100644 --- a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts @@ -1,115 +1,121 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpRequest } from '@angular/common/http'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material'; + +import { Mock } from 'src/app/test-utilities/mock'; +import { createMockStorage } from 'src/app/test-utilities/storage.mock'; +import { WINDOW } from '../window-inject-token'; import { UserInfo, UserCredentials } from '../entities'; import { createTokenUrl, validateTokenUrl, CreateTokenRequest, CreateTokenResponse, ValidateTokenRequest, ValidateTokenResponse } from './http-entities'; -import { InternalUserService, UserLoginState } from './internal-user.service'; +import { InternalUserService, SnackBarTextKey, snackBarText, TOKEN_STORAGE_KEY } from './internal-user.service'; +import { repeat } from 'src/app/utilities/language-untilities'; -describe('InternalUserService', () => { - const mockUserCredentials: UserCredentials = { - username: 'user', - password: 'user' - }; - beforeEach(() => TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: Router, useValue: null }] - })); +describe('InternalUserService', () => { + let mockLocalStorage: Mock<Storage>; + let mockSnackBar: jasmine.SpyObj<MatSnackBar>; + + beforeEach(() => { + mockLocalStorage = createMockStorage(); + mockSnackBar = jasmine.createSpyObj('MatSnackBar', ['open']); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: WINDOW, useValue: { localStorage: mockLocalStorage } }, + { provide: Router, useValue: null }, + { provide: MatSnackBar, useValue: mockSnackBar } + ] + }); + }); it('should be created', () => { const service: InternalUserService = TestBed.get(InternalUserService); expect(service).toBeTruthy(); }); - it('should be nologin at first', () => { - const service: InternalUserService = TestBed.get(InternalUserService); - expect(service.currentUserInfo).toBe(null); - service.refreshAndGetUserState().subscribe(result => { - expect(result).toBe('nologin'); - }); - }); - - it('login should work well', () => { - const service: InternalUserService = TestBed.get(InternalUserService); - - const mockUserInfo: UserInfo = { - username: 'user', - roles: ['user', 'other'] - }; - - service.tryLogin(mockUserCredentials).subscribe(result => { - expect(result).toEqual(mockUserInfo); - }); - - const httpController = TestBed.get(HttpTestingController) as HttpTestingController; + const mockUserInfo: UserInfo = { + username: 'user', + roles: ['user', 'other'] + }; - httpController.expectOne((request: HttpRequest<CreateTokenRequest>) => - request.url === createTokenUrl && request.body !== null && - request.body.username === 'user' && - request.body.password === 'user').flush(<CreateTokenResponse>{ - token: 'test-token', - userInfo: mockUserInfo + const mockToken = 'mock-token'; + + describe('validate token', () => { + const validateTokenRequestMatcher = (req: HttpRequest<ValidateTokenRequest>): boolean => + req.url === validateTokenUrl && req.body !== null && req.body.token === mockToken; + + function createTest( + expectSnackBarTextKey: SnackBarTextKey, + setStorageToken: boolean, + setHttpController?: (controller: HttpTestingController) => void + ): () => void { + return fakeAsync(() => { + if (setStorageToken) { + mockLocalStorage.setItem(TOKEN_STORAGE_KEY, mockToken); + } + TestBed.get(InternalUserService); + const controller = TestBed.get(HttpTestingController) as HttpTestingController; + if (setHttpController) { + setHttpController(controller); + } + controller.verify(); + tick(); + expect(mockSnackBar.open).toHaveBeenCalledWith(snackBarText[expectSnackBarTextKey], jasmine.anything(), jasmine.anything()); }); - - expect(service.currentUserInfo).toEqual(mockUserInfo); - - httpController.verify(); + } + + it('no login should work well', createTest('noLogin', false)); + it('already login should work well', createTest('alreadyLogin', true, + controller => controller.expectOne(validateTokenRequestMatcher).flush( + <ValidateTokenResponse>{ isValid: true, userInfo: mockUserInfo }))); + it('invalid login should work well', createTest('invalidLogin', true, + controller => controller.expectOne(validateTokenRequestMatcher).flush(<ValidateTokenResponse>{ isValid: false }))); + it('check fail should work well', createTest('checkFail', true, + controller => repeat(4, () => { + controller.expectOne(validateTokenRequestMatcher).error(new ErrorEvent('Network error', { message: 'simulated network error' })); + }))); }); - describe('validateUserLoginState', () => { - let service: InternalUserService; - let httpController: HttpTestingController; - - const mockUserInfo: UserInfo = { + describe('login should work well', () => { + const mockUserCredentials: UserCredentials = { username: 'user', - roles: ['user', 'other'] + password: 'user' }; - const mockToken = 'mock-token'; + function createTest(rememberMe: boolean) { + return () => { + const service: InternalUserService = TestBed.get(InternalUserService); - const tokenValidateRequestMatcher = (req: HttpRequest<ValidateTokenRequest>) => { - return req.url === validateTokenUrl && req.body !== null && req.body.token === mockToken; - }; + service.tryLogin({ ...mockUserCredentials, rememberMe: rememberMe }).subscribe(result => { + expect(result).toEqual(mockUserInfo); + }); - beforeEach(() => { - service = TestBed.get(InternalUserService); - httpController = TestBed.get(HttpTestingController); + const httpController = TestBed.get(HttpTestingController) as HttpTestingController; - service.tryLogin(mockUserCredentials).subscribe(); // subscribe to activate login + httpController.expectOne((request: HttpRequest<CreateTokenRequest>) => + request.url === createTokenUrl && request.body !== null && + request.body.username === mockUserCredentials.username && + request.body.password === mockUserCredentials.password).flush(<CreateTokenResponse>{ + token: mockToken, + userInfo: mockUserInfo + }); - httpController.expectOne(createTokenUrl).flush(<CreateTokenResponse>{ - token: mockToken, - userInfo: mockUserInfo - }); - }); + expect(service.currentUserInfo).toEqual(mockUserInfo); - it('success should work well', () => { - service.refreshAndGetUserState().subscribe((result: UserLoginState) => { - expect(result).toEqual(<UserLoginState>'success'); - }); - - httpController.expectOne(tokenValidateRequestMatcher).flush(<ValidateTokenResponse>{ - isValid: true, - userInfo: mockUserInfo - }); - - httpController.verify(); - }); - - it('invalid should work well', () => { - service.refreshAndGetUserState().subscribe((result: UserLoginState) => { - expect(result).toEqual(<UserLoginState>'invalidlogin'); - }); + httpController.verify(); - httpController.expectOne(tokenValidateRequestMatcher).flush(<ValidateTokenResponse>{ isValid: false }); + expect(mockLocalStorage.getItem(TOKEN_STORAGE_KEY)).toBe(rememberMe ? mockToken : null); + }; + } - httpController.verify(); - }); + it('remember me should work well', createTest(true)); + it('not remember me should work well', createTest(false)); }); // TODO: test on error situations. diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts index 4767bd16..6de355f2 100644 --- a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts @@ -1,11 +1,9 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; -import { Observable, of, throwError, BehaviorSubject } from 'rxjs'; -import { map, catchError, retry } from 'rxjs/operators'; - -import { nullIfUndefined } from '../../utilities/language-untilities'; +import { Observable, throwError, BehaviorSubject, of } from 'rxjs'; +import { map, catchError, retry, switchMap, tap, filter } from 'rxjs/operators'; import { AlreadyLoginError, BadCredentialsError, BadNetworkError, UnknownError } from './errors'; import { @@ -13,9 +11,24 @@ import { CreateTokenResponse, ValidateTokenRequest, ValidateTokenResponse } from './http-entities'; import { UserCredentials, UserInfo } from '../entities'; +import { MatSnackBar } from '@angular/material'; +import { WINDOW } from '../window-inject-token'; + +export const snackBarText = { + checkFail: 'Failed to check last login', + noLogin: 'No login before!', + alreadyLogin: 'You have login already!', + invalidLogin: 'Last login is no longer invalid!', + ok: 'ok' +}; +export type SnackBarTextKey = Exclude<keyof typeof snackBarText, 'ok'>; -export type UserLoginState = 'nologin' | 'invalidlogin' | 'success'; +export const TOKEN_STORAGE_KEY = 'token'; + +export interface LoginInfo extends UserCredentials { + rememberMe: boolean; +} /** * This service is only used internal in user module. @@ -26,56 +39,80 @@ export type UserLoginState = 'nologin' | 'invalidlogin' | 'success'; export class InternalUserService { private token: string | null = null; - private userInfoSubject = new BehaviorSubject<UserInfo | null>(null); + private userInfoSubject = new BehaviorSubject<UserInfo | null | undefined>(undefined); + + readonly userInfo$: Observable<UserInfo | null> = + <Observable<UserInfo | null>>this.userInfoSubject.pipe(filter(value => value !== undefined)); - get currentUserInfo(): UserInfo | null { + get currentUserInfo(): UserInfo | null | undefined { return this.userInfoSubject.value; } - get userInfo$(): Observable<UserInfo | null> { - return this.userInfoSubject; + private openSnackBar(snackBar: MatSnackBar, textKey: SnackBarTextKey) { + setTimeout(() => snackBar.open(snackBarText[textKey], snackBarText.ok, { duration: 2000 }), 0); } - constructor(private httpClient: HttpClient, private router: Router) { } - - userRouteNavigate(commands: any[] | null) { - this.router.navigate([{ - outlets: { - user: commands - } - }]); - } - - refreshAndGetUserState(): Observable<UserLoginState> { - if (this.token === undefined || this.token === null) { - return of(<UserLoginState>'nologin'); + constructor(@Inject(WINDOW) private window: Window, private httpClient: HttpClient, private router: Router, snackBar: MatSnackBar) { + const savedToken = this.window.localStorage.getItem(TOKEN_STORAGE_KEY); + if (savedToken === null) { + this.openSnackBar(snackBar, 'noLogin'); + this.userInfoSubject.next(null); + } else { + this.validateToken(savedToken).subscribe(result => { + if (result === null) { + this.window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.openSnackBar(snackBar, 'invalidLogin'); + this.userInfoSubject.next(null); + } else { + this.token = savedToken; + this.userInfoSubject.next(result); + this.openSnackBar(snackBar, 'alreadyLogin'); + } + }, _ => { + this.openSnackBar(snackBar, 'checkFail'); + this.userInfoSubject.next(null); + }); } + } - return this.httpClient.post<ValidateTokenResponse>(validateTokenUrl, <ValidateTokenRequest>{ token: this.token }).pipe( + private validateToken(token: string): Observable<UserInfo | null> { + return this.httpClient.post<ValidateTokenResponse>(validateTokenUrl, <ValidateTokenRequest>{ token: token }).pipe( retry(3), - catchError(error => { - console.error('Failed to validate token.'); - return throwError(error); - }), - map(result => { + switchMap(result => { if (result.isValid) { - this.userInfoSubject.next(nullIfUndefined(result.userInfo)); - return <UserLoginState>'success'; + const { userInfo } = result; + if (userInfo) { + return of(userInfo); + } else { + return throwError(new Error('Wrong server response. IsValid is true but UserInfo is null.')); + } } else { - this.token = null; - this.userInfoSubject.next(null); - return <UserLoginState>'invalidlogin'; + return of(null); } - }) + }), + tap({ + error: error => { + console.error('Failed to validate token.'); + console.error(error); + } + }), ); } - tryLogin(credentials: UserCredentials): Observable<UserInfo> { + userRouteNavigate(commands: any[] | null) { + this.router.navigate([{ + outlets: { + user: commands + } + }]); + } + + tryLogin(info: LoginInfo): Observable<UserInfo> { if (this.token) { return throwError(new AlreadyLoginError()); } - return this.httpClient.post<CreateTokenResponse>(createTokenUrl, <CreateTokenRequest>credentials).pipe( + return this.httpClient.post<CreateTokenResponse>(createTokenUrl, <CreateTokenRequest>info).pipe( catchError((error: HttpErrorResponse) => { if (error.error instanceof ErrorEvent) { console.error('An error occurred when login: ' + error.error.message); @@ -90,9 +127,22 @@ export class InternalUserService { }), map(result => { this.token = result.token; + if (info.rememberMe) { + this.window.localStorage.setItem(TOKEN_STORAGE_KEY, result.token); + } this.userInfoSubject.next(result.userInfo); return result.userInfo; }) ); } + + logout() { + if (this.currentUserInfo === null) { + throw new Error('No login now. You can\'t logout.'); + } + + this.window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.token = null; + this.userInfoSubject.next(null); + } } diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css index 6486142b..b1101e2a 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.css @@ -5,3 +5,18 @@ .username { color: blue; } + +:host { + display: flex; + flex-wrap: wrap; +} + +:host p { + margin-top: 0.3em; + margin-bottom: 0.3em; + width: 100%; +} + +.logout-button { + margin-left: auto; +} diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html index e156f0f8..685f6299 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html @@ -3,3 +3,4 @@ </p> <p class="mat-body">You have been login as <span class="username">{{ userInfo.username }}</span>.</p> <p class="mat-body">Your roles are <span class="roles">{{ userInfo.roles.join(', ') }}</span>.</p> +<a mat-flat-button class="logout-button" [routerLink]="['..','logout']">Logout</a> diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts index ff253add..3eba2696 100644 --- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts @@ -2,7 +2,8 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; -import { MockActivatedRoute } from 'src/app/test-utilities/activated-route.mock'; +import { RouterLinkStubDirective } from '../../test-utilities/router-link.mock'; +import { MockActivatedRoute } from '../../test-utilities/activated-route.mock'; import { createMockInternalUserService } from '../internal-user-service/internal-user.service.mock'; import { UserLoginSuccessComponent } from './user-login-success.component'; @@ -29,7 +30,7 @@ describe('UserLoginSuccessComponent', () => { (<any>mockInternalUserService).currentUserInfo = mockUserInfo; TestBed.configureTestingModule({ - declarations: [UserLoginSuccessComponent], + declarations: [UserLoginSuccessComponent, RouterLinkStubDirective], providers: [ { provide: InternalUserService, useValue: mockInternalUserService }, { provide: ActivatedRoute, useValue: mockActivatedRoute } @@ -64,4 +65,11 @@ describe('UserLoginSuccessComponent', () => { fixture.detectChanges(); expect((fixture.debugElement.query(By.css('p.login-success-message')))).toBeTruthy(); }); + + it('logout button should be set well', () => { + fixture.detectChanges(); + const routerLinkDirective: RouterLinkStubDirective = + fixture.debugElement.query(By.css('a')).injector.get(RouterLinkStubDirective); + expect(routerLinkDirective.linkParams).toEqual(['..', 'logout']); + }); }); diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.html b/Timeline/ClientApp/src/app/user/user-login/user-login.component.html index b1dd289d..7398ece7 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.html +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.html @@ -1,8 +1,8 @@ <form [formGroup]="form"> <ng-container *ngIf="message" [ngSwitch]="message"> - <p *ngSwitchCase="'nologin'" class="mat-body no-login-message">You haven't login.</p> - <p *ngSwitchCase="'invalidlogin'" class="mat-body invalid-login-message">Your login is no longer valid.</p> - <p *ngSwitchDefault class="mat-body error-message">{{ message }}</p> + <p *ngSwitchCase="'nologin'" class="mat-h3 no-login-message">You haven't login.</p> + <p *ngSwitchCase="'invalidlogin'" class="mat-h3 invalid-login-message">Your login is no longer valid.</p> + <p *ngSwitchDefault class="mat-h3 error-message">{{ message }}</p> </ng-container> <mat-form-field> <mat-label>Username</mat-label> @@ -13,6 +13,7 @@ <mat-label>Password</mat-label> <input formControlName="password" matInput type="password" /> </mat-form-field> + <mat-checkbox formControlName="rememberMe">Remember me!</mat-checkbox> <div class="w-100"></div> <button mat-flat-button class="login-button" (appDebounceClick)="onLoginButtonClick()">Login</button> </form> diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts index 693d5b6e..f010e4b7 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts @@ -2,7 +2,6 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; -import { ActivatedRoute } from '@angular/router'; import { of, throwError } from 'rxjs'; @@ -10,6 +9,7 @@ import { createMockInternalUserService } from '../internal-user-service/internal import { UserLoginComponent } from './user-login.component'; import { InternalUserService } from '../internal-user-service/internal-user.service'; import { UserInfo } from '../entities'; +import { MatCheckboxModule } from '@angular/material'; describe('UserLoginComponent', () => { let component: UserLoginComponent; @@ -27,7 +27,7 @@ describe('UserLoginComponent', () => { providers: [ { provide: InternalUserService, useValue: mockInternalUserService } ], - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, MatCheckboxModule], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); @@ -48,17 +48,20 @@ describe('UserLoginComponent', () => { const usernameInput = fixture.debugElement.query(By.css('input[type=text]')).nativeElement as HTMLInputElement; const passwordInput = fixture.debugElement.query(By.css('input[type=password]')).nativeElement as HTMLInputElement; + const rememberMeCheckbox = fixture.debugElement.query(By.css('input[type=checkbox]')).nativeElement as HTMLInputElement; usernameInput.value = 'user'; usernameInput.dispatchEvent(new Event('input')); passwordInput.value = 'user'; passwordInput.dispatchEvent(new Event('input')); + rememberMeCheckbox.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); expect(component.form.value).toEqual({ username: 'user', - password: 'user' + password: 'user', + rememberMe: true }); }); @@ -67,7 +70,8 @@ describe('UserLoginComponent', () => { const mockValue = { username: 'user', - password: 'user' + password: 'user', + rememberMe: true }; mockInternalUserService.tryLogin.withArgs(mockValue).and.returnValue(of(<UserInfo>{ username: 'user', roles: ['user'] })); @@ -84,7 +88,7 @@ describe('UserLoginComponent', () => { fixture.detectChanges(); component.message = 'nologin'; fixture.detectChanges(); - expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + expect((fixture.debugElement.query(By.css('p')).nativeElement as HTMLParagraphElement).textContent).toBe('You haven\'t login.'); }); @@ -92,7 +96,7 @@ describe('UserLoginComponent', () => { fixture.detectChanges(); component.message = 'invalidlogin'; fixture.detectChanges(); - expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + expect((fixture.debugElement.query(By.css('p')).nativeElement as HTMLParagraphElement).textContent).toBe('Your login is no longer valid.'); }); @@ -103,7 +107,8 @@ describe('UserLoginComponent', () => { const mockValue = { username: 'user', - password: 'user' + password: 'user', + rememberMe: false }; mockInternalUserService.tryLogin.withArgs(mockValue).and.returnValue(throwError(new Error(customMessage))); component.form.setValue(mockValue); @@ -111,7 +116,7 @@ describe('UserLoginComponent', () => { fixture.detectChanges(); expect(component.message).toBe(customMessage); - expect((fixture.debugElement.query(By.css('p.mat-body')).nativeElement as + expect((fixture.debugElement.query(By.css('p')).nativeElement as HTMLParagraphElement).textContent).toBe(customMessage); }); }); diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts index 836202de..4395c5cf 100644 --- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts +++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.ts @@ -19,7 +19,8 @@ export class UserLoginComponent implements OnInit { form = new FormGroup({ username: new FormControl(''), - password: new FormControl('') + password: new FormControl(''), + rememberMe: new FormControl(false) }); ngOnInit() { diff --git a/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.css b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.css diff --git a/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.html b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.html new file mode 100644 index 00000000..309e5c83 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.html @@ -0,0 +1 @@ +<p class="mat-body">Logout successfully!</p> diff --git a/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.spec.ts b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.spec.ts new file mode 100644 index 00000000..855ea4a1 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserLogoutComponent } from './user-logout.component'; +import { InternalUserService } from '../internal-user-service/internal-user.service'; + +describe('UserLogoutComponent', () => { + let component: UserLogoutComponent; + let fixture: ComponentFixture<UserLogoutComponent>; + + let mockInternalUserService: jasmine.SpyObj<InternalUserService>; + + beforeEach(async(() => { + mockInternalUserService = jasmine.createSpyObj('InternalUserService', ['logout']); + + TestBed.configureTestingModule({ + declarations: [UserLogoutComponent], + providers: [{ provide: InternalUserService, useValue: mockInternalUserService }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserLogoutComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should logout on init', () => { + fixture.detectChanges(); + expect(mockInternalUserService.logout).toHaveBeenCalled(); + }); +}); diff --git a/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.ts b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.ts new file mode 100644 index 00000000..e004196f --- /dev/null +++ b/Timeline/ClientApp/src/app/user/user-logout/user-logout.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit } from '@angular/core'; + +import { InternalUserService } from '../internal-user-service/internal-user.service'; + +@Component({ + selector: 'app-user-logout', + templateUrl: './user-logout.component.html', + styleUrls: ['./user-logout.component.css'] +}) +export class UserLogoutComponent implements OnInit { + constructor(private userService: InternalUserService) { } + + ngOnInit() { + this.userService.logout(); + } +} diff --git a/Timeline/ClientApp/src/app/user/user.module.ts b/Timeline/ClientApp/src/app/user/user.module.ts index 8f3b9a9c..59193380 100644 --- a/Timeline/ClientApp/src/app/user/user.module.ts +++ b/Timeline/ClientApp/src/app/user/user.module.ts @@ -6,7 +6,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; import { MatFormFieldModule, MatProgressSpinnerModule, - MatDialogModule, MatInputModule, MatButtonModule + MatDialogModule, MatInputModule, MatButtonModule, MatSnackBarModule, MatCheckboxModule } from '@angular/material'; import { RequireNoLoginGuard, RequireLoginGuard } from './auth.guard'; @@ -15,19 +15,23 @@ import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginSuccessComponent } from './user-login-success/user-login-success.component'; import { RedirectComponent } from './redirect.component'; import { UtilityModule } from '../utilities/utility.module'; +import { WINDOW } from './window-inject-token'; +import { UserLogoutComponent } from './user-logout/user-logout.component'; @NgModule({ - declarations: [UserDialogComponent, UserLoginComponent, UserLoginSuccessComponent, RedirectComponent], + declarations: [UserDialogComponent, UserLoginComponent, UserLoginSuccessComponent, RedirectComponent, UserLogoutComponent], imports: [ RouterModule.forChild([ { path: 'login', canActivate: [RequireNoLoginGuard], component: UserLoginComponent, outlet: 'user' }, { path: 'success', canActivate: [RequireLoginGuard], component: UserLoginSuccessComponent, outlet: 'user' }, + { path: 'logout', canActivate: [RequireLoginGuard], component: UserLogoutComponent, outlet: 'user' }, { path: '**', component: RedirectComponent, outlet: 'user' } ]), CommonModule, HttpClientModule, ReactiveFormsModule, BrowserAnimationsModule, - MatFormFieldModule, MatProgressSpinnerModule, MatDialogModule, MatInputModule, MatButtonModule, + MatFormFieldModule, MatProgressSpinnerModule, MatDialogModule, MatInputModule, MatButtonModule, MatCheckboxModule, MatSnackBarModule, UtilityModule ], + providers: [{ provide: WINDOW, useValue: window }], exports: [RouterModule], entryComponents: [UserDialogComponent] }) diff --git a/Timeline/ClientApp/src/app/user/user.service.ts b/Timeline/ClientApp/src/app/user/user.service.ts index e7d50dd2..6cae2d31 100644 --- a/Timeline/ClientApp/src/app/user/user.service.ts +++ b/Timeline/ClientApp/src/app/user/user.service.ts @@ -29,7 +29,7 @@ export class UserService { }); } - get currentUserInfo(): UserInfo | null { + get currentUserInfo(): UserInfo | null | undefined { return this.internalService.currentUserInfo; } diff --git a/Timeline/ClientApp/src/app/user/window-inject-token.ts b/Timeline/ClientApp/src/app/user/window-inject-token.ts new file mode 100644 index 00000000..9f8723f6 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/window-inject-token.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const WINDOW = new InjectionToken<Window>('global window'); diff --git a/Timeline/ClientApp/src/app/utilities/language-untilities.ts b/Timeline/ClientApp/src/app/utilities/language-untilities.ts index be9df2dc..94434665 100644 --- a/Timeline/ClientApp/src/app/utilities/language-untilities.ts +++ b/Timeline/ClientApp/src/app/utilities/language-untilities.ts @@ -3,10 +3,16 @@ export function nullIfUndefined<T>(value: T | undefined): T | null { } export function throwIfNullOrUndefined<T>(value: T | null | undefined, - lazyMessage: () => string = () => 'Value mustn\'t be falsy'): T | never { + message: string | (() => string) = 'Value mustn\'t be null or undefined'): T | never { if (value === null || value === undefined) { - throw new Error(lazyMessage()); + throw new Error(typeof message === 'string' ? message : message()); } else { return value; } } + +export function repeat(time: number, action: (index?: number) => void) { + for (let i = 0; i < time; i++) { + action(i); + } +} diff --git a/Timeline/ClientApp/src/tsconfig.app.json b/Timeline/ClientApp/src/tsconfig.app.json index 13151ca4..0d3b876e 100644 --- a/Timeline/ClientApp/src/tsconfig.app.json +++ b/Timeline/ClientApp/src/tsconfig.app.json @@ -7,6 +7,7 @@ }, "exclude": [ "src/test.ts", + "test-utilities/**/*", "**/*.spec.ts", "**/*.mock.ts", "**/*.test.ts" diff --git a/Timeline/ClientApp/src/tsconfig.spec.json b/Timeline/ClientApp/src/tsconfig.spec.json index 6e4460f8..3bcc8926 100644 --- a/Timeline/ClientApp/src/tsconfig.spec.json +++ b/Timeline/ClientApp/src/tsconfig.spec.json @@ -13,6 +13,7 @@ "polyfills.ts" ], "include": [ + "test-utilities/**/*", "**/*.spec.ts", "**/*.d.ts", "**/*.mock.ts", diff --git a/Timeline/ClientApp/tsconfig.json b/Timeline/ClientApp/tsconfig.json index 437067d6..86c42495 100644 --- a/Timeline/ClientApp/tsconfig.json +++ b/Timeline/ClientApp/tsconfig.json @@ -17,5 +17,9 @@ "dom" ], "strict": true + }, + "angularCompilerOptions": { + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true } } |