add api services to frontend
This commit is contained in:
parent
0e0060ffb5
commit
e0230b26ae
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
36
frontend/src/app/api/key.service.ts
Normal file
36
frontend/src/app/api/key.service.ts
Normal 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);
|
||||
}
|
||||
}
|
116
frontend/src/app/api/user.service.ts
Normal file
116
frontend/src/app/api/user.service.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
}
|
||||
|
|
4
frontend/src/app/models/login.ts
Normal file
4
frontend/src/app/models/login.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface LoginModel {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
|
@ -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 {}
|
||||
|
|
44
frontend/src/app/routes/login/login.component.html
Normal file
44
frontend/src/app/routes/login/login.component.html
Normal 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>
|
0
frontend/src/app/routes/login/login.component.scss
Normal file
0
frontend/src/app/routes/login/login.component.scss
Normal file
47
frontend/src/app/routes/login/login.component.ts
Normal file
47
frontend/src/app/routes/login/login.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
42
frontend/src/app/routes/login/login.model.ts
Normal file
42
frontend/src/app/routes/login/login.model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
|
|
|
@ -25,7 +25,7 @@ export class AuthLoginRequest {
|
|||
export class AuthLoginResponse {
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
access_token: string;
|
||||
jwt_token: string;
|
||||
}
|
||||
|
||||
export class AuthRegisterRequest {
|
||||
|
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue