Browse Source

Refactor AuthInterceptor (#1764)

* Refactor AuthInterceptor

* Refactor JwtStrategy
pull/1758/head^2
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
261a0fb0b9
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      apps/api/src/app/account/account.controller.ts
  2. 36
      apps/api/src/app/admin/admin.service.ts
  3. 27
      apps/api/src/app/auth/jwt.strategy.ts
  4. 4
      apps/api/src/app/info/info.service.ts
  5. 3
      apps/api/src/app/order/order.controller.ts
  6. 15
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 7
      apps/api/src/app/user/create-user.dto.ts
  8. 4
      apps/api/src/app/user/user.controller.ts
  9. 8
      apps/api/src/app/user/user.service.ts
  10. 4
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  11. 2
      apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts
  12. 4
      apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts
  13. 13
      apps/client/src/app/components/admin-users/admin-users.html
  14. 28
      apps/client/src/app/core/auth.interceptor.ts
  15. 2
      apps/client/src/app/pages/register/register-page.component.ts
  16. 4
      apps/client/src/app/services/data.service.ts
  17. 15
      apps/client/src/app/services/user/user.service.ts
  18. 4
      libs/common/src/lib/config.ts
  19. 426
      libs/common/src/lib/timezone-cities-to-countries.ts
  20. 1
      package.json
  21. 5
      yarn.lock

5
apps/api/src/app/account/account.controller.ts

@ -1,6 +1,7 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -83,7 +84,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
@ -101,7 +102,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =

36
apps/api/src/app/admin/admin.service.ts

@ -231,12 +231,27 @@ export class AdminService {
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: {
let orderBy: any = {
createdAt: 'desc'
};
let where;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
updatedAt: 'desc'
}
},
};
where = {
NOT: {
Analytics: null
}
};
}
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
where,
select: {
_count: {
select: { Account: true, Order: true }
@ -252,19 +267,16 @@ export class AdminService {
id: true,
Subscription: true
},
take: 30,
where: {
NOT: {
Analytics: null
}
}
take: 30
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const engagement = Analytics
? Analytics.activityCount / daysSinceRegistration
: undefined;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
@ -278,8 +290,8 @@ export class AdminService {
id,
subscription,
accountCount: _count.Account || 0,
country: Analytics.country,
lastActivity: Analytics.updatedAt,
country: Analytics?.country,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0
};
}

27
apps/api/src/app/auth/jwt.strategy.ts

@ -1,33 +1,46 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor(
readonly configurationService: ConfigurationService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true,
secretOrKey: configurationService.get('JWT_SECRET_KEY')
});
}
public async validate({ id }: { id: string }) {
public async validate(request: Request, { id }: { id: string }) {
try {
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
const user = await this.userService.user({ id });
if (user) {
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id }
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } },
update: {
country,
activityCount: { increment: 1 },
updatedAt: new Date()
},
where: { userId: user.id }
});
}
return user;
} else {

4
apps/api/src/app/info/info.service.ts

@ -59,9 +59,7 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);

3
apps/api/src/app/order/order.controller.ts

@ -3,6 +3,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -66,7 +67,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string

15
apps/api/src/app/portfolio/portfolio.controller.ts

@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
@ -65,7 +66,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -189,7 +190,7 @@ export class PortfolioController {
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
public async getDividends(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@ -239,7 +240,7 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@ -291,7 +292,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -360,7 +361,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -451,7 +452,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
@ -474,7 +475,7 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);

7
apps/api/src/app/user/create-user.dto.ts

@ -1,7 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
country?: string;
}

4
apps/api/src/app/user/user.controller.ts

@ -22,7 +22,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { CreateUserDto } from './create-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@ -66,7 +65,7 @@ export class UserController {
}
@Post()
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
public async signupUser(): Promise<UserItem> {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
@ -80,7 +79,6 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
country: data.country,
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});

8
apps/api/src/app/user/user.service.ts

@ -18,8 +18,6 @@ import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { CreateUserDto } from './create-user.dto';
const crypto = require('crypto');
@Injectable()
@ -234,9 +232,10 @@ export class UserService {
}
public async createUser({
country,
data
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
@ -264,7 +263,6 @@ export class UserService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
country,
User: { connect: { id: user.id } }
}
});

4
apps/api/src/interceptors/redact-values-in-response.interceptor.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CallHandler,
ExecutionContext,
@ -22,7 +23,8 @@ export class RedactValuesInResponseInterceptor<T>
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
if (
hasImpersonationId ||

2
apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts

@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}

