Browse Source

Close own account with third-party authentication provider

pull/7450/head
Thomas Kaul 1 month ago
parent
commit
1043e37ad0
  1. 25
      apps/api/src/app/user/user.controller.ts
  2. 8
      apps/api/src/app/user/user.service.ts
  3. 37
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  4. 32
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  5. 5
      libs/common/src/lib/dtos/delete-own-user.dto.ts
  6. 3
      libs/common/src/lib/interfaces/user.interface.ts

25
apps/api/src/app/user/user.controller.ts

@ -67,13 +67,14 @@ export class UserController {
public async deleteOwnUser( public async deleteOwnUser(
@Body() data: DeleteOwnUserDto @Body() data: DeleteOwnUserDto
): Promise<UserModel> { ): Promise<UserModel> {
const user = await this.validateAccessToken( if (this.request.user.provider === 'ANONYMOUS') {
data.accessToken, await this.validateAccessToken(data.accessToken, this.request.user.id);
this.request.user.id } else {
); await this.validateUserHasNoData(this.request.user.id);
}
return this.userService.deleteUser({ return this.userService.deleteUser({
id: user.id id: this.request.user.id
}); });
} }
@ -243,4 +244,18 @@ export class UserController {
return user; 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
);
}
}
} }

8
apps/api/src/app/user/user.service.ts

@ -110,7 +110,7 @@ export class UserService {
locale?: string; locale?: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<IUser> { }): Promise<IUser> {
const { id, permissions, settings, subscription } = user; const { id, permissions, provider, settings, subscription } = user;
const [ const [
access, access,
@ -193,6 +193,7 @@ export class UserService {
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
provider,
referralPartners, referralPartners,
subscription, subscription,
systemMessage, systemMessage,
@ -462,8 +463,11 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (user.provider === 'ANONYMOUS') { if (!hasRole(user, Role.DEMO)) {
currentPermissions.push(permissions.deleteOwnUser); currentPermissions.push(permissions.deleteOwnUser);
}
if (user.provider === 'ANONYMOUS') {
currentPermissions.push(permissions.updateOwnAccessToken); currentPermissions.push(permissions.updateOwnAccessToken);
} }

37
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'; } from '@ghostfolio/client/services/settings-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 { 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 { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
@ -83,6 +86,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateViewMode: boolean;
protected hasPermissionToUpdateUserSettings: boolean; protected hasPermissionToUpdateUserSettings: boolean;
protected isAccessTokenHidden = true; protected isAccessTokenHidden = true;
protected isAnonymousAuthenticationProvider: boolean;
protected readonly isFingerprintSupported = this.doesBrowserSupportAuthn(); protected readonly isFingerprintSupported = this.doesBrowserSupportAuthn();
protected isWebAuthnEnabled: boolean; protected isWebAuthnEnabled: boolean;
protected readonly language = document.documentElement.lang; protected readonly language = document.documentElement.lang;
@ -104,6 +108,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
'uk', 'uk',
'zh' 'zh'
]; ];
protected mustRemoveDataBeforeClosingAccount: boolean;
protected user: User; protected user: User;
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -127,6 +132,14 @@ export class GfUserAccountSettingsComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = 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=${[ this.closeUserAccountMailHref = `mailto:hi@ghostfol.io?subject=Delete Account&body=${[
'Hello', 'Hello',
'', '',
@ -134,7 +147,11 @@ export class GfUserAccountSettingsComponent implements OnInit {
'', '',
`User ID: ${this.user.id}`, `User ID: ${this.user.id}`,
'', '',
'Kind regards' 'Kind regards',
'',
'',
'---',
userDetailUrl
].join(E_MAIL_LINE_BREAK)}`; ].join(E_MAIL_LINE_BREAK)}`;
this.hasPermissionToDeleteOwnUser = hasPermission( this.hasPermissionToDeleteOwnUser = hasPermission(
@ -147,6 +164,14 @@ export class GfUserAccountSettingsComponent implements OnInit {
permissions.requestOwnUserDeletion 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.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions, this.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
@ -207,12 +232,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
.deleteOwnUser({ .deleteOwnUser({
accessToken: this.deleteOwnUserForm.controls.accessToken.value accessToken: this.isAnonymousAuthenticationProvider
? this.deleteOwnUserForm.controls.accessToken.value
: undefined
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
this.notificationService.alert({ 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; return EMPTY;

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

@ -292,7 +292,26 @@
<div class="d-flex py-1"> <div class="d-flex py-1">
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div> <div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
@if (hasPermissionToDeleteOwnUser) { @if (mustRemoveDataBeforeClosingAccount) {
<button color="warn" i18n mat-flat-button [disabled]="true">
Close Account
</button>
<div class="hint-text mt-2 text-muted" i18n>
For security reasons, please delete all activities and accounts
first before your Ghostfolio account can be closed.
</div>
} @else if (
hasPermissionToDeleteOwnUser && !isAnonymousAuthenticationProvider
) {
<button
color="warn"
i18n
mat-flat-button
(click)="onCloseAccount()"
>
Close Account
</button>
} @else if (hasPermissionToDeleteOwnUser) {
<form <form
class="w-100" class="w-100"
[formGroup]="deleteOwnUserForm" [formGroup]="deleteOwnUserForm"
@ -337,17 +356,6 @@
Close Account Close Account
</button> </button>
</form> </form>
} @else if (
hasPermissionToRequestOwnUserDeletion &&
(user?.accounts?.length > 0 || user?.activitiesCount > 0)
) {
<button color="warn" i18n mat-flat-button [disabled]="true">
Close Account
</button>
<div class="hint-text mt-2 text-muted" i18n>
For security reasons, please delete all activities and accounts
first before your Ghostfolio account can be closed.
</div>
} @else if (hasPermissionToRequestOwnUserDeletion) { } @else if (hasPermissionToRequestOwnUserDeletion) {
<a <a
color="warn" color="warn"

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 { export class DeleteOwnUserDto {
@IsOptional()
@IsString() @IsString()
accessToken: string; accessToken?: string;
} }

3
libs/common/src/lib/interfaces/user.interface.ts

@ -1,7 +1,7 @@
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { AccountWithPlatform } from '@ghostfolio/common/types'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Access, Tag } from '@prisma/client'; import { Access, Provider, Tag } from '@prisma/client';
import { ReferralPartner } from './referral-partner.interface'; import { ReferralPartner } from './referral-partner.interface';
import { SubscriptionOffer } from './subscription-offer.interface'; import { SubscriptionOffer } from './subscription-offer.interface';
@ -16,6 +16,7 @@ export interface User {
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
id: string; id: string;
permissions: string[]; permissions: string[];
provider: Provider;
referralPartners?: ReferralPartner[]; referralPartners?: ReferralPartner[];
settings: UserSettings; settings: UserSettings;
systemMessage?: SystemMessage; systemMessage?: SystemMessage;

Loading…
Cancel
Save