[EC-317] Desktop client delete user account (#3151)

* [EC-317] feat: add delete account section in settings

* [EC-317] feat: add new delete account modal

* [EC-317] feat: add ability to replace top-most modal

* [EC-317] chore: remove unecessary lint ignore

* [EC-317] fix: so delete account is closed if export vault is opened

* [EC-317] feat: inital delete account design without i18n

* [EC-317] feat: disabled but basic working delete functionality

* [EC-317] feat: implement according to new design

* [EC-317] feat: use translations

* [EC-317] feat: implement working deletion

* [EC-317] feat: add loading state and error messages

* [EC-317] feat: add menu bar item

* [EC-317] feat: update form to support typed reactive forms

* [EC-317] chore: update translation text after design review

* [EC-317] feat: move deletion logic to service

* [EC-317] refactor: update web deletion

* [EC-317] feat: disable submit if secret is empty

* [EC-317] fix: handle errors in components as well

* [EC-317] fix: use abstraction as interface

* [EC-317] refactor: extract deleteAccount from api service

* [EC-317] fix: typo in translations

* [EC-317] chore: rename to accountApiService
This commit is contained in:
Andreas Coroiu 2022-07-29 21:49:58 +02:00 committed by GitHub
parent cc91b79a15
commit a22ef4d36c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 264 additions and 48 deletions

View File

@ -0,0 +1,38 @@
<div class="modal fade" role="dialog" aria-modal="true" attr.aria-label="{{ 'settings' | i18n }}">
<div class="modal-dialog" role="document">
<form
class="modal-content"
#form
[appApiAction]="formPromise"
(ngSubmit)="submit()"
[formGroup]="deleteForm"
>
<div class="modal-body">
<p class="modal-text">{{ "deleteAccountDesc" | i18n }}</p>
<app-callout type="warning" title="{{ 'warning' | i18n }}">
{{ "deleteAccountWarning" | i18n }}
</app-callout>
<div class="box last">
<div class="box-header">{{ "deleteAccount" | i18n }}</div>
<div class="box-content">
<app-user-verification
ngDefaultControl
formControlName="verification"
name="verification"
>
</app-user-verification>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="danger" [disabled]="form.loading || !secret">
<i class="bwi bwi-spinner bwi-spin" [hidden]="!form.loading" aria-hidden="true"></i>
<span [hidden]="form.loading">{{ "deleteAccount" | i18n }}</span>
</button>
<button type="button" data-dismiss="modal" [disabled]="form.loading">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,48 @@
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { AccountService } from "@bitwarden/common/abstractions/account/account.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { Verification } from "../../../../../libs/common/src/types/verification";
@Component({
selector: "app-delete-account",
templateUrl: "delete-account.component.html",
})
export class DeleteAccountComponent {
formPromise: Promise<void>;
deleteForm = this.formBuilder.group({
verification: undefined as Verification | undefined,
});
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder,
private accountService: AccountService,
private logService: LogService
) {}
get secret() {
return this.deleteForm.get("verification")?.value?.secret;
}
async submit() {
try {
const verification = this.deleteForm.get("verification").value;
this.formPromise = this.accountService.delete(verification);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
this.i18nService.t("accountDeleted"),
this.i18nService.t("accountDeletedDesc")
);
} catch (e) {
this.logService.error(e);
}
}
}

View File

@ -108,6 +108,14 @@
</label>
</div>
</div>
<div class="form-group">
<label>{{ "deleteAccount" | i18n }}</label>
<small class="help-block">
{{ "deleteAccountDesc" | i18n }}
<a (click)="openDeleteAccount()">{{ "deleteAccount" | i18n }}</a>
</small>
</div>
</ng-container>
</div>
</div>

View File

@ -18,6 +18,8 @@ import { isWindowsStore } from "@bitwarden/electron/utils";
import { SetPinComponent } from "../components/set-pin.component";
import { DeleteAccountComponent } from "./delete-account.component";
@Component({
selector: "app-settings",
templateUrl: "settings.component.html",
@ -437,4 +439,8 @@ export class SettingsComponent implements OnInit {
this.enableBrowserIntegrationFingerprint
);
}
async openDeleteAccount() {
this.modalService.open(DeleteAccountComponent, { replaceTopModal: true });
}
}