4
apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts

@ -26,9 +26,7 @@ export class TransformDataSourceInResponseInterceptor<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
data = redactAttributes({
options: [
{

13
apps/client/src/app/components/admin-users/admin-users.html

@ -28,7 +28,13 @@
>
<ng-container i18n>Engagement per Day</ng-container>
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2"
i18n
>
Last Request
</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
@ -86,7 +92,10 @@
[value]="userItem.engagement"
></gf-value>
</td>
<td class="mat-cell px-1 py-2">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
{{ formatDistanceToNow(userItem.lastActivity) }}
</td>
<td class="mat-cell px-1 py-2">

28
apps/client/src/app/core/auth.interceptor.ts

@ -5,14 +5,16 @@ import {
HttpRequest
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
HEADER_KEY_IMPERSONATION,
HEADER_KEY_TIMEZONE,
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import { Observable } from 'rxjs';
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
import { TokenStorageService } from '../services/token-storage.service';
const IMPERSONATION_KEY = 'Impersonation-Id';
const TOKEN_HEADER_KEY = 'Authorization';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
public constructor(
@ -24,21 +26,27 @@ export class AuthInterceptor implements HttpInterceptor {
req: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
let authReq = req;
let request = req;
let headers = request.headers.set(
HEADER_KEY_TIMEZONE,
Intl?.DateTimeFormat().resolvedOptions().timeZone
);
const token = this.tokenStorageService.getToken();
const impersonationId = this.impersonationStorageService.getId();
if (token !== null) {
let headers = req.headers.set(TOKEN_HEADER_KEY, `Bearer ${token}`);
headers = headers.set(HEADER_KEY_TOKEN, `Bearer ${token}`);
const impersonationId = this.impersonationStorageService.getId();
if (impersonationId !== null) {
headers = headers.set(IMPERSONATION_KEY, impersonationId);
headers = headers.set(HEADER_KEY_IMPERSONATION, impersonationId);
}
authReq = req.clone({ headers });
}
return next.handle(authReq);
request = request.clone({ headers });
return next.handle(request);
}
}

2
apps/client/src/app/pages/register/register-page.component.ts

@ -63,7 +63,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public async createAccount() {
this.dataService
.postUser({ country: this.userService.getCountry() })
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.openShowAccessTokenDialog(accessToken, authToken, role);

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

@ -405,8 +405,8 @@ export class DataService {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
}
public postUser({ country }: { country: string }) {
return this.http.post<UserItem>(`/api/v1/user`, { country });
public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {});
}
public putAccount(aAccount: UpdateAccountDto) {

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

@ -6,7 +6,6 @@ import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/compone
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { timezoneCitiesToCountries } from '@ghostfolio/common/timezone-cities-to-countries';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, of } from 'rxjs';
import { throwError } from 'rxjs';
@ -46,20 +45,6 @@ export class UserService extends ObservableStore<UserStoreState> {
}
}
public getCountry() {
let country: string;
if (Intl) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timeZoneArray = timeZone.split('/');
const city = timeZoneArray[timeZoneArray.length - 1];
country = timezoneCitiesToCountries[city];
}
return country;
}
public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser);
}

4
libs/common/src/lib/config.ts

@ -69,6 +69,10 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS: JobOptions = {
}
};
export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id';
export const HEADER_KEY_TIMEZONE = 'Timezone';
export const HEADER_KEY_TOKEN = 'Authorization';
export const MAX_CHART_ITEMS = 365;
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';

426
libs/common/src/lib/timezone-cities-to-countries.ts

