From 8ba968dc0740c74240ee9b3f74df532a7f033fda Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:02:37 +0200 Subject: [PATCH] Feature/user account deletion for Google users (#8093) * Extend user account deletion to users without security token * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/user/user.controller.ts | 26 ++++++++++++++----- apps/api/src/app/user/user.service.ts | 14 +++++++++- .../user-account-settings.component.ts | 20 +++++++++++--- .../user-account-settings.html | 14 +++++++++- libs/common/src/lib/config.ts | 2 ++ .../src/lib/dtos/delete-own-user.dto.ts | 5 ++-- 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a105dd492..2af3d74aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the user account deletion flow in the user settings of the user account page to users without a _Security Token_ within 14 days after the registration - Improved the _Storybook_ stories of the account selector, accounts table, activities table and holdings table components - Improved the language localization for Spanish (`es`) diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 32ac353351..c4824fe7d6 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -71,14 +71,21 @@ export class UserController { @Delete() @HasPermission(permissions.deleteOwnUser) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async deleteOwnUser( - @Body() data: DeleteOwnUserDto + @Body() data: DeleteOwnUserDto, + @Impersonation() { isActive }: ImpersonationContext ): Promise { - const user = await this.validateAccessToken( - data.accessToken, - this.request.user.id - ); + const user = this.request.user; + + if (user.provider === 'ANONYMOUS') { + await this.validateAccessToken(data.accessToken, user.id); + } else if (isActive) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } return this.userService.deleteUser({ id: user.id @@ -260,6 +267,13 @@ export class UserController { accessToken: string, userId: string ): Promise { + if (!accessToken) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + const hashedAccessToken = this.userService.createAccessToken({ password: accessToken, salt: this.configurationService.get('ACCESS_TOKEN_SALT') diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 2e54a7ddc3..227d04da80 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -14,6 +14,7 @@ import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, DEFAULT_LOCALE, + DELETE_OWN_USER_PERIOD, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_MAX_DAILY_REQUESTS, @@ -45,7 +46,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { Prisma, Role, User } from '@prisma/client'; -import { differenceInDays, subDays } from 'date-fns'; +import { addMilliseconds, differenceInDays, isBefore, subDays } from 'date-fns'; import { isNil, without } from 'lodash-es'; import { createHmac } from 'node:crypto'; @@ -438,6 +439,17 @@ export class UserService { currentPermissions.push(permissions.enableSubscriptionInterstitial); } + if ( + !hasRole(user, Role.DEMO) && + user.provider !== 'ANONYMOUS' && + isBefore( + new Date(), + addMilliseconds(user.createdAt, DELETE_OWN_USER_PERIOD) + ) + ) { + currentPermissions.push(permissions.deleteOwnUser); + } + currentPermissions = without( currentPermissions, permissions.accessHoldingsChart, 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 83f7ddd9ae..3c95652e44 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 @@ -94,6 +94,7 @@ export class GfUserAccountSettingsComponent implements OnInit { protected hasImpersonationId: boolean; protected hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean; + protected hasPermissionToUpdateOwnAccessToken: boolean; protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateUserSettings: boolean; protected isAccessTokenHidden = true; @@ -194,6 +195,11 @@ export class GfUserAccountSettingsComponent implements OnInit { permissions.requestOwnUserDeletion ); + this.hasPermissionToUpdateOwnAccessToken = hasPermission( + this.user.permissions, + permissions.updateOwnAccessToken + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -271,13 +277,19 @@ export class GfUserAccountSettingsComponent implements OnInit { this.notificationService.confirm({ confirmFn: () => { this.dataService - .deleteOwnUser({ - accessToken: this.deleteOwnUserForm.controls.accessToken.value - }) + .deleteOwnUser( + this.hasPermissionToUpdateOwnAccessToken + ? { + accessToken: this.deleteOwnUserForm.controls.accessToken.value + } + : {} + ) .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` + title: this.hasPermissionToUpdateOwnAccessToken + ? $localize`Oops! Incorrect Security Token.` + : $localize`Oops! Something went wrong.` }); return EMPTY; 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 2d4c090dd2..bf18373447 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 @@ -318,7 +318,10 @@
Danger Zone
- @if (hasPermissionToDeleteOwnUser) { + @if ( + hasPermissionToDeleteOwnUser && + hasPermissionToUpdateOwnAccessToken + ) {
+ } @else if (hasPermissionToDeleteOwnUser && !hasImpersonationId) { + } @else if ( hasPermissionToRequestOwnUserDeletion && (user?.accounts?.length > 0 || user?.activitiesCount > 0) diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 73bf5cc00c..a60a377dd0 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -198,6 +198,8 @@ export const DEFAULT_REDACTED_PATHS = [ 'valueInBaseCurrency' ]; +export const DELETE_OWN_USER_PERIOD = ms('2 weeks'); + // USX is handled separately export const DERIVED_CURRENCIES = [ { diff --git a/libs/common/src/lib/dtos/delete-own-user.dto.ts b/libs/common/src/lib/dtos/delete-own-user.dto.ts index 1e3f940cb1..bf1868c6ec 100644 --- a/libs/common/src/lib/dtos/delete-own-user.dto.ts +++ b/libs/common/src/lib/dtos/delete-own-user.dto.ts @@ -1,6 +1,7 @@ -import { IsString } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; export class DeleteOwnUserDto { + @IsOptional() @IsString() - accessToken: string; + accessToken?: string; }