View File

@ -40,6 +40,7 @@ import { CipherType } from "@bitwarden/common/enums/cipherType";
import { MenuUpdateRequest } from "../main/menu/menu.updater";
import { DeleteAccountComponent } from "./accounts/delete-account.component";
import { PremiumComponent } from "./accounts/premium.component";
import { SettingsComponent } from "./accounts/settings.component";
import { ExportComponent } from "./vault/export.component";
@ -153,9 +154,7 @@ export class AppComponent implements OnInit {
this.systemService.cancelProcessReload();
break;
case "loggedOut":
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
this.notificationsService.updateConnection();
this.updateAppMenu();
await this.systemService.clearPendingClipboard();
@ -180,9 +179,7 @@ export class AppComponent implements OnInit {
}
break;
case "locked":
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
if (
message.userId == null ||
message.userId === (await this.stateService.getUserId())
@ -223,6 +220,9 @@ export class AppComponent implements OnInit {
}
break;
}
case "deleteAccount":
this.modalService.open(DeleteAccountComponent, { replaceTopModal: true });
break;
case "openPasswordHistory":
await this.openModal<PasswordGeneratorHistoryComponent>(
PasswordGeneratorHistoryComponent,
@ -368,9 +368,7 @@ export class AppComponent implements OnInit {
}
async openExportVault() {
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
const [modal, childComponent] = await this.modalService.openViewRef(
ExportComponent,
@ -388,9 +386,7 @@ export class AppComponent implements OnInit {
}
async addFolder() {
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
const [modal, childComponent] = await this.modalService.openViewRef(
FolderAddEditComponent,
@ -410,9 +406,7 @@ export class AppComponent implements OnInit {
}
async openGenerator() {
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
[this.modal] = await this.modalService.openViewRef(
GeneratorComponent,
@ -542,9 +536,7 @@ export class AppComponent implements OnInit {
}
private async openModal<T>(type: Type<T>, ref: ViewContainerRef) {
if (this.modal != null) {
this.modal.close();
}
this.modalService.closeAll();
[this.modal] = await this.modalService.openViewRef(type, ref);

View File

@ -57,6 +57,7 @@ import localeZhTw from "@angular/common/locales/zh-Hant";
import { NgModule } from "@angular/core";
import { AccessibilityCookieComponent } from "./accounts/accessibility-cookie.component";
import { DeleteAccountComponent } from "./accounts/delete-account.component";
import { EnvironmentComponent } from "./accounts/environment.component";
import { HintComponent } from "./accounts/hint.component";
import { LockComponent } from "./accounts/lock.component";
@ -165,6 +166,7 @@ registerLocaleData(localeZhTw, "zh-TW");
AttachmentsComponent,
CiphersComponent,
CollectionsComponent,
DeleteAccountComponent,
EnvironmentComponent,
ExportComponent,
FolderAddEditComponent,

View File

@ -1393,6 +1393,21 @@
"lockWithMasterPassOnRestart": {
"message": "Lock with master password on restart"
},
"deleteAccount": {
"message": "Delete account"
},
"deleteAccountDesc": {
"message": "Proceed below to delete your account and all vault data."
},
"deleteAccountWarning": {
"message": "Deleting your account is permanent. It cannot be undone."
},
"accountDeleted": {
"message": "Account deleted"
},
"accountDeletedDesc": {
"message": "Your account has been closed and all associated data has been deleted."
},
"preferences": {
"message": "Preferences"
},
@ -1975,5 +1990,5 @@
},
"cardBrandMir": {
"message": "Mir"
}
}
}

View File

@ -19,6 +19,8 @@ export class AccountMenu implements IMenubarMenu {
this.changeMasterPassword,
this.twoStepLogin,
this.fingerprintPhrase,
this.separator,
this.deleteAccount,
];
}
@ -105,6 +107,19 @@ export class AccountMenu implements IMenubarMenu {
};
}
private get deleteAccount(): MenuItemConstructorOptions {
return {
label: this.localize("deleteAccount"),
id: "deleteAccount",
click: () => this.sendMessage("deleteAccount"),
enabled: !this._isLocked,
};
}
private get separator(): MenuItemConstructorOptions {
return { type: "separator" };
}
private localize(s: string) {
return this._i18nService.t(s);
}

View File

@ -102,6 +102,10 @@
color: themed("mutedColor");
}
}
&.last {
margin-bottom: 15px;
}
}
.box-content-row {

View File

@ -336,6 +336,25 @@ form,
@include themify($themes) {
color: themed("mutedColor");
}
a {
@extend .btn;
@extend .link;
padding: 0;
font-size: inherit;
font-weight: bold;
@include themify($themes) {
color: themed("mutedColor");
}
&:hover {
@include themify($themes) {
color: darken(themed("mutedColor"), 6%);
}
}
}
}
}

