Initial implementation of login and registration with fake backend for future testing

Also introduced flex-layout
This commit is contained in:
2020-07-21 15:34:52 +02:00
parent d90204d34c
commit c82d784866
31 changed files with 724 additions and 230 deletions

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AccountService } from './account.service';
describe('AccountService', () => {
let service: AccountService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AccountService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { User } from './user';
@Injectable({
providedIn: 'root'
})
export class AccountService {
private userSubject: BehaviorSubject<User>;
public user: Observable<User>;
constructor(private httpClient: HttpClient) {
this.userSubject = new BehaviorSubject<User>(JSON.parse(localStorage.getItem('user')));
this.user = this.userSubject.asObservable();
}
public get userValue() {
return this.userSubject.value;
}
login(username, password) {
return this.httpClient.post<User>(environment.apiUrl + '/fake_login', { username, password })
.pipe((map(user => {
localStorage.setItem('user', JSON.stringify(user));
this.userSubject.next(user);
return user;
})))
}
register(user) {
return this.httpClient.post<User>(environment.apiUrl + '/fake_registration', user);
}
}

View File

@@ -0,0 +1,7 @@
import { AuthGuard } from './auth.guard';
describe('Auth.Guard', () => {
it('should create an instance', () => {
expect(new AuthGuard()).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AccountService } from './account.service';
@Injectable({providedIn: 'root'})
export class AuthGuard implements CanActivate {
constructor(private router: Router,
private accountService: AccountService) { }
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
const user = this.accountService.userValue;
if (user) {
return true;
}
this.router.navigate(['/login'], {queryParams: {returnURL: state.url}});
return false;
}
}

View File

@@ -0,0 +1,22 @@
.login-card {
width: 400px;
}
.login-container {
width: 100%;
height: 100%;
background-color: #333333;
}
.login-form {
padding: 10px 40px;
}
.mat-button {
width: 80px;
}
.mat-form-field {
width: 100%;
padding-top: 15px;
}

View File

@@ -0,0 +1,30 @@
<div class="login-container" fxLayout="row" fxLayoutAlign="center center">
<mat-card class="login-card" fxLayout="column" fxLayoutAlign="center center">
<mat-card-title>Login</mat-card-title>
<mat-card-content>
<form class="login-form" [formGroup]="form" (ngSubmit)="onLogin()">
<mat-form-field appearance="fill">
<mat-label>User name</mat-label>
<label>
<input matInput formControlName="username" required>
</label>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Password</mat-label>
<label>
<input matInput formControlName="password" type="password" required minlength="15">
</label>
</mat-form-field>
<div fxLayout="row" fxLayoutAlign="space-evenly center">
<button mat-flat-button color="primary" [disabled]="loading">
<span *ngIf="loading" class="spinner-border spinner-border-sm mr-1"></span>
Login
</button>
<a mat-button routerLink="/register">Sign up</a>
</div>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LoginComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { AccountService } from '../account.service';
import { ActivatedRoute, Router } from '@angular/router';
import { first } from 'rxjs/operators';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
form: FormGroup = new FormGroup({
username: new FormControl(''),
password: new FormControl(''),
});
loading = false;
returnUrl = this.activatedRoute.snapshot.queryParams['returnUrl'] || '/';
onLogin() {
this.loading = true;
this.accountService.login(this.form.controls['username'], this.form.controls['password'])
.pipe(first())
.subscribe(data => {
this.router.navigate([this.returnUrl]);
},
error => {
// TODO error handling
console.log(error);
this.loading = false;
});
}
constructor(private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private router: Router,
) { }
ngOnInit(): void {
}
}

View File

@@ -0,0 +1,7 @@
import { PasswordErrorStateMatcher } from './password-error-state-matcher';
describe('PasswordErrorStateMatcher', () => {
it('should create an instance', () => {
expect(new PasswordErrorStateMatcher()).toBeTruthy();
});
});

View File

@@ -0,0 +1,11 @@
import { ErrorStateMatcher } from '@angular/material/core';
import { FormControl, FormGroupDirective, NgForm } from '@angular/forms';
export class PasswordErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const invalidControl = !!(control && control.invalid && control.parent.touched);
const invalidParent = !!(control && control.parent && control.parent.invalid && control.parent.touched);
return invalidControl || invalidParent;
}
}

View File

@@ -0,0 +1,22 @@
.register-card {
width: 400px;
}
.register-container {
width: 100%;
height: 100%;
background-color: #333333;
}
.register-form {
padding: 10px 40px;
}
.mat-button {
width: 80px;
}
.mat-form-field {
width: 100%;
padding-top: 15px;
}

View File

@@ -0,0 +1,48 @@
<div class="register-container" fxLayout="row" fxLayoutAlign="center center">
<mat-card class="register-card" fxLayout="column" fxLayoutAlign="center center">
<mat-card-title>Sign up</mat-card-title>
<mat-card-content>
<form class="register-form" [formGroup]="form" (ngSubmit)="onRegister()">
<mat-form-field appearance="fill">
<mat-label>User name</mat-label>
<label>
<input matInput formControlName="username" required>
</label>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Email</mat-label>
<label>
<input matInput formControlName="email" required email>
</label>
</mat-form-field>
<div formGroupName="password">
<mat-form-field appearance="fill" hintLabel="At least 15 characters.">
<mat-label>Password</mat-label>
<label>
<input matInput formControlName="first" type="password" required minlength="15">
</label>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Confirm password</mat-label>
<label>
<input matInput formControlName="second" type="password" required minlength="15"
[errorStateMatcher]="passwordErrorStateMatcher">
</label>
<mat-error *ngIf="form.get('password').hasError('mismatch')">Passwords don't match.</mat-error>
</mat-form-field>
</div>
<div fxLayout="row" fxLayoutAlign="space-evenly center">
<button mat-flat-button color="primary" [disabled]="loading">
<span *ngIf="loading" class="spinner-border spinner-border-sm mr-1"></span>
Sign up
</button>
<a mat-button routerLink="/login">Login</a>
</div>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterComponent } from './register.component';
describe('RegisterComponent', () => {
let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ RegisterComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RegisterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,65 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { first } from 'rxjs/operators';
import { AccountService } from '../account.service';
import { PasswordErrorStateMatcher } from './password-error-state-matcher';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.css']
})
export class RegisterComponent implements OnInit {
form = this.formBuilder.group({
username: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: this.formBuilder.group({
first: ['', [Validators.required, Validators.minLength(15)]],
second: ['', [Validators.required, Validators.minLength(15)]],
},
{ validators: this.passwordsEqual }),
});
loading = false;
passwordErrorStateMatcher = new PasswordErrorStateMatcher();
constructor(private accountService: AccountService,
private formBuilder: FormBuilder,
private router: Router,
) { }
passwordsEqual(passwords: FormGroup): ValidationErrors | null {
const password1 = passwords.get('first');
const password2 = passwords.get('second');
return password1 && password2 && password1.value === password2.value ? null : { mismatch: true };
}
readForm() {
return {
username: this.form.get('username').value,
email: this.form.get('email').value,
password: this.form.get('password').get('first').value,
}
}
onRegister() {
if (this.form.invalid) {
return;
}
this.loading = true;
this.accountService.register(this.readForm())
.pipe(first())
.subscribe(data => {
this.router.navigate(['/login']);
},
error => {
// TODO error handling
console.log(error);
this.loading = false;
});
}
ngOnInit(): void { }
}

