diff --git a/CHANGELOG.md b/CHANGELOG.md index af3f26f32..e2d47440b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the ability to close a user account + ### Changed - Improved the language localization for German (`de`) diff --git a/apps/api/src/app/user/delete-own-user.dto.ts b/apps/api/src/app/user/delete-own-user.dto.ts new file mode 100644 index 000000000..1e3f940cb --- /dev/null +++ b/apps/api/src/app/user/delete-own-user.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteOwnUserDto { + @IsString() + accessToken: string; +} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 39e78dcdc..7cd2002bd 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,5 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -25,6 +26,7 @@ import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { size } from 'lodash'; +import { DeleteOwnUserDto } from './delete-own-user.dto'; import { UserItem } from './interfaces/user-item.interface'; import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UserService } from './user.service'; @@ -32,12 +34,41 @@ import { UserService } from './user.service'; @Controller('user') export class UserController { public constructor( + private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} + @Delete() + @HasPermission(permissions.deleteOwnUser) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteOwnUser( + @Body() data: DeleteOwnUserDto + ): Promise { + const hashedAccessToken = this.userService.createAccessToken( + data.accessToken, + this.configurationService.get('ACCESS_TOKEN_SALT') + ); + + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken, id: this.request.user.id } + }); + + if (!user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.userService.deleteUser({ + accessToken: hashedAccessToken, + id: user.id + }); + } + @Delete(':id') @HasPermission(permissions.deleteUser) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 0ac586671..51bd4c4db 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -240,10 +240,13 @@ export class UserService { // Reset benchmark user.Settings.settings.benchmark = undefined; - } - - if (user.subscription?.type === 'Premium') { + } else if (user.subscription?.type === 'Premium') { currentPermissions.push(permissions.reportDataGlitch); + + currentPermissions = without( + currentPermissions, + permissions.deleteOwnUser + ); } } diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index c6d7f8e89..4f80207aa 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -4,6 +4,7 @@ import { KEY_TOKEN, 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 { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; @@ -17,6 +18,7 @@ import { OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { format, parseISO } from 'date-fns'; import { uniq } from 'lodash'; @@ -33,8 +35,13 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { public appearancePlaceholder = $localize`Auto`; public baseCurrency: string; public currencies: string[] = []; + public deleteOwnUserForm = this.formBuilder.group({ + accessToken: ['', Validators.required] + }); + public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; + public isAccessTokenHidden = true; public isWebAuthnEnabled: boolean; public language = document.documentElement.lang; public locales = [ @@ -58,7 +65,9 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private formBuilder: FormBuilder, private settingsStorageService: SettingsStorageService, + private tokenStorageService: TokenStorageService, private userService: UserService, public webAuthnService: WebAuthnService ) { @@ -73,6 +82,11 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionToDeleteOwnUser = hasPermission( + this.user.permissions, + permissions.deleteOwnUser + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -125,6 +139,33 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { }); } + public onCloseAccount() { + const confirmation = confirm( + $localize`Do you really want to close your Ghostfolio account?` + ); + + if (confirmation) { + this.dataService + .deleteOwnUser({ + accessToken: this.deleteOwnUserForm.get('accessToken').value + }) + .pipe( + catchError(() => { + alert($localize`Oops! Incorrect Security Token.`); + + return EMPTY; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(() => { + this.tokenStorageService.signOut(); + this.userService.remove(); + + document.location.href = `/${document.documentElement.lang}`; + }); + } + } + public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ isExperimentalFeatures: aEvent.checked }) diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 1ad24e22e..fff38a588 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -232,6 +232,55 @@ + @if (hasPermissionToDeleteOwnUser) { +
+
+
+
Danger Zone
+
+ + Security Token + + + + +
+
+
+ } diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts index 89626a96c..e0fe2e1e2 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts @@ -1,11 +1,12 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { RouterModule } from '@angular/router'; @@ -22,10 +23,12 @@ import { UserAccountSettingsComponent } from './user-account-settings.component' MatButtonModule, MatCardModule, MatFormFieldModule, + MatInputModule, MatSelectModule, MatSlideToggleModule, ReactiveFormsModule, RouterModule - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfUserAccountSettingsModule {} diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 18f1b966d..241bb1d3e 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -9,6 +9,7 @@ import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; +import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; @@ -271,6 +272,10 @@ export class DataService { return this.http.delete(`/api/v1/benchmark/${dataSource}/${symbol}`); } + public deleteOwnUser(aData: DeleteOwnUserDto) { + return this.http.delete(`/api/v1/user`, { body: aData }); + } + public deleteUser(aId: string) { return this.http.delete(`/api/v1/user/${aId}`); } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 7c8b8ccbe..304c8ba24 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -17,6 +17,7 @@ export const permissions = { deleteAccountBalance: 'deleteAcccountBalance', deleteAuthDevice: 'deleteAuthDevice', deleteOrder: 'deleteOrder', + deleteOwnUser: 'deleteOwnUser', deletePlatform: 'deletePlatform', deleteTag: 'deleteTag', deleteUser: 'deleteUser', @@ -57,6 +58,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAuthDevice, permissions.deleteOrder, + permissions.deleteOwnUser, permissions.deletePlatform, permissions.deleteTag, permissions.deleteUser, @@ -84,6 +86,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccountBalance, permissions.deleteAuthDevice, permissions.deleteOrder, + permissions.deleteOwnUser, permissions.updateAccount, permissions.updateAuthDevice, permissions.updateOrder,