add api services to frontend

This commit is contained in:
rubikscraft 2022-03-03 13:38:39 +01:00
parent 0e0060ffb5
commit e0230b26ae
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
22 changed files with 383 additions and 73 deletions

View file

@ -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;

View file

@ -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",

View file

@ -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 {}

View file

@ -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<T extends Object>(
type: ClassConstructor<T>,
@ -52,16 +53,34 @@ export class ApiService {
});
}
private async fetch(
private async fetchSafeJson<T extends Object>(
type: ClassConstructor<T>,
url: RequestInfo,
options: RequestInit
): AsyncFailable<Response> {
try {
return await window.fetch(url, options);
} catch (e: any) {
console.warn(e);
): AsyncFailable<T> {
let result = await this.fetchJsonAs<ApiResponse<T>>(url, options);
if (HasFailed(result)) return result;
if (result.success === false) return Fail(result.data.message);
const resultClass = plainToClass<
ApiSuccessResponse<T>,
ApiSuccessResponse<T>
>(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<T>(
@ -95,33 +114,24 @@ export class ApiService {
}
}
private async fetchSafeJson<T extends Object>(
type: ClassConstructor<T>,
private async fetch(
url: RequestInfo,
options: RequestInit
): AsyncFailable<T> {
let result = await this.fetchJsonAs<ApiResponse<T>>(url, options);
if (HasFailed(result)) return result;
): AsyncFailable<Response> {
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<T>,
ApiSuccessResponse<T>
>(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;
}
}

View file

@ -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);
}
}

View file

@ -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<EUser | null>(null);
constructor(private api: ApiService, private key: KeyService) {
this.init().catch(console.error);
}
public async login(username: string, password: string): AsyncFailable<EUser> {
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<EUser> {
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<EUser> {
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<EUser> {
const got = await this.api.get(AuthMeResponse, '/api/auth/me');
if (HasFailed(got)) return got;
this.key.set(got.newJwtToken);
return got.user;
}
}

View file

@ -6,5 +6,5 @@
<span>Picsur</span>
</a>
<span class="spacer"></span>
<button mat-stroked-button>Login</button>
<button mat-stroked-button (click)="doLogin()">Login</button>
</mat-toolbar>

View file

@ -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']);
}
}

View file

@ -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 {
}

View file

@ -0,0 +1,4 @@
export interface LoginModel {
username: string;
password: string;
}

View file

@ -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 {}

View file

@ -0,0 +1,44 @@
<div class="content-border">
<div class="container centered">
<form class="row" (ngSubmit)="onSubmit()">
<div class="col-12 py-2" *ngIf="loginFail">
<mat-error>
Failed to login. Please check your username and password.
</mat-error>
</div>
<div class="col-12 py-2">
<mat-form-field appearance="outline" color="accent">
<mat-label>Username</mat-label>
<input
matInput
type="text"
[formControl]="model.username"
name="username"
required
/>
<mat-error *ngIf="model.username.errors">{{
model.usernameError
}}</mat-error>
</mat-form-field>
</div>
<div class="col-12 py-2">
<mat-form-field appearance="outline" color="accent">
<mat-label>Password</mat-label>
<input
matInput
type="password"
[formControl]="model.password"
name="password"
required
/>
<mat-error *ngIf="model.password.errors">{{
model.passwordError
}}</mat-error>
</mat-form-field>
</div>
<div class="col-12 py-2">
<button mat-raised-button color="accent" type="submit">Login</button>
</div>
</form>
</div>
</div>

View file

@ -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(['/']);
}
}

View file

@ -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<LoginModel> {
if (this.username.errors || this.password.errors) {
return Fail('Invalid username or password');
} else {
return {
username: this.username.value,
password: this.password.value,
};
}
}
}

View file

@ -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;

View file

@ -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

View file

@ -25,7 +25,7 @@ export class AuthLoginRequest {
export class AuthLoginResponse {
@IsString()
@IsDefined()
access_token: string;
jwt_token: string;
}
export class AuthRegisterRequest {

View file

@ -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 = '';
}

View file

@ -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;

View file

@ -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;

View file

@ -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"