diff --git a/CHANGELOG.md b/CHANGELOG.md index a41523ec9..6dddbbfa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Supported the management of additional currencies in the admin control panel - Introduced the system message +- Introduced the read only mode ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index bbf5423cc..405c53e68 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -1,9 +1,5 @@ import { Access } from '@ghostfolio/common/interfaces'; -import { - getPermissions, - hasPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -66,10 +62,7 @@ export class AccessController { @Body() data: CreateAccessDto ): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.createAccess - ) + !hasPermission(this.request.user.permissions, permissions.createAccess) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -86,10 +79,7 @@ export class AccessController { @UseGuards(AuthGuard('jwt')) public async deleteAccess(@Param('id') id: string): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.deleteAccess - ) + !hasPermission(this.request.user.permissions, permissions.deleteAccess) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index a832bfca9..54fd912fd 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -6,11 +6,7 @@ import { } from '@ghostfolio/api/helper/object.helper'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { Accounts } from '@ghostfolio/common/interfaces'; -import { - getPermissions, - hasPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -48,10 +44,7 @@ export class AccountController { @UseGuards(AuthGuard('jwt')) public async deleteAccount(@Param('id') id: string): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.deleteAccount - ) + !hasPermission(this.request.user.permissions, permissions.deleteAccount) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -143,10 +136,7 @@ export class AccountController { @Body() data: CreateAccountDto ): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.createAccount - ) + !hasPermission(this.request.user.permissions, permissions.createAccount) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -183,10 +173,7 @@ export class AccountController { @UseGuards(AuthGuard('jwt')) public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.updateAccount - ) + !hasPermission(this.request.user.permissions, permissions.updateAccount) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 054371458..ef6753894 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,17 +1,12 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { AdminData, AdminMarketData, AdminMarketDataDetails } from '@ghostfolio/common/interfaces'; -import { - getPermissions, - hasPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -45,7 +40,7 @@ export class AdminController { public async getAdminData(): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -63,7 +58,7 @@ export class AdminController { public async gatherMax(): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -87,7 +82,7 @@ export class AdminController { ): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -107,7 +102,7 @@ export class AdminController { public async gatherProfileData(): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -127,7 +122,7 @@ export class AdminController { public async getMarketData(): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -147,7 +142,7 @@ export class AdminController { ): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { @@ -168,7 +163,7 @@ export class AdminController { ) { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.accessAdminControl ) ) { diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts index 89a44dd2f..33eae0cc0 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -1,9 +1,5 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; -import { - getPermissions, - hasPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Controller, @@ -29,7 +25,7 @@ export class AuthDeviceController { public async deleteAuthDevice(@Param('id') id: string): Promise { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.deleteAuthDevice ) ) { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index d573f91fe..8a59ff82a 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,7 +1,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { Module } from '@nestjs/common'; @@ -19,7 +19,8 @@ import { JwtStrategy } from './jwt.strategy'; secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } }), - SubscriptionModule + SubscriptionModule, + UserModule ], providers: [ AuthDeviceService, @@ -28,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy'; GoogleStrategy, JwtStrategy, PrismaService, - UserService, WebAuthService ] }) diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 074313d5a..073a3dbd9 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -6,6 +6,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + PROPERTY_IS_READ_ONLY_MODE, PROPERTY_STRIPE_CONFIG, PROPERTY_SYSTEM_MESSAGE } from '@ghostfolio/common/config'; @@ -36,6 +37,7 @@ export class InfoService { public async get(): Promise { const info: Partial = {}; + let isReadOnlyMode: boolean; const platforms = await this.prismaService.platform.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } @@ -52,6 +54,12 @@ export class InfoService { globalPermissions.push(permissions.enableImport); } + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { + isReadOnlyMode = (await this.propertyService.getByKey( + PROPERTY_IS_READ_ONLY_MODE + )) as boolean; + } + if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { globalPermissions.push(permissions.enableSocialLogin); } @@ -77,6 +85,7 @@ export class InfoService { return { ...info, globalPermissions, + isReadOnlyMode, platforms, systemMessage, currencies: this.exchangeRateDataService.getCurrencies(), diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index d2c5d02e6..5281529b0 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,11 +1,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; -import { - getPermissions, - hasPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -43,10 +39,7 @@ export class OrderController { @UseGuards(AuthGuard('jwt')) public async deleteOrder(@Param('id') id: string): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.deleteOrder - ) + !hasPermission(this.request.user.permissions, permissions.deleteOrder) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -115,10 +108,7 @@ export class OrderController { @UseGuards(AuthGuard('jwt')) public async createOrder(@Body() data: CreateOrderDto): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.createOrder - ) + !hasPermission(this.request.user.permissions, permissions.createOrder) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -161,10 +151,7 @@ export class OrderController { @UseGuards(AuthGuard('jwt')) public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.updateOrder - ) + !hasPermission(this.request.user.permissions, permissions.updateOrder) ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index f62599dfd..0a7ed21cf 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,7 +1,10 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { User } from '@ghostfolio/common/interfaces'; import { - getPermissions, hasPermission, + hasRole, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -20,7 +23,7 @@ import { import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; -import { Provider } from '@prisma/client'; +import { Provider, Role } from '@prisma/client'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -34,7 +37,9 @@ import { UserService } from './user.service'; @Controller('user') export class UserController { public constructor( + private readonly configurationService: ConfigurationService, private jwtService: JwtService, + private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @@ -43,10 +48,7 @@ export class UserController { @UseGuards(AuthGuard('jwt')) public async deleteUser(@Param('id') id: string): Promise { if ( - !hasPermission( - getPermissions(this.request.user.role), - permissions.deleteUser - ) || + !hasPermission(this.request.user.permissions, permissions.deleteUser) || id === this.request.user.id ) { throw new HttpException( @@ -68,6 +70,19 @@ export class UserController { @Post() public async signupUser(): Promise { + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { + const isReadOnlyMode = (await this.propertyService.getByKey( + PROPERTY_IS_READ_ONLY_MODE + )) as boolean; + + if (isReadOnlyMode) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + const { accessToken, id } = await this.userService.createUser({ provider: Provider.ANONYMOUS }); @@ -85,7 +100,7 @@ export class UserController { public async updateUserSetting(@Body() data: UpdateUserSettingDto) { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.updateUserSettings ) ) { @@ -111,7 +126,7 @@ export class UserController { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { if ( !hasPermission( - getPermissions(this.request.user.role), + this.request.user.permissions, permissions.updateUserSettings ) ) { @@ -127,10 +142,7 @@ export class UserController { }; if ( - hasPermission( - getPermissions(this.request.user.role), - permissions.updateViewMode - ) + hasPermission(this.request.user.permissions, permissions.updateViewMode) ) { userSettings.viewMode = data.viewMode; } diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 7d2fc3d8e..ffbdc80db 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -1,6 +1,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -13,6 +14,7 @@ import { UserService } from './user.service'; secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '30 days' } }), + PropertyModule, SubscriptionModule ], controllers: [UserController], diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index a513cbba6..13a5b7d1d 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,12 +1,21 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; -import { baseCurrency, locale } from '@ghostfolio/common/config'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_IS_READ_ONLY_MODE, + baseCurrency, + locale +} from '@ghostfolio/common/config'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; -import { getPermissions, permissions } from '@ghostfolio/common/permissions'; +import { + getPermissions, + hasRole, + permissions +} from '@ghostfolio/common/permissions'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { Injectable } from '@nestjs/common'; -import { Prisma, Provider, User, ViewMode } from '@prisma/client'; +import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client'; import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettings } from './interfaces/user-settings.interface'; @@ -20,6 +29,7 @@ export class UserService { public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService ) {} @@ -74,12 +84,32 @@ export class UserService { const user: UserWithSettings = userFromDatabase; - const currentPermissions = getPermissions(userFromDatabase.role); + let currentPermissions = getPermissions(userFromDatabase.role); if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { currentPermissions.push(permissions.accessFearAndGreedIndex); } + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { + if (hasRole(user, Role.ADMIN)) { + currentPermissions.push(permissions.toggleReadOnlyMode); + } + + const isReadOnlyMode = (await this.propertyService.getByKey( + PROPERTY_IS_READ_ONLY_MODE + )) as boolean; + + if (isReadOnlyMode) { + currentPermissions = currentPermissions.filter((permission) => { + return !( + permission.startsWith('create') || + permission.startsWith('delete') || + permission.startsWith('update') + ); + }); + } + } + user.permissions = currentPermissions; if (userFromDatabase?.Settings) { diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 4a44e87db..1956a6b62 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -18,6 +18,7 @@ export class ConfigurationService { ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_IMPORT: bool({ default: true }), + ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 6902a27ec..e475d32ec 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -9,6 +9,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_IMPORT: boolean; + ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 5b576f9bc..4760c3a94 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -14,7 +14,7 @@ export class PropertyService { public async get() { const response: { - [key: string]: object | string | string[]; + [key: string]: boolean | object | string | string[]; } = { [PROPERTY_CURRENCIES]: [] }; diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 76c78378a..51105632b 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { CacheService } from '@ghostfolio/client/services/cache.service'; import { DataService } from '@ghostfolio/client/services/data.service'; @@ -6,6 +7,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_FORMAT, PROPERTY_CURRENCIES, + PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE } from '@ghostfolio/common/config'; import { InfoItem, User } from '@ghostfolio/common/interfaces'; @@ -32,6 +34,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { public defaultDateFormat = DEFAULT_DATE_FORMAT; public exchangeRates: { label1: string; label2: string; value: number }[]; public hasPermissionForSystemMessage: boolean; + public hasPermissionToToggleReadOnlyMode: boolean; public info: InfoItem; public lastDataGathering: string; public transactionCount: number; @@ -52,27 +55,32 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { ) { this.info = this.dataService.fetchInfo(); - this.hasPermissionForSystemMessage = hasPermission( - this.info.globalPermissions, - permissions.enableSystemMessage - ); - } - - /** - * Initializes the controller - */ - public ngOnInit() { - this.fetchAdminData(); - this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; + + this.hasPermissionForSystemMessage = hasPermission( + this.info.globalPermissions, + permissions.enableSystemMessage + ); + + this.hasPermissionToToggleReadOnlyMode = hasPermission( + this.user.permissions, + permissions.toggleReadOnlyMode + ); } }); } + /** + * Initializes the controller + */ + public ngOnInit() { + this.fetchAdminData(); + } + public formatDistanceToNow(aDateString: string) { if (aDateString) { const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { @@ -147,6 +155,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { .subscribe(() => {}); } + public onReadOnlyModeChange(aEvent: MatSlideToggleChange) { + this.setReadOnlyMode(aEvent.checked); + } + public onSetSystemMessage() { const systemMessage = prompt('Please set your system message:'); @@ -223,4 +235,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { }, 300); }); } + + private setReadOnlyMode(aValue: boolean) { + this.dataService + .putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, { + value: aValue ? 'true' : '' + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + setTimeout(() => { + window.location.reload(); + }, 300); + }); + } } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index ca81b5143..80bbf1ab6 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -146,6 +146,16 @@ +
+
Read-only Mode
+
+ +
+
diff --git a/apps/client/src/app/components/admin-overview/admin-overview.module.ts b/apps/client/src/app/components/admin-overview/admin-overview.module.ts index d87e0c5ad..f75f312ce 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.module.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { CacheService } from '@ghostfolio/client/services/cache.service'; import { GfValueModule } from '@ghostfolio/ui/value'; @@ -10,7 +11,13 @@ import { AdminOverviewComponent } from './admin-overview.component'; @NgModule({ declarations: [AdminOverviewComponent], exports: [], - imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule], + imports: [ + CommonModule, + GfValueModule, + MatButtonModule, + MatCardModule, + MatSlideToggleModule + ], providers: [CacheService], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 593ac23f3..4aeab5574 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -270,7 +270,7 @@ Sign In -
+
diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.ts b/apps/client/src/app/components/transactions-table/transactions-table.component.ts index e9a657406..ab2f18e10 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.ts +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.ts @@ -43,6 +43,7 @@ export class TransactionsTableComponent { @Input() baseCurrency: string; @Input() deviceType: string; + @Input() hasPermissionToCreateOrder: boolean; @Input() hasPermissionToImportOrders: boolean; @Input() locale: string; @Input() showActions: boolean; diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index afed17f4e..ea0cf8d2e 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -15,22 +15,28 @@ import { } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; +import { InfoItem } from '@ghostfolio/common/interfaces'; import { StatusCodes } from 'http-status-codes'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; -import { TokenStorageService } from '../services/token-storage.service'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { + public info: InfoItem; public snackBarRef: MatSnackBarRef; public constructor( + private dataService: DataService, private router: Router, private tokenStorageService: TokenStorageService, private snackBar: MatSnackBar, private webAuthnService: WebAuthnService - ) {} + ) { + this.info = this.dataService.fetchInfo(); + } public intercept( request: HttpRequest, @@ -63,11 +69,19 @@ export class HttpResponseInterceptor implements HttpInterceptor { catchError((error: HttpErrorResponse) => { if (error.status === StatusCodes.FORBIDDEN) { if (!this.snackBarRef) { - this.snackBarRef = this.snackBar.open( - 'This feature requires a subscription.', - 'Upgrade Plan', - { duration: 6000 } - ); + if (this.info.isReadOnlyMode) { + this.snackBarRef = this.snackBar.open( + 'This feature is currently unavailable. Please try again later.', + undefined, + { duration: 6000 } + ); + } else { + this.snackBarRef = this.snackBar.open( + 'This feature requires a subscription.', + 'Upgrade Plan', + { duration: 6000 } + ); + } this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef = undefined; diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 81ede2dc1..53191835c 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -51,7 +51,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit { this.route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { - if (params['createDialog']) { + if (params['createDialog'] && this.hasPermissionToCreateAccount) { this.openCreateAccountDialog(); } else if (params['editDialog']) { if (this.accounts) { diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index 0b79782a3..1827e156e 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -61,7 +61,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { this.routeQueryParams = route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { - if (params['createDialog']) { + if (params['createDialog'] && this.hasPermissionToCreateOrder) { this.openCreateTransactionDialog(); } else if (params['editDialog']) { if (this.transactions) { @@ -130,7 +130,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .subscribe((response) => { this.transactions = response; - if (this.transactions?.length <= 0) { + if (this.hasPermissionToCreateOrder && this.transactions?.length <= 0) { this.router.navigate([], { queryParams: { createDialog: true } }); } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html index 5288d7313..f97727e38 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html @@ -5,6 +5,7 @@ (); @@ -37,6 +39,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit { private router: Router, private tokenStorageService: TokenStorageService ) { + this.info = this.dataService.fetchInfo(); + this.tokenStorageService.signOut(); } diff --git a/apps/client/src/app/pages/register/register-page.html b/apps/client/src/app/pages/register/register-page.html index 5806e8f4c..96d49251b 100644 --- a/apps/client/src/app/pages/register/register-page.html +++ b/apps/client/src/app/pages/register/register-page.html @@ -22,7 +22,7 @@ color="primary" i18n mat-flat-button - [disabled]="!demoAuthToken" + [disabled]="!demoAuthToken || info?.isReadOnlyMode" (click)="createAccount()" > Create Account diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 6697c92f9..31076cccd 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -31,6 +31,7 @@ export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const PROPERTY_CURRENCIES = 'CURRENCIES'; +export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING'; export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; diff --git a/libs/common/src/lib/interfaces/admin-data.interface.ts b/libs/common/src/lib/interfaces/admin-data.interface.ts index ce4ea8c0b..a061269e7 100644 --- a/libs/common/src/lib/interfaces/admin-data.interface.ts +++ b/libs/common/src/lib/interfaces/admin-data.interface.ts @@ -4,7 +4,7 @@ export interface AdminData { dataGatheringProgress?: number; exchangeRates: { label1: string; label2: string; value: number }[]; lastDataGathering?: Date | 'IN_PROGRESS'; - settings: { [key: string]: object | string | string[] }; + settings: { [key: string]: boolean | object | string | string[] }; transactionCount: number; userCount: number; users: { diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index 08b511cf3..7ac5f3e9e 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -7,11 +7,12 @@ export interface InfoItem { currencies: string[]; demoAuthToken: string; globalPermissions: string[]; + isReadOnlyMode?: boolean; lastDataGathering?: Date; - systemMessage?: string; platforms: { id: string; name: string }[]; primaryDataSource: DataSource; statistics: Statistics; stripePublicKey?: string; subscriptions: Subscription[]; + systemMessage?: string; } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 8feab7466..dc574f7ef 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -1,5 +1,7 @@ import { Role } from '@prisma/client'; +import { UserWithSettings } from './interfaces'; + export const permissions = { accessAdminControl: 'accessAdminControl', accessFearAndGreedIndex: 'accessFearAndGreedIndex', @@ -18,6 +20,7 @@ export const permissions = { enableStatistics: 'enableStatistics', enableSubscription: 'enableSubscription', enableSystemMessage: 'enableSystemMessage', + toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', updateAuthDevice: 'updateAuthDevice', updateOrder: 'updateOrder', @@ -25,13 +28,6 @@ export const permissions = { updateViewMode: 'updateViewMode' }; -export function hasPermission( - aPermissions: string[] = [], - aPermission: string -) { - return aPermissions.includes(aPermission); -} - export function getPermissions(aRole: Role): string[] { switch (aRole) { case 'ADMIN': @@ -75,3 +71,14 @@ export function getPermissions(aRole: Role): string[] { return []; } } + +export function hasPermission( + aPermissions: string[] = [], + aPermission: string +) { + return aPermissions.includes(aPermission); +} + +export function hasRole(aUser: UserWithSettings, aRole: Role): boolean { + return aUser?.role === aRole; +}