Browse Source

Feature/refactor user service as observable store (#117)

* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
pull/118/head
Thomas 3 years ago
committed by GitHub
parent
commit
0d6fe4a232
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 1
      apps/client/src/app/app.component.html
  3. 42
      apps/client/src/app/app.component.ts
  4. 9
      apps/client/src/app/components/header/header.component.ts
  5. 8
      apps/client/src/app/core/auth.guard.ts
  6. 28
      apps/client/src/app/pages/about/about-page.component.ts
  7. 28
      apps/client/src/app/pages/account/account-page.component.ts
  8. 20
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  9. 15
      apps/client/src/app/pages/admin/admin-page.component.ts
  10. 15
      apps/client/src/app/pages/analysis/analysis-page.component.ts
  11. 20
      apps/client/src/app/pages/home/home-page.component.ts
  12. 26
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  13. 20
      apps/client/src/app/pages/transactions/transactions-page.component.ts
  14. 17
      apps/client/src/app/pages/zen/zen-page.component.ts
  15. 11
      apps/client/src/app/services/data.service.ts
  16. 11
      apps/client/src/app/services/token-storage.service.ts
  17. 4
      apps/client/src/app/services/user/user-store.actions.ts
  18. 5
      apps/client/src/app/services/user/user-store.state.ts
  19. 56
      apps/client/src/app/services/user/user.service.ts
  20. 1
      package.json
  21. 5
      yarn.lock

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
## 1.7.0 - 22.05.2021
### Changed

1
apps/client/src/app/app.component.html

@ -4,6 +4,7 @@
[currentRoute]="currentRoute"
[info]="info"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>

42
apps/client/src/app/app.component.ts

@ -17,6 +17,7 @@ import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({
selector: 'gf-root',
@ -30,7 +31,6 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem;
public isLoggedIn = false;
public user: User;
public version = environment.version;
@ -42,7 +42,8 @@ export class AppComponent implements OnDestroy, OnInit {
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.initializeTheme();
this.user = undefined;
@ -64,26 +65,22 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.isLoggedIn = !!this.tokenStorageService.getToken();
if (this.isLoggedIn) {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
this.cd.markForCheck();
});
} else {
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
} else if (!this.tokenStorageService.getToken()) {
// User has not been logged in
this.user = null;
}
this.cd.markForCheck();
});
}
@ -92,6 +89,13 @@ export class AppComponent implements OnDestroy, OnInit {
window.location.reload();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

9
apps/client/src/app/components/header/header.component.ts

@ -1,8 +1,10 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges
OnChanges,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
@ -26,6 +28,8 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem;
@Input() user: User;
@Output() signOut = new EventEmitter<void>();
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
@ -75,8 +79,7 @@ export class HeaderComponent implements OnChanges {
}
public onSignOut() {
this.tokenStorageService.signOut();
window.location.reload();
this.signOut.next();
}
public openLoginDialog(): void {

8
apps/client/src/app/core/auth.guard.ts

@ -11,13 +11,15 @@ import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
@ -29,8 +31,8 @@ export class AuthGuard implements CanActivate {
}
return new Promise<boolean>((resolve) => {
this.dataService
.fetchUser()
this.userService
.get()
.pipe(
catchError(() => {
if (state.url !== '/start') {

28
apps/client/src/app/pages/about/about-page.component.ts

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -27,27 +26,22 @@ export class AboutPageComponent implements OnInit {
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.cd.markForCheck();
}
});
}
public ngOnDestroy() {

28
apps/client/src/app/pages/account/account-page.component.ts

@ -1,6 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -30,7 +30,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.dataService
.fetchInfo()
@ -44,12 +44,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
);
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
@ -57,7 +56,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
);
this.cd.markForCheck();
});
}
});
}
@ -78,11 +77,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.userService.remove();
this.cd.markForCheck();
});
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
}

20
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -5,7 +5,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client';
@ -42,7 +42,7 @@ export class AccountsPageComponent implements OnInit {
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class AccountsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.createAccount
);
this.hasPermissionToDeleteAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteAccount
);
this.cd.markForCheck();
});
}
});
this.fetchAccounts();

15
apps/client/src/app/pages/admin/admin-page.component.ts

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
@ -34,7 +34,7 @@ export class AdminPageComponent implements OnInit {
private cacheService: CacheService,
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -43,13 +43,12 @@ export class AdminPageComponent implements OnInit {
public ngOnInit() {
this.fetchAdminData();
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
});
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}

15
apps/client/src/app/pages/analysis/analysis-page.component.ts

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioItem,
PortfolioPosition,
@ -44,7 +44,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -79,15 +79,14 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.cd.markForCheck();
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.cd.markForCheck();
});
}
});
}

20
apps/client/src/app/pages/home/home-page.component.ts