@ -1,426 +0,0 @@
export const timezoneCitiesToCountries = {
Abidjan: 'CI',
Accra: 'GH',
Adak: 'US',
Addis_Ababa: 'ET',
Adelaide: 'AU',
Aden: 'YE',
Algiers: 'DZ',
Almaty: 'KZ',
Amman: 'JO',
Amsterdam: 'NL',
Anadyr: 'RU',
Anchorage: 'US',
Andorra: 'AD',
Anguilla: 'AI',
Antananarivo: 'MG',
Antigua: 'AG',
Apia: 'WS',
Aqtau: 'KZ',
Aqtobe: 'KZ',
Araguaina: 'BR',
Aruba: 'AW',
Ashgabat: 'TM',
Asmara: 'ER',
Astrakhan: 'RU',
Asuncion: 'PY',
Athens: 'GR',
Atikokan: 'CA',
Atyrau: 'KZ',
Auckland: 'NZ',
Azores: 'PT',
Baghdad: 'IQ',
Bahia: 'BR',
Bahia_Banderas: 'MX',
Bahrain: 'BH',
Baku: 'AZ',
Bamako: 'ML',
Bangkok: 'TH',
Bangui: 'CF',
Banjul: 'GM',
Barbados: 'BB',
Barnaul: 'RU',
Beirut: 'LB',
Belem: 'BR',
Belgrade: 'RS',
Belize: 'BZ',
Berlin: 'DE',
Bermuda: 'BM',
Beulah: 'US',
Bishkek: 'KG',
Bissau: 'GW',
'Blanc-Sablon': 'CA',
Blantyre: 'MW',
Boa_Vista: 'BR',
Bogota: 'CO',
Boise: 'US',
Bougainville: 'PG',
Bratislava: 'SK',
Brazzaville: 'CG',
Brisbane: 'AU',
Broken_Hill: 'AU',
Brunei: 'BN',
Brussels: 'BE',
Bucharest: 'RO',
Budapest: 'HU',
Buenos_Aires: 'AR',
Bujumbura: 'BI',
Busingen: 'DE',
Cairo: 'EG',
Cambridge_Bay: 'CA',
Campo_Grande: 'BR',
Canary: 'ES',
Cancun: 'MX',
Cape_Verde: 'CV',
Caracas: 'VE',
Casablanca: 'MA',
Casey: 'AQ',
Catamarca: 'AR',
Cayenne: 'GF',
Cayman: 'KY',
Center: 'US',
Ceuta: 'ES',
Chagos: 'IO',
Chatham: 'NZ',
Chicago: 'US',
Chihuahua: 'MX',
Chisinau: 'MD',
Chita: 'RU',
Choibalsan: 'MN',
Christmas: 'CX',
Chuuk: 'FM',
Cocos: 'CC',
Colombo: 'LK',
Comoro: 'KM',
Conakry: 'GN',
Copenhagen: 'DK',
Cordoba: 'AR',
Costa_Rica: 'CR',
Creston: 'CA',
Cuiaba: 'BR',
Curacao: 'CW',
Dakar: 'SN',
Damascus: 'SY',
Danmarkshavn: 'GL',
Dar_es_Salaam: 'TZ',
Darwin: 'AU',
Davis: 'AQ',
Dawson: 'CA',
Dawson_Creek: 'CA',
Denver: 'US',
Detroit: 'US',
Dhaka: 'BD',
Dili: 'TL',
Djibouti: 'DJ',
Dominica: 'DM',
Douala: 'CM',
Dubai: 'AE',
Dublin: 'IE',
DumontDUrville: 'AQ',
Dushanbe: 'TJ',
Easter: 'CL',
Edmonton: 'CA',
Efate: 'VU',
Eirunepe: 'BR',
El_Aaiun: 'EH',
El_Salvador: 'SV',
Eucla: 'AU',
Fakaofo: 'TK',
Famagusta: 'CY',
Faroe: 'FO',
Fiji: 'FJ',
Fort_Nelson: 'CA',
Fortaleza: 'BR',
Freetown: 'SL',
Funafuti: 'TV',
Gaborone: 'BW',
Galapagos: 'EC',
Gambier: 'PF',
Gaza: 'PS',
Gibraltar: 'GI',
Glace_Bay: 'CA',
Goose_Bay: 'CA',
Grand_Turk: 'TC',
Grenada: 'GD',
Guadalcanal: 'SB',
Guadeloupe: 'GP',
Guam: 'GU',
Guatemala: 'GT',
Guayaquil: 'EC',
Guernsey: 'GG',
Guyana: 'GY',
Halifax: 'CA',
Harare: 'ZW',
Havana: 'CU',
Hebron: 'PS',
Helsinki: 'FI',
Hermosillo: 'MX',
Ho_Chi_Minh: 'VN',
Hobart: 'AU',
Hong_Kong: 'HK',
Honolulu: 'US',
Hovd: 'MN',
Indianapolis: 'US',
Inuvik: 'CA',
Iqaluit: 'CA',
Irkutsk: 'RU',
Isle_of_Man: 'IM',
Istanbul: 'TR',
Jakarta: 'ID',
Jamaica: 'JM',
Jayapura: 'ID',
Jersey: 'JE',
Jerusalem: 'IL',
Johannesburg: 'ZA',
Juba: 'SS',
Jujuy: 'AR',
Juneau: 'US',
Kabul: 'AF',
Kaliningrad: 'RU',
Kamchatka: 'RU',
Kampala: 'UG',
Kanton: 'KI',
Karachi: 'PK',
Kathmandu: 'NP',
Kerguelen: 'TF',
Khandyga: 'RU',
Khartoum: 'SD',
Kiev: 'UA',
Kigali: 'RW',
Kinshasa: 'CD',
Kiritimati: 'KI',
Kirov: 'RU',
Knox: 'US',
Kolkata: 'IN',
Kosrae: 'FM',
Kralendijk: 'NL',
Krasnoyarsk: 'RU',
Kuala_Lumpur: 'MY',
Kuching: 'MY',
Kuwait: 'KW',
Kwajalein: 'MH',
La_Paz: 'BO',
La_Rioja: 'AR',
Lagos: 'NG',
Libreville: 'GA',
Lima: 'PE',
Lindeman: 'AU',
Lisbon: 'PT',
Ljubljana: 'SI',
Lome: 'TG',
London: 'GB',
Longyearbyen: 'SJ',
Lord_Howe: 'AU',
Los_Angeles: 'US',
Louisville: 'US',
Lower_Princes: 'SX',
Luanda: 'AO',
Lubumbashi: 'CD',
Lusaka: 'ZM',
Luxembourg: 'LU',
Macau: 'MO',
Maceio: 'BR',
Macquarie: 'AU',
Madeira: 'PT',
Madrid: 'ES',
Magadan: 'RU',
Mahe: 'SC',
Majuro: 'MH',
Makassar: 'ID',
Malabo: 'GQ',
Maldives: 'MV',
Malta: 'MT',
Managua: 'NI',
Manaus: 'BR',
Manila: 'PH',
Maputo: 'MZ',
Marengo: 'US',
Mariehamn: 'AX',
Marigot: 'MF',
Marquesas: 'PF',
Martinique: 'MQ',
Maseru: 'LS',
Matamoros: 'MX',
Mauritius: 'MU',
Mawson: 'AQ',
Mayotte: 'YT',
Mazatlan: 'MX',
Mbabane: 'SZ',
McMurdo: 'AQ',
Melbourne: 'AU',
Mendoza: 'AR',
Menominee: 'US',
Merida: 'MX',
Metlakatla: 'US',
Mexico_City: 'MX',
Midway: 'UM',
Minsk: 'BY',
Miquelon: 'PM',
Mogadishu: 'SO',
Monaco: 'MC',
Moncton: 'CA',
Monrovia: 'LR',
Monterrey: 'MX',
Montevideo: 'UY',
Monticello: 'US',
Montserrat: 'MS',
Moscow: 'RU',
Muscat: 'OM',
Nairobi: 'KE',
Nassau: 'BS',
Nauru: 'NR',
Ndjamena: 'TD',
New_Salem: 'US',
New_York: 'US',
Niamey: 'NE',
Nicosia: 'CY',
Nipigon: 'CA',
Niue: 'NU',
Nome: 'US',
Norfolk: 'NF',
Noronha: 'BR',
Nouakchott: 'MR',
Noumea: 'NC',
Novokuznetsk: 'RU',
Novosibirsk: 'RU',
Nuuk: 'GL',
Ojinaga: 'MX',
Omsk: 'RU',
Oral: 'KZ',
Oslo: 'NO',
Ouagadougou: 'BF',
Pago_Pago: 'AS',
Palau: 'PW',
Palmer: 'AQ',
Panama: 'PA',
Pangnirtung: 'CA',
Paramaribo: 'SR',
Paris: 'FR',
Perth: 'AU',
Petersburg: 'US',
Phnom_Penh: 'KH',
Phoenix: 'US',
Pitcairn: 'PN',
Podgorica: 'ME',
Pohnpei: 'FM',
Pontianak: 'ID',
'Port-au-Prince': 'HT',
Port_Moresby: 'PG',
Port_of_Spain: 'TT',
'Porto-Novo': 'BJ',
Porto_Velho: 'BR',
Prague: 'CZ',
Puerto_Rico: 'PR',
Punta_Arenas: 'CL',
Pyongyang: 'KP',
Qatar: 'QA',
Qostanay: 'KZ',
Qyzylorda: 'KZ',
Rainy_River: 'CA',
Rankin_Inlet: 'CA',
Rarotonga: 'CK',
Recife: 'BR',
Regina: 'CA',
Resolute: 'CA',
Reunion: 'RE',
Reykjavik: 'IS',
Riga: 'LV',
Rio_Branco: 'BR',
Rio_Gallegos: 'AR',
Riyadh: 'SA',
Rome: 'IT',
Rothera: 'AQ',
Saipan: 'MP',
Sakhalin: 'RU',
Salta: 'AR',
Samara: 'RU',
Samarkand: 'UZ',
San_Juan: 'AR',
San_Luis: 'AR',
San_Marino: 'SM',
Santarem: 'BR',
Santiago: 'CL',
Santo_Domingo: 'DO',
Sao_Paulo: 'BR',
Sao_Tome: 'ST',
Sarajevo: 'BA',
Saratov: 'RU',
Scoresbysund: 'GL',
Seoul: 'KR',
Shanghai: 'CN',
Simferopol: 'RU',
Singapore: 'SG',
Sitka: 'US',
Skopje: 'MK',
Sofia: 'BG',
South_Georgia: 'GS',
Srednekolymsk: 'RU',
St_Barthelemy: 'BL',
St_Helena: 'SH',
St_Johns: 'CA',
St_Kitts: 'KN',
St_Lucia: 'LC',
St_Thomas: 'VI',
St_Vincent: 'VC',
Stanley: 'FK',
Stockholm: 'SE',
Swift_Current: 'CA',
Sydney: 'AU',
Syowa: 'AQ',
Tahiti: 'PF',
Taipei: 'TW',
Tallinn: 'EE',
Tarawa: 'KI',
Tashkent: 'UZ',
Tbilisi: 'GE',
Tegucigalpa: 'HN',
Tehran: 'IR',
Tell_City: 'US',
Thimphu: 'BT',
Thule: 'GL',
Thunder_Bay: 'CA',
Tijuana: 'MX',
Tirane: 'AL',
Tokyo: 'JP',
Tomsk: 'RU',
Tongatapu: 'TO',
Toronto: 'CA',
Tortola: 'VI (UK)',
Tripoli: 'LY',
Troll: 'AQ',
Tucuman: 'AR',
Tunis: 'TN',
Ulaanbaatar: 'MN',
Ulyanovsk: 'RU',
Urumqi: 'CN',
Ushuaia: 'AR',
'Ust-Nera': 'RU',
Uzhgorod: 'UA',
Vaduz: 'LI',
Vancouver: 'CA',
Vatican: 'VA',
Vevay: 'US',
Vienna: 'AT',
Vientiane: 'LA',
Vilnius: 'LT',
Vincennes: 'US',
Vladivostok: 'RU',
Volgograd: 'RU',
Vostok: 'AQ',
Wake: 'UM',
Wallis: 'WF',
Warsaw: 'PL',
Whitehorse: 'CA',
Winamac: 'US',
Windhoek: 'NA',
Winnipeg: 'CA',
Yakutat: 'US',
Yakutsk: 'RU',
Yangon: 'MM',
Yekaterinburg: 'RU',
Yellowknife: 'CA',
Yerevan: 'AM',
Zagreb: 'HR',
Zaporozhye: 'UA',
Zurich: 'CH'
};

1
package.json

@ -100,6 +100,7 @@
"class-transformer": "0.3.2",
"class-validator": "0.13.1",
"color": "4.2.3",
"countries-and-timezones": "3.4.1",
"countries-list": "2.6.1",
"countup.js": "2.3.2",
"date-fns": "2.29.3",

5
yarn.lock

@ -9352,6 +9352,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
path-type "^4.0.0"
yaml "^1.10.0"
countries-and-timezones@3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/countries-and-timezones/-/countries-and-timezones-3.4.1.tgz#0ec2540f57e42f0f740eb2acaede786043347fe1"
integrity sha512-INeHGCony4XUUR8iGL/lmt9s1Oi+n+gFHeJAMfbV5hJfYeDOB8JG1oxz5xFQu5oBZoRCJe/87k1Vzue9DoIauA==
countries-list@2.6.1:
version "2.6.1"
resolved "https://registry.npmjs.org/countries-list/-/countries-list-2.6.1.tgz"

Loading…
Cancel
Save