View File

@ -71,10 +71,6 @@
.box {
margin-bottom: 20px;
&.last {
margin-bottom: 15px;
}
}
.buttons {

View File

@ -6,6 +6,7 @@
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
[formGroup]="deleteForm"
>
<div class="modal-header">
<h2 class="modal-title" id="deleteAccountTitle">{{ "deleteAccount" | i18n }}</h2>
@ -21,7 +22,7 @@
<div class="modal-body">
<p>{{ "deleteAccountDesc" | i18n }}</p>
<app-callout type="warning">{{ "deleteAccountWarning" | i18n }}</app-callout>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
<app-user-verification ngDefaultControl formControlName="verification" name="verification">
</app-user-verification>
</div>
<div class="modal-footer">

View File

@ -1,11 +1,10 @@
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/abstractions/account/account.service.abstraction";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification.service";
import { Verification } from "@bitwarden/common/types/verification";
@Component({
@ -13,30 +12,30 @@ import { Verification } from "@bitwarden/common/types/verification";
templateUrl: "delete-account.component.html",
})
export class DeleteAccountComponent {
masterPassword: Verification;
formPromise: Promise<any>;
formPromise: Promise<void>;
deleteForm = this.formBuilder.group({
verification: undefined as Verification | undefined,
});
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private userVerificationService: UserVerificationService,
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private accountService: AccountService,
private logService: LogService
) {}
async submit() {
try {
this.formPromise = this.userVerificationService
.buildRequest(this.masterPassword)
.then((request) => this.apiService.deleteAccount(request));
const verification = this.deleteForm.get("verification").value;
this.formPromise = this.accountService.delete(verification);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
this.i18nService.t("accountDeleted"),
this.i18nService.t("accountDeletedDesc")
);
this.messagingService.send("logout");
} catch (e) {
this.logService.error(e);
}

View File

@ -1088,7 +1088,7 @@
"message": "Account Deleted"
},
"accountDeletedDesc": {
"message": "Your Bitwarden account and vault data were permanently deleted."
"message": "Your account has been closed and all associated data has been deleted."
},
"myAccount": {
"message": "My Account"

View File

@ -5,6 +5,7 @@ import { ControlValueAccessor, UntypedFormControl, NG_VALUE_ACCESSOR } from "@an
import { KeyConnectorService } from "@bitwarden/common/abstractions/keyConnector.service";
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification.service";
import { VerificationType } from "@bitwarden/common/enums/verificationType";
import { Utils } from "@bitwarden/common/misc/utils";
import { Verification } from "@bitwarden/common/types/verification";
/**
@ -90,7 +91,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit {
this.onChange({
type: this.usesKeyConnector ? VerificationType.OTP : VerificationType.MasterPassword,
secret: secret,
secret: Utils.isNullOrWhitespace(secret) ? null : secret,
});
}
}

View File

@ -3,6 +3,8 @@ import { InjectionToken, Injector, LOCALE_ID, NgModule } from "@angular/core";
import { ThemingService } from "@bitwarden/angular/services/theming/theming.service";
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service.abstraction";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/abstractions/account/account.service.abstraction";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -49,6 +51,8 @@ import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarde
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { Account } from "@bitwarden/common/models/domain/account";
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
import { AccountApiService } from "@bitwarden/common/services/account/account-api.service";
import { AccountService } from "@bitwarden/common/services/account/account.service";
import { ApiService } from "@bitwarden/common/services/api.service";
import { AppIdService } from "@bitwarden/common/services/appId.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
@ -234,6 +238,21 @@ export const LOG_MAC_FAILURES = new InjectionToken<string>("LOG_MAC_FAILURES");
useClass: FolderApiService,
deps: [FolderServiceAbstraction, ApiServiceAbstraction],
},
{
provide: AccountApiServiceAbstraction,
useClass: AccountApiService,
deps: [ApiServiceAbstraction],
},
{
provide: AccountServiceAbstraction,
useClass: AccountService,
deps: [
AccountApiServiceAbstraction,
UserVerificationServiceAbstraction,
MessagingServiceAbstraction,
LogService,
],
},
{ provide: LogService, useFactory: () => new ConsoleLogService(false) },
{
provide: CollectionServiceAbstraction,

View File

@ -17,7 +17,8 @@ import { ModalRef } from "../components/modal/modal.ref";
export class ModalConfig<D = any> {
data?: D;
allowMultipleModals = false;
allowMultipleModals?: boolean;
replaceTopModal?: boolean;
}
@Injectable()
@ -63,13 +64,18 @@ export class ModalService {
return [modalRef, modalComponentRef.instance.componentRef.instance];
}
open(componentType: Type<any>, config?: ModalConfig) {
if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) {
open(componentType: Type<any>, config: ModalConfig = {}) {
const { replaceTopModal = false, allowMultipleModals = false } = config;
if (this.modalCount > 0 && replaceTopModal) {
this.topModal.instance.close();
}
if (this.modalCount > 0 && !allowMultipleModals) {
return;
}
// eslint-disable-next-line
const [modalRef, _] = this.openInternal(componentType, config, true);
const [modalRef] = this.openInternal(componentType, config, true);
return modalRef;
}
@ -89,6 +95,10 @@ export class ModalService {
return this.componentFactoryResolver.resolveComponentFactory(componentType);
}
closeAll(): void {
this.modalList.forEach((modal) => modal.instance.close());
}
protected openInternal(
componentType: Type<any>,
config?: ModalConfig,

View File

@ -0,0 +1,5 @@
import { SecretVerificationRequest } from "@bitwarden/common/models/request/secretVerificationRequest";
export abstract class AccountApiService {
abstract deleteAccount(request: SecretVerificationRequest): Promise<void>;
}

View File

@ -0,0 +1,5 @@
import { Verification } from "../../types/verification";
export abstract class AccountService {
abstract delete(verification: Verification): Promise<any>;
}

View File

@ -209,7 +209,6 @@ export abstract class ApiService {
setPassword: (request: SetPasswordRequest) => Promise<any>;
postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>;
postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>;
deleteAccount: (request: SecretVerificationRequest) => Promise<any>;
getAccountRevisionDate: () => Promise<number>;
postPasswordHint: (request: PasswordHintRequest) => Promise<any>;
postRegister: (request: RegisterRequest) => Promise<any>;

View File

@ -0,0 +1,11 @@
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service.abstraction";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SecretVerificationRequest } from "@bitwarden/common/models/request/secretVerificationRequest";
export class AccountApiService implements AccountApiServiceAbstraction {
constructor(private apiService: ApiService) {}
deleteAccount(request: SecretVerificationRequest): Promise<void> {
return this.apiService.send("DELETE", "/accounts", request, true, false);
}
}

View File

@ -0,0 +1,27 @@
import { AccountApiService } from "@bitwarden/common/abstractions/account/account-api.service.abstraction";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification.service";
import { AccountService as AccountServiceAbstraction } from "../../abstractions/account/account.service.abstraction";
import { Verification } from "../../types/verification";
export class AccountService implements AccountServiceAbstraction {
constructor(
private accountApiService: AccountApiService,
private userVerificationService: UserVerificationService,
private messagingService: MessagingService,
private logService: LogService
) {}
async delete(verification: Verification): Promise<any> {
try {
const verificationRequest = await this.userVerificationService.buildRequest(verification);
await this.accountApiService.deleteAccount(verificationRequest);
this.messagingService.send("logout");
} catch (e) {
this.logService.error(e);
throw e;
}
}
}

View File

@ -350,10 +350,6 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("POST", "/accounts/security-stamp", request, true, false);
}
deleteAccount(request: SecretVerificationRequest): Promise<any> {
return this.send("DELETE", "/accounts", request, true, false);
}
async getAccountRevisionDate(): Promise<number> {
const r = await this.send("GET", "/accounts/revision-date", null, true, true);
return r as number;