From e0230b26ae9d2b27e19730147e9d627c96075ea6 Mon Sep 17 00:00:00 2001 From: rubikscraft Date: Thu, 3 Mar 2022 13:38:39 +0100 Subject: [PATCH] add api services to frontend --- .../src/routes/api/auth/auth.controller.ts | 2 +- frontend/package.json | 1 + frontend/src/app/api/api.module.ts | 4 +- frontend/src/app/api/api.service.ts | 68 +++++----- frontend/src/app/api/key.service.ts | 36 ++++++ frontend/src/app/api/user.service.ts | 116 ++++++++++++++++++ .../components/header/header.component.html | 2 +- .../app/components/header/header.component.ts | 9 +- .../app/components/header/header.module.ts | 8 +- frontend/src/app/models/login.ts | 4 + frontend/src/app/router/router.module.ts | 18 ++- .../src/app/routes/login/login.component.html | 44 +++++++ .../src/app/routes/login/login.component.scss | 0 .../src/app/routes/login/login.component.ts | 47 +++++++ frontend/src/app/routes/login/login.model.ts | 42 +++++++ .../src/app/routes/view/view.component.ts | 4 +- frontend/src/polyfills.ts | 4 +- shared/src/dto/auth.dto.ts | 2 +- shared/src/dto/imagelinks.dto.ts | 12 +- shared/src/entities/image.entity.ts | 16 +-- shared/src/entities/user.entity.ts | 12 +- yarn.lock | 5 + 22 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 frontend/src/app/api/key.service.ts create mode 100644 frontend/src/app/api/user.service.ts create mode 100644 frontend/src/app/models/login.ts create mode 100644 frontend/src/app/routes/login/login.component.html create mode 100644 frontend/src/app/routes/login/login.component.scss create mode 100644 frontend/src/app/routes/login/login.component.ts create mode 100644 frontend/src/app/routes/login/login.model.ts diff --git a/backend/src/routes/api/auth/auth.controller.ts b/backend/src/routes/api/auth/auth.controller.ts index 1bd7f4b..561a151 100644 --- a/backend/src/routes/api/auth/auth.controller.ts +++ b/backend/src/routes/api/auth/auth.controller.ts @@ -30,7 +30,7 @@ export class AuthController { @Post('login') async login(@Request() req: AuthFasityRequest) { const response: AuthLoginResponse = { - access_token: await this.authService.createToken(req.user), + jwt_token: await this.authService.createToken(req.user), }; return response; diff --git a/frontend/package.json b/frontend/package.json index 9d7f28b..9657297 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "bootstrap": "^5.1.3", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "jwt-decode": "^3.1.2", "ngx-dropzone": "^3.1.0", "picsur-shared": "*", "rxjs": "~7.5.4", diff --git a/frontend/src/app/api/api.module.ts b/frontend/src/app/api/api.module.ts index cf0f6da..309cd51 100644 --- a/frontend/src/app/api/api.module.ts +++ b/frontend/src/app/api/api.module.ts @@ -2,9 +2,11 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ApiService } from './api.service'; import { ImageService } from './image.service'; +import { UserService } from './user.service'; +import { KeyService } from './key.service'; @NgModule({ - providers: [ApiService, ImageService], + providers: [ApiService, ImageService, UserService, KeyService], imports: [CommonModule], }) export class ApiModule {} diff --git a/frontend/src/app/api/api.service.ts b/frontend/src/app/api/api.service.ts index 64843f6..10804a4 100644 --- a/frontend/src/app/api/api.service.ts +++ b/frontend/src/app/api/api.service.ts @@ -8,12 +8,13 @@ import { import { validate } from 'class-validator'; import { ClassConstructor, plainToClass } from 'class-transformer'; import { MultiPartRequest } from '../models/multi-part-request'; +import { KeyService } from './key.service'; @Injectable({ providedIn: 'root', }) export class ApiService { - constructor() {} + constructor(private keyService: KeyService) {} public async get( type: ClassConstructor, @@ -52,16 +53,34 @@ export class ApiService { }); } - private async fetch( + private async fetchSafeJson( + type: ClassConstructor, url: RequestInfo, options: RequestInit - ): AsyncFailable { - try { - return await window.fetch(url, options); - } catch (e: any) { - console.warn(e); + ): AsyncFailable { + let result = await this.fetchJsonAs>(url, options); + if (HasFailed(result)) return result; + + if (result.success === false) return Fail(result.data.message); + + const resultClass = plainToClass< + ApiSuccessResponse, + ApiSuccessResponse + >(ApiSuccessResponse, result); + const resultErrors = await validate(resultClass); + if (resultErrors.length > 0) { + console.warn('result', resultErrors); return Fail('Something went wrong'); } + + const dataClass = plainToClass(type, result.data); + const dataErrors = await validate(dataClass); + if (dataErrors.length > 0) { + console.warn('data', dataErrors); + return Fail('Something went wrong'); + } + + return result.data; } private async fetchJsonAs( @@ -95,33 +114,24 @@ export class ApiService { } } - private async fetchSafeJson( - type: ClassConstructor, + private async fetch( url: RequestInfo, options: RequestInit - ): AsyncFailable { - let result = await this.fetchJsonAs>(url, options); - if (HasFailed(result)) return result; + ): AsyncFailable { + try { + const key = this.keyService.get(); + const isJSON = typeof options.body === 'string'; - if (result.success === false) return Fail(result.data.message); + const headers: any = options.headers || {}; + if (key !== null) + headers['Authorization'] = `Bearer ${this.keyService.get()}`; + if (isJSON) headers['Content-Type'] = 'application/json'; + options.headers = headers; - const resultClass = plainToClass< - ApiSuccessResponse, - ApiSuccessResponse - >(ApiSuccessResponse, result); - const resultErrors = await validate(resultClass); - if (resultErrors.length > 0) { - console.warn('result', resultErrors); + return await window.fetch(url, options); + } catch (e: any) { + console.warn(e); return Fail('Something went wrong'); } - - const dataClass = plainToClass(type, result.data); - const dataErrors = await validate(dataClass); - if (dataErrors.length > 0) { - console.warn('data', dataErrors); - return Fail('Something went wrong'); - } - - return result.data; } } diff --git a/frontend/src/app/api/key.service.ts b/frontend/src/app/api/key.service.ts new file mode 100644 index 0000000..b220332 --- /dev/null +++ b/frontend/src/app/api/key.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class KeyService { + private key: string | null = null; + + constructor() { + this.load(); + } + + private load() { + this.key = localStorage.getItem('apiKey'); + } + + private store() { + if (this.key) localStorage.setItem('apiKey', this.key); + else localStorage.removeItem('apiKey'); + } + + public get() { + setTimeout(this.load.bind(this), 0); + return this.key; + } + + public set(key: string) { + this.key = key; + setTimeout(this.store.bind(this), 0); + } + + public clear() { + this.key = null; + setTimeout(this.store.bind(this), 0); + } +} diff --git a/frontend/src/app/api/user.service.ts b/frontend/src/app/api/user.service.ts new file mode 100644 index 0000000..e97eef1 --- /dev/null +++ b/frontend/src/app/api/user.service.ts @@ -0,0 +1,116 @@ +import { Injectable, OnInit } from '@angular/core'; +import { EUser } from 'picsur-shared/dist/entities/user.entity'; +import { BehaviorSubject, Subject } from 'rxjs'; +import jwt_decode from 'jwt-decode'; +import { + AsyncFailable, + Fail, + Failable, + HasFailed, +} from 'picsur-shared/dist/types'; +import { plainToClass } from 'class-transformer'; +import { + AuthLoginRequest, + AuthLoginResponse, + AuthMeResponse, + JwtDataDto, +} from 'picsur-shared/dist/dto/auth.dto'; +import { validate } from 'class-validator'; +import { ApiService } from './api.service'; +import { KeyService } from './key.service'; + +@Injectable({ + providedIn: 'root', +}) +export class UserService { + public get user() { + return this.userSubject; + } + + private userSubject = new BehaviorSubject(null); + + constructor(private api: ApiService, private key: KeyService) { + this.init().catch(console.error); + } + + public async login(username: string, password: string): AsyncFailable { + const request: AuthLoginRequest = { + username, + password, + }; + + const response = await this.api.post( + AuthLoginRequest, + AuthLoginResponse, + '/api/auth/login', + request + ); + + if (HasFailed(response)) return response; + this.key.set(response.jwt_token); + + const user = await this.fetchUser(); + if (HasFailed(user)) return user; + + this.userSubject.next(user); + return user; + } + + public async logout(): AsyncFailable { + const value = this.userSubject.getValue(); + this.key.clear(); + this.userSubject.next(null); + if (value === null) { + return Fail('Not logged in'); + } else { + return value; + } + } + + private async init() { + const apikey = await this.key.get(); + if (!apikey) return; + + const user = await this.extractUser(apikey); + if (HasFailed(user)) { + console.warn(user.getReason()); + return; + } + + this.userSubject.next(user); + + const fetchedUser = await this.fetchUser(); + if (HasFailed(fetchedUser)) { + console.warn(fetchedUser.getReason()); + return; + } + + this.userSubject.next(fetchedUser); + } + + private async extractUser(token: string): AsyncFailable { + let decoded: any; + try { + decoded = jwt_decode(token); + } catch (e) { + return Fail('Invalid token'); + } + + const jwtData = plainToClass(JwtDataDto, decoded); + const errors = await validate(jwtData); + if (errors.length > 0) { + console.warn(errors); + return Fail('Invalid token data'); + } + + return jwtData.user; + } + + private async fetchUser(): AsyncFailable { + const got = await this.api.get(AuthMeResponse, '/api/auth/me'); + if (HasFailed(got)) return got; + + this.key.set(got.newJwtToken); + return got.user; + } +} diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index 9627316..bd5be6d 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -6,5 +6,5 @@ Picsur - + diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index 2c19d87..2b0d378 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -1,8 +1,15 @@ import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; @Component({ selector: 'app-header', templateUrl: './header.component.html', styleUrls: ['./header.component.scss'], }) -export class HeaderComponent {} +export class HeaderComponent { + constructor(private router: Router) {} + + doLogin() { + this.router.navigate(['/login']); + } +} diff --git a/frontend/src/app/components/header/header.module.ts b/frontend/src/app/components/header/header.module.ts index 58a0132..db09fcb 100644 --- a/frontend/src/app/components/header/header.module.ts +++ b/frontend/src/app/components/header/header.module.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { HeaderComponent } from './header.component'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; -import { AppRouterModule } from 'src/app/router/router.module'; +import { RouterModule } from '@angular/router'; @NgModule({ - imports: [CommonModule, MatToolbarModule, MatButtonModule, AppRouterModule], + imports: [CommonModule, MatToolbarModule, MatButtonModule, RouterModule], declarations: [HeaderComponent], exports: [HeaderComponent], }) -export class HeaderModule {} +export class HeaderModule { + +} diff --git a/frontend/src/app/models/login.ts b/frontend/src/app/models/login.ts new file mode 100644 index 0000000..3ac5aec --- /dev/null +++ b/frontend/src/app/models/login.ts @@ -0,0 +1,4 @@ +export interface LoginModel { + username: string; + password: string; +} diff --git a/frontend/src/app/router/router.module.ts b/frontend/src/app/router/router.module.ts index acbc5a6..6136cb5 100644 --- a/frontend/src/app/router/router.module.ts +++ b/frontend/src/app/router/router.module.ts @@ -11,6 +11,11 @@ import { ViewComponent } from '../routes/view/view.component'; import { CopyFieldModule } from '../components/copy-field/copy-field.module'; import { MatButtonModule } from '@angular/material/button'; import { UtilModule } from '../util/util.module'; +import { LoginComponent } from '../routes/login/login.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { CommonModule } from '@angular/common'; +import {MatFormFieldControl, MatFormFieldModule} from '@angular/material/form-field'; const routes: Routes = [ { path: '', component: UploadComponent }, @@ -19,21 +24,32 @@ const routes: Routes = [ component: ProcessingComponent, }, { path: 'view/:hash', component: ViewComponent }, + { path: 'login', component: LoginComponent }, { path: '**', component: PageNotFoundComponent }, ]; @NgModule({ imports: [ + CommonModule, NgxDropzoneModule, UtilModule, MatProgressSpinnerModule, MatButtonModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, PageNotFoundModule, CopyFieldModule, ApiModule, + FormsModule, RouterModule.forRoot(routes), ], - declarations: [UploadComponent, ProcessingComponent, ViewComponent], + declarations: [ + UploadComponent, + ProcessingComponent, + ViewComponent, + LoginComponent, + ], exports: [RouterModule], }) export class AppRouterModule {} diff --git a/frontend/src/app/routes/login/login.component.html b/frontend/src/app/routes/login/login.component.html new file mode 100644 index 0000000..e3fe859 --- /dev/null +++ b/frontend/src/app/routes/login/login.component.html @@ -0,0 +1,44 @@ +
+
+
+
+ + Failed to login. Please check your username and password. + +
+
+ + Username + + {{ + model.usernameError + }} + +
+
+ + Password + + {{ + model.passwordError + }} + +
+
+ +
+
+
+
diff --git a/frontend/src/app/routes/login/login.component.scss b/frontend/src/app/routes/login/login.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/routes/login/login.component.ts b/frontend/src/app/routes/login/login.component.ts new file mode 100644 index 0000000..7e48208 --- /dev/null +++ b/frontend/src/app/routes/login/login.component.ts @@ -0,0 +1,47 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { Subscription } from 'rxjs'; +import { UserService } from 'src/app/api/user.service'; +import { LoginControl } from './login.model'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginComponent implements OnInit, OnDestroy { + private userSubscription: Subscription; + + model = new LoginControl(); + loginFail = false; + + constructor(private userService: UserService, private router: Router) {} + + ngOnInit(): void { + console.log('init'); + this.userSubscription = this.userService.user.subscribe((user) => { + console.log('sub', user); + }); + } + + ngOnDestroy(): void { + this.userSubscription.unsubscribe(); + } + + async onSubmit() { + const data = this.model.getData(); + if (HasFailed(data)) { + return; + } + + const user = await this.userService.login(data.username, data.password); + if (HasFailed(user)) { + console.warn(user); + this.loginFail = true; + return; + } + + this.router.navigate(['/']); + } +} diff --git a/frontend/src/app/routes/login/login.model.ts b/frontend/src/app/routes/login/login.model.ts new file mode 100644 index 0000000..e76b090 --- /dev/null +++ b/frontend/src/app/routes/login/login.model.ts @@ -0,0 +1,42 @@ +import { FormControl, Validators } from '@angular/forms'; +import { Fail, Failable } from 'picsur-shared/dist/types'; +import { LoginModel } from '../../models/login'; + +export class LoginControl { + public username = new FormControl('', [ + Validators.required, + Validators.minLength(3), + ]); + + public password = new FormControl('', [ + Validators.required, + Validators.minLength(3), + ]); + + public get usernameError() { + return this.username.hasError('required') + ? 'Username is required' + : this.username.hasError('minlength') + ? 'Username is too short' + : ''; + } + + public get passwordError() { + return this.password.hasError('required') + ? 'Password is required' + : this.password.hasError('minlength') + ? 'Password is too short' + : ''; + } + + public getData(): Failable { + if (this.username.errors || this.password.errors) { + return Fail('Invalid username or password'); + } else { + return { + username: this.username.value, + password: this.password.value, + }; + } + } +} diff --git a/frontend/src/app/routes/view/view.component.ts b/frontend/src/app/routes/view/view.component.ts index 1c8b5a4..48e834e 100644 --- a/frontend/src/app/routes/view/view.component.ts +++ b/frontend/src/app/routes/view/view.component.ts @@ -20,8 +20,8 @@ export class ViewComponent implements OnInit { private utilService: UtilService ) {} - public imageUrl: string; - public imageLinks: ImageLinks; + public imageUrl: string = ''; + public imageLinks: ImageLinks = new ImageLinks(); async ngOnInit() { const params = this.route.snapshot.paramMap; diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts index 429bb9e..8dd9a70 100644 --- a/frontend/src/polyfills.ts +++ b/frontend/src/polyfills.ts @@ -45,8 +45,8 @@ /*************************************************************************************************** * Zone JS is required by default for Angular itself. */ -import 'zone.js'; // Included with Angular CLI. - +import 'zone.js'; // Included with Angular CLI. +import 'reflect-metadata'; /*************************************************************************************************** * APPLICATION IMPORTS diff --git a/shared/src/dto/auth.dto.ts b/shared/src/dto/auth.dto.ts index 8d28e4b..3fac9e6 100644 --- a/shared/src/dto/auth.dto.ts +++ b/shared/src/dto/auth.dto.ts @@ -25,7 +25,7 @@ export class AuthLoginRequest { export class AuthLoginResponse { @IsString() @IsDefined() - access_token: string; + jwt_token: string; } export class AuthRegisterRequest { diff --git a/shared/src/dto/imagelinks.dto.ts b/shared/src/dto/imagelinks.dto.ts index 24dbe5c..f0da668 100644 --- a/shared/src/dto/imagelinks.dto.ts +++ b/shared/src/dto/imagelinks.dto.ts @@ -1,7 +1,7 @@ -export interface ImageLinks { - source: string; - markdown: string; - html: string; - rst: string; - bbcode: string; +export class ImageLinks { + source: string = ''; + markdown: string = ''; + html: string = ''; + rst: string = ''; + bbcode: string = ''; } diff --git a/shared/src/entities/image.entity.ts b/shared/src/entities/image.entity.ts index 55c8386..155bf23 100644 --- a/shared/src/entities/image.entity.ts +++ b/shared/src/entities/image.entity.ts @@ -1,31 +1,19 @@ import { Exclude } from 'class-transformer'; -import { - IsDefined, - IsEnum, - IsHash, - IsOptional, -} from 'class-validator'; -//import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { IsDefined, IsEnum, IsHash, IsOptional } from 'class-validator'; import { SupportedMime, SupportedMimes } from '../dto/mimes.dto'; -//@Entity() export class EImage { - // @PrimaryGeneratedColumn() @IsOptional() id?: number; - // @Index() - // @Column({ unique: true }) @IsHash('sha256') hash: string; // Binary data - // @Column({ type: 'bytea', nullable: false, select: false }) @IsOptional() @Exclude() - data?: Buffer; + data?: object; - // @Column({ enum: SupportedMimes }) @IsEnum(SupportedMimes) @IsDefined() mime: SupportedMime; diff --git a/shared/src/entities/user.entity.ts b/shared/src/entities/user.entity.ts index 9743c2b..a1f65ee 100644 --- a/shared/src/entities/user.entity.ts +++ b/shared/src/entities/user.entity.ts @@ -1,30 +1,20 @@ -import { Exclude, Expose } from 'class-transformer'; +import { Exclude } from 'class-transformer'; import { IsDefined, - IsEmpty, IsNotEmpty, IsOptional, } from 'class-validator'; -// import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -// Different data for public and private - -// @Entity() export class EUser { - // @PrimaryGeneratedColumn() @IsOptional() id?: number; - // @Index() - // @Column({ unique: true }) @IsNotEmpty() username: string; - // @Column({ default: false }) @IsDefined() isAdmin: boolean; - // @Column({ select: false }) @IsOptional() @Exclude() password?: string; diff --git a/yarn.lock b/yarn.lock index 90c739f..a44ee01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4976,6 +4976,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + karma-chrome-launcher@~3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738"