View File

@@ -0,0 +1,7 @@
import { User } from './user';
describe('User', () => {
it('should create an instance', () => {
expect(new User()).toBeTruthy();
});
});

6
src/app/account/user.ts Normal file
View File

@@ -0,0 +1,6 @@
export class User {
id: number;
username: string;
email: string;
token: string;
}

View File

@@ -1,8 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { AuthGuard } from './account/auth.guard';
import { LoginComponent } from './account/login/login.component';
import { RegisterComponent } from './account/register/register.component';
const routes: Routes = [];
const routes: Routes = [
{ path: '', component: AppComponent, canActivate: [AuthGuard] },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: '**', redirectTo: '' },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],

View File

@@ -16,69 +16,6 @@ svg.material-icons:not(:last-child) {
margin-right: 8px;
}
.card svg.material-icons path {
fill: #888;
}
.card-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* margin-top: 16px; */
}
.card {
border-radius: 4px;
border: 1px solid #eee;
background-color: #fafafa;
height: 40px;
width: 200px;
margin: 0 8px 16px;
padding: 16px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
line-height: 24px;
}
.card-container .card:not(:last-child) {
margin-right: 0;
}
.card.card-small {
height: 16px;
width: 168px;
}
.card-container .card:not(.highlight-card) {
cursor: pointer;
}
.card-container .card:not(.highlight-card):hover {
transform: translateY(-3px);
box-shadow: 0 4px 17px rgba(0, 0, 0, 0.35);
}
.card-container .card:not(.highlight-card):hover .material-icons path {
fill: rgb(105, 103, 103);
}
.card.highlight-card {
background-color: #1976d2;
color: white;
font-weight: 600;
border: none;
width: auto;
min-width: 30%;
position: relative;
}
.card.card.highlight-card span {
margin-left: 60px;
}
/* Responsive Styles */
@media screen and (max-width: 767px) {
@@ -93,6 +30,10 @@ svg.material-icons:not(:last-child) {
}
.router {
height: 100%;
}
.chat {
float: right;
height: 100%;

View File

@@ -1,20 +1,3 @@
<div class="chat">
<app-chat></app-chat>
<div class="router">
<router-outlet></router-outlet>
</div>
<div>
<div class="card-container">
<div class="card card-small" (click)="onClickSocket()" tabindex="0">
<svg class="material-icons" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<span>Send test message</span>
</div>
<div class="card card-small" (click)="onClickApi()" tabindex="0">
<svg class="material-icons" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<span>Call rest api</span>
</div>
</div>
</div>
<router-outlet></router-outlet>

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { SocketService } from './socket/socket.service';
import { HttpClient } from '@angular/common/http';
import { AccountService } from './account/account.service';
@Component({
selector: 'app-root',
@@ -10,22 +10,14 @@ import { HttpClient } from '@angular/common/http';
export class AppComponent {
title = 'rona-frontend';
onClickSocket() {
this.socketService.send('test', {'user': 'USERNAME', 'payload': 'PAYLOAD TEST'})
}
onClickApi() {
this.httpService.get('http://localhost:5005/').subscribe(response => {
console.log('REST API call returned: ', response);
});
}
/**
*
* @param accountService
* @param socketService
* @param httpService
*/
constructor(private socketService: SocketService,
private httpService: HttpClient) { }
constructor(private accountService: AccountService,
private socketService: SocketService,
) { }
}

View File

@@ -1,32 +1,50 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FlexLayoutModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ChatComponent } from './chat/chat.component';
import { EntryComponent } from './chat/entry/entry.component';
import { InputComponent } from './chat/input/input.component';
import {MatCardModule} from '@angular/material/card';
import {MatInputModule} from '@angular/material/input';
import { LoginComponent } from './account/login/login.component';
import { TestComponent } from './test/test.component';
import { RegisterComponent } from './account/register/register.component';
import { fakeBackendProvider } from './utils/fake-backend';
@NgModule({
declarations: [
AppComponent,
ChatComponent,
EntryComponent,
InputComponent
InputComponent,
LoginComponent,
TestComponent,
RegisterComponent
],
imports: [
HttpClientModule,
BrowserModule,
BrowserAnimationsModule,
FlexLayoutModule,
MatButtonModule,
MatCardModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
AppRoutingModule,
],
providers: [
fakeBackendProvider,
],
imports: [
BrowserModule,
HttpClientModule,
AppRoutingModule,
BrowserAnimationsModule,
MatCardModule,
MatInputModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

View File

@@ -0,0 +1,15 @@
<div>
<div class="card-container">
<div class="card card-small" (click)="onClickSocket()" tabindex="0">
<svg class="material-icons" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<span>Send test message</span>
</div>
<div class="card card-small" (click)="onClickApi()" tabindex="0">
<svg class="material-icons" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<span>Call rest api</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TestComponent } from './test.component';
describe('TestComponent', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ TestComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,29 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {SocketService} from '../socket/socket.service';
@Component({
selector: 'app-test',
templateUrl: './test.component.html',
styleUrls: ['./test.component.css']
})
export class TestComponent implements OnInit {
onClickSocket() {
this.socketService.send('test', {'user': 'USERNAME', 'payload': 'PAYLOAD TEST'})
}
onClickApi() {
this.httpService.get('http://localhost:5005/').subscribe(response => {
console.log('REST API call returned: ', response);
});
}
constructor(private httpService: HttpClient,
private socketService: SocketService,
) { }
ngOnInit(): void {
}
}

View File

@@ -0,0 +1,7 @@
import { FakeBackend } from './fake-backend';
describe('FakeBackend', () => {
it('should create an instance', () => {
expect(new FakeBackend()).toBeTruthy();
});
});

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@angular/core';
import { HttpRequest, HttpResponse, HttpHandler, HttpEvent, HttpInterceptor, HTTP_INTERCEPTORS } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { delay, mergeMap, materialize, dematerialize } from 'rxjs/operators';
let users = JSON.parse(localStorage.getItem('users')) || [];
@Injectable()
export class FakeBackend implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const { url, method, headers, body } = request;
return of(null)
.pipe(mergeMap(handleRoute))
.pipe(materialize())
.pipe(delay(500))
.pipe(dematerialize());
function handleRoute() {
switch (true) {
case url.endsWith('fake_login') && method === 'POST':
return authenticate();
case url.endsWith('fake_registration') && method === 'POST':
return register();
default:
return next.handle(request);
}
}
function authenticate() {
const { username, password } = body;
const user = users.find(x => x.username === username.value && x.password === password.value);
if (!user) {
console.log(password);
return error('Username or password is incorrect.');
}
return ok({
id: user.id,
username: user.username,
token: 'fake-jwt-token',
});
}
function register() {
const user = body;
if (users.find(x => x.username === user.username)) {
return error('Username ' + user.username + ' is already taken.');
}
user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
users.push(user);
localStorage.setItem('users', JSON.stringify(users));
console.log('Register user: ' + user);
return ok();
}
function ok(body?) {
return of(new HttpResponse({ status: 200, body }));
}
function error(message) {
return throwError({ error: { message } });
}
}
}
export const fakeBackendProvider = {
provide: HTTP_INTERCEPTORS,
useClass: FakeBackend,
multi: true,
}