@ -10,7 +10,7 @@ import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioOverview,
PortfolioPerformance,
@ -66,7 +66,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -76,14 +76,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
}
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
user.permissions,
this.user.permissions,
permissions.accessFearAndGreedIndex
);
@ -99,12 +99,12 @@ export class HomePageComponent implements OnDestroy, OnInit {
}
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
}
});
}

26
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -23,27 +22,22 @@ export class PricingPageComponent implements OnInit {
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.cd.markForCheck();
}
});
}
public ngOnDestroy() {

20
apps/client/src/app/pages/transactions/transactions-page.component.ts

@ -5,7 +5,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
@ -42,7 +42,7 @@ export class TransactionsPageComponent implements OnInit {
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class TransactionsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToDeleteOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteOrder
);
this.cd.markForCheck();
});
}
});
this.fetchOrders();

17
apps/client/src/app/pages/zen/zen-page.component.ts

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -35,22 +35,21 @@ export class ZenPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
}
});
}

11
apps/client/src/app/services/data.service.ts

@ -74,13 +74,6 @@ export class DataService {
}
public fetchInfo() {
/*
if (this.info) {
// TODO: Cache info
return of(this.info);
}
*/
return this.http.get<InfoItem>('/api/info').pipe(
map((data) => {
if (
@ -154,10 +147,6 @@ export class DataService {
);
}
public fetchUser() {
return this.http.get<User>('/api/user');
}
public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
}

11
apps/client/src/app/services/token-storage.service.ts

@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
const TOKEN_KEY = 'auth-token';
@ -7,23 +6,15 @@ const TOKEN_KEY = 'auth-token';
providedIn: 'root'
})
export class TokenStorageService {
private hasTokenChangeSubject = new BehaviorSubject<void>(null);
public constructor() {}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
}
public onChangeHasToken() {
return this.hasTokenChangeSubject.asObservable();
}
public saveToken(token: string): void {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.setItem(TOKEN_KEY, token);
this.hasTokenChangeSubject.next();
}
public signOut(): void {
@ -34,7 +25,5 @@ export class TokenStorageService {
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
}
this.hasTokenChangeSubject.next();
}
}

4
apps/client/src/app/services/user/user-store.actions.ts

@ -0,0 +1,4 @@
export enum UserStoreActions {
GetUser = 'GET_USER',
RemoveUser = 'REMOVE_USER'
}

5
apps/client/src/app/services/user/user-store.state.ts

@ -0,0 +1,5 @@
import { User } from '@ghostfolio/common/interfaces';
export interface UserStoreState {
user: User;
}

56
apps/client/src/app/services/user/user.service.ts

@ -0,0 +1,56 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ObservableStore } from '@codewithdan/observable-store';
import { User } from '@ghostfolio/common/interfaces';
import { of } from 'rxjs';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { UserStoreActions } from './user-store.actions';
import { UserStoreState } from './user-store.state';
@Injectable({
providedIn: 'root'
})
export class UserService extends ObservableStore<UserStoreState> {
public constructor(private http: HttpClient) {
super({ trackStateHistory: true });
this.setState({ user: undefined }, 'INIT_STATE');
}
public get() {
const state = this.getState();
if (state?.user) {
// Get from cache
return of(state.user);
} else {
// Get from endpoint
return this.fetchUser().pipe(catchError(this.handleError));
}
}
public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser);
}
private fetchUser() {
return this.http.get<User>('/api/user').pipe(
map((user) => {
this.setState({ user }, UserStoreActions.GetUser);
return user;
}),
catchError(this.handleError)
);
}
private handleError(error: any) {
if (error.error instanceof Error) {
const errMessage = error.error.message;
return throwError(errMessage);
}
return throwError(error || 'Server error');
}
}

1
package.json

@ -55,6 +55,7 @@
"@angular/platform-browser": "11.2.4",
"@angular/platform-browser-dynamic": "11.2.4",
"@angular/router": "11.2.4",
"@codewithdan/observable-store": "2.2.11",
"@nestjs/common": "7.6.5",
"@nestjs/config": "0.6.1",
"@nestjs/core": "7.6.5",

5
yarn.lock

@ -1396,6 +1396,11 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@codewithdan/observable-store@2.2.11":
version "2.2.11"
resolved "https://registry.yarnpkg.com/@codewithdan/observable-store/-/observable-store-2.2.11.tgz#f5a168e86a2fa185a50ca40a1e838aa5e5fb007d"
integrity sha512-6CfqLJUqV0SwS4yE+9vciqxHUJ6CqIptSXXzFw80MonCDoVJvCJ/xhKfs7VZqJ4jDtEu/7ILvovFtZdLg9fiAg==
"@ctrl/tinycolor@^2.6.0":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-2.6.1.tgz#0e78cc836a1fd997a9a22fa1c26c555411882160"

Loading…
Cancel
Save