Browse Source

Feature/user account deletion for Google users (#8093)

* Extend user account deletion to users without security token

* Update changelog
pull/8063/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
8ba968dc07
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 26
      apps/api/src/app/user/user.controller.ts
  3. 14
      apps/api/src/app/user/user.service.ts
  4. 20
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  5. 14
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  6. 2
      libs/common/src/lib/config.ts
  7. 5
      libs/common/src/lib/dtos/delete-own-user.dto.ts

1
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`)

26
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<UserModel> {
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<UserModel> {
if (!accessToken) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')

14
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,

20
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;

14
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -318,7 +318,10 @@
<div class="d-flex py-1">
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
<div class="pl-1 w-50">
@if (hasPermissionToDeleteOwnUser) {
@if (
hasPermissionToDeleteOwnUser &&
hasPermissionToUpdateOwnAccessToken
) {
<form
class="w-100"
[formGroup]="deleteOwnUserForm"
@ -363,6 +366,15 @@
Close Account
</button>
</form>
} @else if (hasPermissionToDeleteOwnUser && !hasImpersonationId) {
<button
color="warn"
i18n
mat-flat-button
(click)="onCloseAccount()"
>
Close Account
</button>
} @else if (
hasPermissionToRequestOwnUserDeletion &&
(user?.accounts?.length > 0 || user?.activitiesCount > 0)

2
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 = [
{

5
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;
}

Loading…
Cancel
Save