From 1043e37ad05c4ea8cd13c6329bc6ce0fcdab2a47 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:35:25 +0200 Subject: [PATCH] Close own account with third-party authentication provider --- apps/api/src/app/user/user.controller.ts | 25 ++++++++++--- apps/api/src/app/user/user.service.ts | 8 +++- .../user-account-settings.component.ts | 37 +++++++++++++++++-- .../user-account-settings.html | 32 ++++++++++------ .../src/lib/dtos/delete-own-user.dto.ts | 5 ++- .../src/lib/interfaces/user.interface.ts | 3 +- 6 files changed, 84 insertions(+), 26 deletions(-) diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 7a1fc71dc..d1cb18198 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -67,13 +67,14 @@ export class UserController { public async deleteOwnUser( @Body() data: DeleteOwnUserDto ): Promise { - const user = await this.validateAccessToken( - data.accessToken, - this.request.user.id - ); + if (this.request.user.provider === 'ANONYMOUS') { + await this.validateAccessToken(data.accessToken, this.request.user.id); + } else { + await this.validateUserHasNoData(this.request.user.id); + } return this.userService.deleteUser({ - id: user.id + id: this.request.user.id }); } @@ -243,4 +244,18 @@ export class UserController { return user; } + + private async validateUserHasNoData(userId: string) { + const [accountsCount, activitiesCount] = await Promise.all([ + this.prismaService.account.count({ where: { userId } }), + this.prismaService.order.count({ where: { userId } }) + ]); + + if (accountsCount > 0 || activitiesCount > 0) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 648f36cbf..66931e818 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -110,7 +110,7 @@ export class UserService { locale?: string; user: UserWithSettings; }): Promise { - const { id, permissions, settings, subscription } = user; + const { id, permissions, provider, settings, subscription } = user; const [ access, @@ -193,6 +193,7 @@ export class UserService { activitiesCount, id, permissions, + provider, referralPartners, subscription, systemMessage, @@ -462,8 +463,11 @@ export class UserService { let currentPermissions = getPermissions(user.role); - if (user.provider === 'ANONYMOUS') { + if (!hasRole(user, Role.DEMO)) { currentPermissions.push(permissions.deleteOwnUser); + } + + if (user.provider === 'ANONYMOUS') { currentPermissions.push(permissions.updateOwnAccessToken); } 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 a62760dad..2e71db8f1 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 @@ -5,7 +5,10 @@ import { } from '@ghostfolio/client/services/settings-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { E_MAIL_LINE_BREAK } from '@ghostfolio/common/config'; +import { + DEFAULT_LANGUAGE_CODE, + E_MAIL_LINE_BREAK +} from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { downloadAsFile } from '@ghostfolio/common/helper'; import { User } from '@ghostfolio/common/interfaces'; @@ -83,6 +86,7 @@ export class GfUserAccountSettingsComponent implements OnInit { protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateUserSettings: boolean; protected isAccessTokenHidden = true; + protected isAnonymousAuthenticationProvider: boolean; protected readonly isFingerprintSupported = this.doesBrowserSupportAuthn(); protected isWebAuthnEnabled: boolean; protected readonly language = document.documentElement.lang; @@ -104,6 +108,7 @@ export class GfUserAccountSettingsComponent implements OnInit { 'uk', 'zh' ]; + protected mustRemoveDataBeforeClosingAccount: boolean; protected user: User; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -127,6 +132,14 @@ export class GfUserAccountSettingsComponent implements OnInit { if (state?.user) { this.user = state.user; + const userDetailUrl = [ + window.location.origin, + DEFAULT_LANGUAGE_CODE, + internalRoutes.adminControl.path, + internalRoutes.adminControl.subRoutes.users.path, + this.user.id + ].join('/'); + this.closeUserAccountMailHref = `mailto:hi@ghostfol.io?subject=Delete Account&body=${[ 'Hello', '', @@ -134,7 +147,11 @@ export class GfUserAccountSettingsComponent implements OnInit { '', `User ID: ${this.user.id}`, '', - 'Kind regards' + 'Kind regards', + '', + '', + '---', + userDetailUrl ].join(E_MAIL_LINE_BREAK)}`; this.hasPermissionToDeleteOwnUser = hasPermission( @@ -147,6 +164,14 @@ export class GfUserAccountSettingsComponent implements OnInit { permissions.requestOwnUserDeletion ); + this.isAnonymousAuthenticationProvider = + this.user.provider === 'ANONYMOUS'; + + this.mustRemoveDataBeforeClosingAccount = + (!this.isAnonymousAuthenticationProvider || + this.hasPermissionToRequestOwnUserDeletion) && + (this.user.accounts?.length > 0 || this.user.activitiesCount > 0); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -207,12 +232,16 @@ export class GfUserAccountSettingsComponent implements OnInit { confirmFn: () => { this.dataService .deleteOwnUser({ - accessToken: this.deleteOwnUserForm.controls.accessToken.value + accessToken: this.isAnonymousAuthenticationProvider + ? this.deleteOwnUserForm.controls.accessToken.value + : undefined }) .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` + title: this.isAnonymousAuthenticationProvider + ? $localize`Oops! Incorrect Security Token.` + : $localize`Oops! Your account could not be closed.` }); 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 e77a89b24..7f0e281d0 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 @@ -292,7 +292,26 @@
Danger Zone
- @if (hasPermissionToDeleteOwnUser) { + @if (mustRemoveDataBeforeClosingAccount) { + +
+ For security reasons, please delete all activities and accounts + first before your Ghostfolio account can be closed. +
+ } @else if ( + hasPermissionToDeleteOwnUser && !isAnonymousAuthenticationProvider + ) { + + } @else if (hasPermissionToDeleteOwnUser) {
- } @else if ( - hasPermissionToRequestOwnUserDeletion && - (user?.accounts?.length > 0 || user?.activitiesCount > 0) - ) { - -
- For security reasons, please delete all activities and accounts - first before your Ghostfolio account can be closed. -
} @else if (hasPermissionToRequestOwnUserDeletion) {