Feature/add ability to close user account (#3444)
* Add ability to close user account * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
This commit is contained in:
parent
9f875adf0c
commit
1a0cb561cd
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the ability to close a user account
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the language localization for German (`de`)
|
- Improved the language localization for German (`de`)
|
||||||
|
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteOwnUserDto {
|
||||||
|
@IsString()
|
||||||
|
accessToken: string;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { size } from 'lodash';
|
import { size } from 'lodash';
|
||||||
|
|
||||||
|
import { DeleteOwnUserDto } from './delete-own-user.dto';
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
@ -32,12 +34,41 @@ import { UserService } from './user.service';
|
|||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@HasPermission(permissions.deleteOwnUser)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteOwnUser(
|
||||||
|
@Body() data: DeleteOwnUserDto
|
||||||
|
): Promise<UserModel> {
|
||||||
|
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')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteUser)
|
@HasPermission(permissions.deleteUser)
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@ -240,10 +240,13 @@ export class UserService {
|
|||||||
|
|
||||||
// Reset benchmark
|
// Reset benchmark
|
||||||
user.Settings.settings.benchmark = undefined;
|
user.Settings.settings.benchmark = undefined;
|
||||||
}
|
} else if (user.subscription?.type === 'Premium') {
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.deleteOwnUser
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
KEY_TOKEN,
|
KEY_TOKEN,
|
||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} 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 { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, Validators } from '@angular/forms';
|
||||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
@ -33,8 +35,13 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
public appearancePlaceholder = $localize`Auto`;
|
public appearancePlaceholder = $localize`Auto`;
|
||||||
public baseCurrency: string;
|
public baseCurrency: string;
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
|
public deleteOwnUserForm = this.formBuilder.group({
|
||||||
|
accessToken: ['', Validators.required]
|
||||||
|
});
|
||||||
|
public hasPermissionToDeleteOwnUser: boolean;
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public isAccessTokenHidden = true;
|
||||||
public isWebAuthnEnabled: boolean;
|
public isWebAuthnEnabled: boolean;
|
||||||
public language = document.documentElement.lang;
|
public language = document.documentElement.lang;
|
||||||
public locales = [
|
public locales = [
|
||||||
@ -58,7 +65,9 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private tokenStorageService: TokenStorageService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
public webAuthnService: WebAuthnService
|
public webAuthnService: WebAuthnService
|
||||||
) {
|
) {
|
||||||
@ -73,6 +82,11 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteOwnUser = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.deleteOwnUser
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.updateUserSettings
|
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) {
|
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
||||||
|
@ -232,6 +232,55 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (hasPermissionToDeleteOwnUser) {
|
||||||
|
<hr class="mt-5" />
|
||||||
|
<form
|
||||||
|
class="w-100"
|
||||||
|
[formGroup]="deleteOwnUserForm"
|
||||||
|
(ngSubmit)="onCloseAccount()"
|
||||||
|
>
|
||||||
|
<div class="d-flex py-1">
|
||||||
|
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
|
||||||
|
<div class="pl-1 w-50">
|
||||||
|
<mat-form-field
|
||||||
|
appearance="outline"
|
||||||
|
class="without-hint w-100"
|
||||||
|
[hideRequiredMarker]="true"
|
||||||
|
>
|
||||||
|
<mat-label i18n>Security Token</mat-label>
|
||||||
|
<input
|
||||||
|
formControlName="accessToken"
|
||||||
|
matInput
|
||||||
|
[type]="isAccessTokenHidden ? 'password' : 'text'"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="isAccessTokenHidden = !isAccessTokenHidden"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
[name]="
|
||||||
|
isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
class="mt-2"
|
||||||
|
color="warn"
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="
|
||||||
|
!(deleteOwnUserForm.dirty && deleteOwnUserForm.valid)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span i18n>Close Account</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
@ -22,10 +23,12 @@ import { UserAccountSettingsComponent } from './user-account-settings.component'
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
]
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfUserAccountSettingsModule {}
|
export class GfUserAccountSettingsModule {}
|
||||||
|
@ -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 { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
|
||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-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 { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
|
||||||
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
|
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
@ -271,6 +272,10 @@ export class DataService {
|
|||||||
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
|
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteOwnUser(aData: DeleteOwnUserDto) {
|
||||||
|
return this.http.delete<any>(`/api/v1/user`, { body: aData });
|
||||||
|
}
|
||||||
|
|
||||||
public deleteUser(aId: string) {
|
public deleteUser(aId: string) {
|
||||||
return this.http.delete<any>(`/api/v1/user/${aId}`);
|
return this.http.delete<any>(`/api/v1/user/${aId}`);
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ export const permissions = {
|
|||||||
deleteAccountBalance: 'deleteAcccountBalance',
|
deleteAccountBalance: 'deleteAcccountBalance',
|
||||||
deleteAuthDevice: 'deleteAuthDevice',
|
deleteAuthDevice: 'deleteAuthDevice',
|
||||||
deleteOrder: 'deleteOrder',
|
deleteOrder: 'deleteOrder',
|
||||||
|
deleteOwnUser: 'deleteOwnUser',
|
||||||
deletePlatform: 'deletePlatform',
|
deletePlatform: 'deletePlatform',
|
||||||
deleteTag: 'deleteTag',
|
deleteTag: 'deleteTag',
|
||||||
deleteUser: 'deleteUser',
|
deleteUser: 'deleteUser',
|
||||||
@ -57,6 +58,7 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
permissions.deleteAccount,
|
permissions.deleteAccount,
|
||||||
permissions.deleteAuthDevice,
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
|
permissions.deleteOwnUser,
|
||||||
permissions.deletePlatform,
|
permissions.deletePlatform,
|
||||||
permissions.deleteTag,
|
permissions.deleteTag,
|
||||||
permissions.deleteUser,
|
permissions.deleteUser,
|
||||||
@ -84,6 +86,7 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
permissions.deleteAccountBalance,
|
permissions.deleteAccountBalance,
|
||||||
permissions.deleteAuthDevice,
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
|
permissions.deleteOwnUser,
|
||||||
permissions.updateAccount,
|
permissions.updateAccount,
|
||||||
permissions.updateAuthDevice,
|
permissions.updateAuthDevice,
|
||||||
permissions.updateOrder,
|
permissions.updateOrder,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user