mirror of
https://github.com/bitwarden/browser
synced 2026-01-09 03:53:53 +00:00
[PM-28813] Implement encryption diagnostics & recovery tool (#17673)
* Implement data recovery tool * Fix tests * Move Sdkloadservice call and use bit action
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "dataRecoveryTitle" | i18n }}</h2>
|
||||
|
||||
<div class="tw-max-w-lg">
|
||||
<p bitTypography="body1" class="tw-mb-4">
|
||||
{{ "dataRecoveryDescription" | i18n }}
|
||||
</p>
|
||||
|
||||
@if (!diagnosticsCompleted() && !recoveryCompleted()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[bitAction]="runDiagnostics"
|
||||
class="tw-mb-6"
|
||||
>
|
||||
{{ "runDiagnostics" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="tw-space-y-3 tw-mb-6">
|
||||
@for (step of steps(); track $index) {
|
||||
@if (
|
||||
($index === 0 && hasStarted()) ||
|
||||
($index > 0 &&
|
||||
(steps()[$index - 1].status === StepStatus.Completed ||
|
||||
steps()[$index - 1].status === StepStatus.Failed))
|
||||
) {
|
||||
<div class="tw-flex tw-items-start tw-gap-3">
|
||||
<div class="tw-mt-1">
|
||||
@if (step.status === StepStatus.Failed) {
|
||||
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
|
||||
} @else if (step.status === StepStatus.Completed) {
|
||||
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
|
||||
} @else if (step.status === StepStatus.InProgress) {
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
|
||||
} @else {
|
||||
<i class="bwi bwi-circle tw-text-secondary-300" aria-hidden="true"></i>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
[class.tw-text-danger]="step.status === StepStatus.Failed"
|
||||
[class.tw-text-success]="step.status === StepStatus.Completed"
|
||||
[class.tw-text-primary-600]="step.status === StepStatus.InProgress"
|
||||
[class.tw-font-semibold]="step.status === StepStatus.InProgress"
|
||||
[class.tw-text-secondary-500]="step.status === StepStatus.NotStarted"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (diagnosticsCompleted()) {
|
||||
<div class="tw-flex tw-gap-3">
|
||||
@if (hasIssues() && !recoveryCompleted()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="status() === StepStatus.InProgress"
|
||||
[bitAction]="runRecovery"
|
||||
>
|
||||
{{ "repairIssues" | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="saveDiagnosticLogs">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "saveDiagnosticLogs" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,348 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { DataRecoveryComponent, StepStatus } from "./data-recovery.component";
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./steps";
|
||||
|
||||
// Mock SdkLoadService
|
||||
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({
|
||||
SdkLoadService: {
|
||||
Ready: Promise.resolve(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("DataRecoveryComponent", () => {
|
||||
let component: DataRecoveryComponent;
|
||||
let fixture: ComponentFixture<DataRecoveryComponent>;
|
||||
|
||||
// Mock Services
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockKeyService: MockProxy<KeyService>;
|
||||
let mockFolderApiService: MockProxy<FolderApiServiceAbstraction>;
|
||||
let mockCipherEncryptService: MockProxy<CipherEncryptionService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockPrivateKeyRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let mockFileDownloadService: MockProxy<FileDownloadService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockApiService = mock<ApiService>();
|
||||
mockAccountService = mockAccountServiceWith(mockUserId);
|
||||
mockKeyService = mock<KeyService>();
|
||||
mockFolderApiService = mock<FolderApiServiceAbstraction>();
|
||||
mockCipherEncryptService = mock<CipherEncryptionService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockPrivateKeyRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockFileDownloadService = mock<FileDownloadService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DataRecoveryComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: KeyService, useValue: mockKeyService },
|
||||
{ provide: FolderApiServiceAbstraction, useValue: mockFolderApiService },
|
||||
{ provide: CipherEncryptionService, useValue: mockCipherEncryptService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{
|
||||
provide: UserAsymmetricKeysRegenerationService,
|
||||
useValue: mockPrivateKeyRegenerationService,
|
||||
},
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
|
||||
{ provide: FileDownloadService, useValue: mockFileDownloadService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DataRecoveryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with default signal values", () => {
|
||||
expect(component.status()).toBe(StepStatus.NotStarted);
|
||||
expect(component.hasStarted()).toBe(false);
|
||||
expect(component.diagnosticsCompleted()).toBe(false);
|
||||
expect(component.recoveryCompleted()).toBe(false);
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
|
||||
it("should initialize steps in correct order", () => {
|
||||
const steps = component.steps();
|
||||
expect(steps.length).toBe(5);
|
||||
expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n");
|
||||
expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n");
|
||||
expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n");
|
||||
expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n");
|
||||
expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDiagnostics", () => {
|
||||
let mockSteps: MockProxy<RecoveryStep>[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock steps
|
||||
mockSteps = Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
const mockStep = mock<RecoveryStep>();
|
||||
mockStep.title = "mockStep";
|
||||
mockStep.runDiagnostics.mockResolvedValue(true);
|
||||
mockStep.canRecover.mockReturnValue(false);
|
||||
return mockStep;
|
||||
});
|
||||
|
||||
// Replace recovery steps with mocks
|
||||
component["recoverySteps"] = mockSteps;
|
||||
});
|
||||
|
||||
it("should not run if already running", async () => {
|
||||
component["status"].set(StepStatus.InProgress);
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set hasStarted, isRunning and initialize workingData", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasStarted()).toBe(true);
|
||||
expect(component["workingData"]).toBeDefined();
|
||||
expect(component["workingData"]?.userId).toBeNull();
|
||||
expect(component["workingData"]?.userKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should run diagnostics for all steps", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalledWith(
|
||||
component["workingData"],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark steps as completed when diagnostics succeed", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
steps.forEach((step) => {
|
||||
expect(step.status).toBe(StepStatus.Completed);
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark steps as failed when diagnostics return false", async () => {
|
||||
mockSteps[2].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
expect(steps[2].status).toBe(StepStatus.Failed);
|
||||
});
|
||||
|
||||
it("should mark steps as failed when diagnostics throw error", async () => {
|
||||
mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error"));
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
const steps = component.steps();
|
||||
expect(steps[3].status).toBe(StepStatus.Failed);
|
||||
expect(steps[3].message).toBe("Test error");
|
||||
});
|
||||
|
||||
it("should continue diagnostics even if a step fails", async () => {
|
||||
mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed"));
|
||||
mockSteps[3].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
// All steps should have been called despite failures
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set hasIssues to true when a step can recover", async () => {
|
||||
mockSteps[2].runDiagnostics.mockResolvedValue(false);
|
||||
mockSteps[2].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasIssues()).toBe(true);
|
||||
});
|
||||
|
||||
it("should set hasIssues to false when no step can recover", async () => {
|
||||
mockSteps.forEach((step) => {
|
||||
step.runDiagnostics.mockResolvedValue(true);
|
||||
step.canRecover.mockReturnValue(false);
|
||||
});
|
||||
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
|
||||
it("should set diagnosticsCompleted and status to completed when complete", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.diagnosticsCompleted()).toBe(true);
|
||||
expect(component.status()).toBe(StepStatus.Completed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRecovery", () => {
|
||||
let mockSteps: MockProxy<RecoveryStep>[];
|
||||
let mockWorkingData: RecoveryWorkingData;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkingData = {
|
||||
userId: mockUserId,
|
||||
userKey: null as any,
|
||||
isPrivateKeyCorrupt: false,
|
||||
encryptedPrivateKey: null,
|
||||
ciphers: [],
|
||||
folders: [],
|
||||
};
|
||||
|
||||
mockSteps = Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
const mockStep = mock<RecoveryStep>();
|
||||
mockStep.title = "mockStep";
|
||||
mockStep.canRecover.mockReturnValue(false);
|
||||
mockStep.runRecovery.mockResolvedValue();
|
||||
mockStep.runDiagnostics.mockResolvedValue(true);
|
||||
return mockStep;
|
||||
});
|
||||
|
||||
component["recoverySteps"] = mockSteps;
|
||||
component["workingData"] = mockWorkingData;
|
||||
});
|
||||
|
||||
it("should not run if already running", async () => {
|
||||
component["status"].set(StepStatus.InProgress);
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not run if workingData is null", async () => {
|
||||
component["workingData"] = null;
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only run recovery for steps that can recover", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[3].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
|
||||
expect(mockSteps[1].runRecovery).toHaveBeenCalled();
|
||||
expect(mockSteps[2].runRecovery).not.toHaveBeenCalled();
|
||||
expect(mockSteps[3].runRecovery).toHaveBeenCalled();
|
||||
expect(mockSteps[4].runRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set recoveryCompleted and status when successful", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.recoveryCompleted()).toBe(true);
|
||||
expect(component.status()).toBe(StepStatus.Completed);
|
||||
});
|
||||
|
||||
it("should set status to failed if recovery is cancelled", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.status()).toBe(StepStatus.Failed);
|
||||
expect(component.recoveryCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it("should re-run diagnostics after recovery completes", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
// Diagnostics should be called twice: once for initial diagnostic scan
|
||||
mockSteps.forEach((step) => {
|
||||
expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it("should update hasIssues after re-running diagnostics", async () => {
|
||||
// Setup initial state with an issue
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[1].runDiagnostics.mockResolvedValue(false);
|
||||
|
||||
// After recovery completes, the issue should be fixed
|
||||
mockSteps[1].runRecovery.mockImplementation(() => {
|
||||
// Simulate recovery fixing the issue
|
||||
mockSteps[1].canRecover.mockReturnValue(false);
|
||||
mockSteps[1].runDiagnostics.mockResolvedValue(true);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
// Verify hasIssues is updated after re-running diagnostics
|
||||
expect(component.hasIssues()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveDiagnosticLogs", () => {
|
||||
it("should call fileDownloadService with log content", () => {
|
||||
component.saveDiagnosticLogs();
|
||||
|
||||
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
|
||||
fileName: expect.stringContaining("data-recovery-logs-"),
|
||||
blobData: expect.any(String),
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should include timestamp in filename", () => {
|
||||
component.saveDiagnosticLogs();
|
||||
|
||||
const downloadCall = mockFileDownloadService.download.mock.calls[0][0];
|
||||
expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { ButtonModule, DialogService } from "@bitwarden/components";
|
||||
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { LogRecorder } from "./log-recorder";
|
||||
import {
|
||||
SyncStep,
|
||||
UserInfoStep,
|
||||
RecoveryStep,
|
||||
PrivateKeyStep,
|
||||
RecoveryWorkingData,
|
||||
FolderStep,
|
||||
CipherStep,
|
||||
} from "./steps";
|
||||
|
||||
export const StepStatus = Object.freeze({
|
||||
NotStarted: 0,
|
||||
InProgress: 1,
|
||||
Completed: 2,
|
||||
Failed: 3,
|
||||
} as const);
|
||||
export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
|
||||
|
||||
interface StepState {
|
||||
title: string;
|
||||
status: StepStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-data-recovery",
|
||||
templateUrl: "data-recovery.component.html",
|
||||
standalone: true,
|
||||
imports: [JslibModule, ButtonModule, CommonModule, SharedModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DataRecoveryComponent {
|
||||
protected readonly StepStatus = StepStatus;
|
||||
|
||||
private i18nService = inject(I18nService);
|
||||
private apiService = inject(ApiService);
|
||||
private accountService = inject(AccountService);
|
||||
private keyService = inject(KeyService);
|
||||
private folderApiService = inject(FolderApiServiceAbstraction);
|
||||
private cipherEncryptService = inject(CipherEncryptionService);
|
||||
private dialogService = inject(DialogService);
|
||||
private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService);
|
||||
private cryptoFunctionService = inject(CryptoFunctionService);
|
||||
private logService = inject(LogService);
|
||||
private fileDownloadService = inject(FileDownloadService);
|
||||
|
||||
private logger: LogRecorder = new LogRecorder(this.logService);
|
||||
private recoverySteps: RecoveryStep[] = [
|
||||
new UserInfoStep(this.accountService, this.keyService),
|
||||
new SyncStep(this.apiService),
|
||||
new PrivateKeyStep(
|
||||
this.privateKeyRegenerationService,
|
||||
this.dialogService,
|
||||
this.cryptoFunctionService,
|
||||
),
|
||||
new FolderStep(this.folderApiService, this.dialogService),
|
||||
new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
|
||||
];
|
||||
private workingData: RecoveryWorkingData | null = null;
|
||||
|
||||
readonly status = signal<StepStatus>(StepStatus.NotStarted);
|
||||
readonly hasStarted = signal(false);
|
||||
readonly diagnosticsCompleted = signal(false);
|
||||
readonly recoveryCompleted = signal(false);
|
||||
readonly steps = signal<StepState[]>(
|
||||
this.recoverySteps.map((step) => ({
|
||||
title: this.i18nService.t(step.title),
|
||||
status: StepStatus.NotStarted,
|
||||
})),
|
||||
);
|
||||
readonly hasIssues = signal(false);
|
||||
|
||||
runDiagnostics = async () => {
|
||||
if (this.status() === StepStatus.InProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasStarted.set(true);
|
||||
this.status.set(StepStatus.InProgress);
|
||||
this.diagnosticsCompleted.set(false);
|
||||
|
||||
this.logger.record("Starting diagnostics...");
|
||||
this.workingData = {
|
||||
userId: null,
|
||||
userKey: null,
|
||||
isPrivateKeyCorrupt: false,
|
||||
encryptedPrivateKey: null,
|
||||
ciphers: [],
|
||||
folders: [],
|
||||
};
|
||||
|
||||
await this.runDiagnosticsInternal();
|
||||
|
||||
this.status.set(StepStatus.Completed);
|
||||
this.diagnosticsCompleted.set(true);
|
||||
};
|
||||
|
||||
private async runDiagnosticsInternal() {
|
||||
if (!this.workingData) {
|
||||
this.logger.record("No working data available");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSteps = this.steps();
|
||||
let hasAnyFailures = false;
|
||||
|
||||
for (let i = 0; i < this.recoverySteps.length; i++) {
|
||||
const step = this.recoverySteps[i];
|
||||
currentSteps[i].status = StepStatus.InProgress;
|
||||
this.steps.set([...currentSteps]);
|
||||
|
||||
this.logger.record(`Running diagnostics for step: ${step.title}`);
|
||||
try {
|
||||
const success = await step.runDiagnostics(this.workingData, this.logger);
|
||||
currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed;
|
||||
if (!success) {
|
||||
hasAnyFailures = true;
|
||||
}
|
||||
this.steps.set([...currentSteps]);
|
||||
this.logger.record(`Diagnostics completed for step: ${step.title}`);
|
||||
} catch (error) {
|
||||
currentSteps[i].status = StepStatus.Failed;
|
||||
currentSteps[i].message = (error as Error).message;
|
||||
this.steps.set([...currentSteps]);
|
||||
this.logger.record(
|
||||
`Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`,
|
||||
);
|
||||
hasAnyFailures = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyFailures) {
|
||||
this.logger.record("Diagnostics completed with errors");
|
||||
} else {
|
||||
this.logger.record("Diagnostics completed successfully");
|
||||
}
|
||||
|
||||
// Check if any recovery can be performed
|
||||
const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!));
|
||||
this.hasIssues.set(canRecoverAnyStep);
|
||||
}
|
||||
|
||||
runRecovery = async () => {
|
||||
if (this.status() === StepStatus.InProgress || !this.workingData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set(StepStatus.InProgress);
|
||||
this.recoveryCompleted.set(false);
|
||||
|
||||
this.logger.record("Starting recovery process...");
|
||||
|
||||
try {
|
||||
for (let i = 0; i < this.recoverySteps.length; i++) {
|
||||
const step = this.recoverySteps[i];
|
||||
if (step.canRecover(this.workingData)) {
|
||||
this.logger.record(`Running recovery for step: ${step.title}`);
|
||||
await step.runRecovery(this.workingData, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.record("Recovery process completed");
|
||||
this.recoveryCompleted.set(true);
|
||||
|
||||
// Re-run diagnostics after recovery
|
||||
this.logger.record("Re-running diagnostics to verify recovery...");
|
||||
await this.runDiagnosticsInternal();
|
||||
|
||||
this.status.set(StepStatus.Completed);
|
||||
} catch (error) {
|
||||
this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
|
||||
this.status.set(StepStatus.Failed);
|
||||
}
|
||||
};
|
||||
|
||||
saveDiagnosticLogs = () => {
|
||||
const logs = this.logger.getLogs();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `data-recovery-logs-${timestamp}.txt`;
|
||||
|
||||
const logContent = logs.join("\n");
|
||||
this.fileDownloadService.download({
|
||||
fileName: filename,
|
||||
blobData: logContent,
|
||||
blobOptions: { type: "text/plain" },
|
||||
});
|
||||
|
||||
this.logger.record("Diagnostic logs saved");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
/**
|
||||
* Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere.
|
||||
*/
|
||||
export class LogRecorder {
|
||||
private logs: string[] = [];
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
record(message: string) {
|
||||
this.logs.push(message);
|
||||
this.logService.info(`[DataRecovery] ${message}`);
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class CipherStep implements RecoveryStep {
|
||||
title = "recoveryStepCipherTitle";
|
||||
|
||||
private undecryptableCipherIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cipherService: CipherEncryptionService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userId) {
|
||||
logger.record("Missing user ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.undecryptableCipherIds = [];
|
||||
for (const cipher of workingData.ciphers) {
|
||||
try {
|
||||
await this.cipherService.decrypt(cipher, workingData.userId);
|
||||
} catch {
|
||||
logger.record(`Cipher ID ${cipher.id} was undecryptable`);
|
||||
this.undecryptableCipherIds.push(cipher.id);
|
||||
}
|
||||
}
|
||||
logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`);
|
||||
|
||||
return this.undecryptableCipherIds.length == 0;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return this.undecryptableCipherIds.length > 0;
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// Recovery means deleting the broken ciphers.
|
||||
if (this.undecryptableCipherIds.length === 0) {
|
||||
logger.record("No undecryptable ciphers to recover");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryDeleteCiphersTitle" },
|
||||
content: { key: "recoveryDeleteCiphersDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled cipher deletion");
|
||||
throw new Error("Cipher recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`);
|
||||
|
||||
for (const cipherId of this.undecryptableCipherIds) {
|
||||
try {
|
||||
await this.apiService.deleteCipher(cipherId);
|
||||
logger.record(`Deleted cipher ${cipherId}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class FolderStep implements RecoveryStep {
|
||||
title = "recoveryStepFoldersTitle";
|
||||
|
||||
private undecryptableFolderIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private folderService: FolderApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userKey) {
|
||||
logger.record("Missing user key");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.undecryptableFolderIds = [];
|
||||
for (const folder of workingData.folders) {
|
||||
if (!folder.name?.encryptedString) {
|
||||
logger.record(`Folder ID ${folder.id} has no name`);
|
||||
this.undecryptableFolderIds.push(folder.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await SdkLoadService.Ready;
|
||||
PureCrypto.symmetric_decrypt_string(
|
||||
folder.name.encryptedString,
|
||||
workingData.userKey.toEncoded(),
|
||||
);
|
||||
} catch {
|
||||
logger.record(`Folder name for folder ID ${folder.id} was undecryptable`);
|
||||
this.undecryptableFolderIds.push(folder.id);
|
||||
}
|
||||
}
|
||||
logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`);
|
||||
|
||||
return this.undecryptableFolderIds.length == 0;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return this.undecryptableFolderIds.length > 0;
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// Recovery means deleting the broken folders.
|
||||
if (this.undecryptableFolderIds.length === 0) {
|
||||
logger.record("No undecryptable folders to recover");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workingData.userId) {
|
||||
logger.record("Missing user ID");
|
||||
throw new Error("Missing user ID");
|
||||
}
|
||||
|
||||
logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`);
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryDeleteFoldersTitle" },
|
||||
content: { key: "recoveryDeleteFoldersDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled folder deletion");
|
||||
throw new Error("Folder recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`);
|
||||
|
||||
for (const folderId of this.undecryptableFolderIds) {
|
||||
try {
|
||||
await this.folderService.delete(folderId, workingData.userId);
|
||||
logger.record(`Deleted folder ${folderId}`);
|
||||
} catch (error) {
|
||||
logger.record(`Failed to delete folder ${folderId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`);
|
||||
}
|
||||
|
||||
getUndecryptableFolderIds(): string[] {
|
||||
return this.undecryptableFolderIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./sync-step";
|
||||
export * from "./user-info-step";
|
||||
export * from "./recovery-step";
|
||||
export * from "./private-key-step";
|
||||
export * from "./folder-step";
|
||||
export * from "./cipher-step";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class PrivateKeyStep implements RecoveryStep {
|
||||
title = "recoveryStepPrivateKeyTitle";
|
||||
|
||||
constructor(
|
||||
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private dialogService: DialogService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
if (!workingData.userId || !workingData.userKey) {
|
||||
logger.record("Missing user ID or user key");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation.
|
||||
const encryptedPrivateKey = workingData.encryptedPrivateKey;
|
||||
if (!encryptedPrivateKey) {
|
||||
logger.record("No encrypted private key found");
|
||||
return false;
|
||||
}
|
||||
logger.record("Private key length: " + encryptedPrivateKey.length);
|
||||
let privateKey: Uint8Array;
|
||||
try {
|
||||
await SdkLoadService.Ready;
|
||||
privateKey = PureCrypto.unwrap_decapsulation_key(
|
||||
encryptedPrivateKey,
|
||||
workingData.userKey.toEncoded(),
|
||||
);
|
||||
} catch {
|
||||
logger.record("Private key was un-decryptable");
|
||||
workingData.isPrivateKeyCorrupt = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding.
|
||||
try {
|
||||
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
logger.record("Public key length: " + publicKey.length);
|
||||
} catch {
|
||||
logger.record("Public key could not be derived; private key is corrupt");
|
||||
workingData.isPrivateKeyCorrupt = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
// Only support recovery on V1 users.
|
||||
return (
|
||||
workingData.isPrivateKeyCorrupt &&
|
||||
workingData.userKey !== null &&
|
||||
workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64
|
||||
);
|
||||
}
|
||||
|
||||
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
// The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization.
|
||||
// This is because this will break emergency access enrollments / organization memberships / provider memberships.
|
||||
logger.record("Showing confirmation dialog for private key replacement");
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "recoveryReplacePrivateKeyTitle" },
|
||||
content: { key: "recoveryReplacePrivateKeyDesc" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
logger.record("User cancelled private key replacement");
|
||||
throw new Error("Private key recovery cancelled by user");
|
||||
}
|
||||
|
||||
logger.record("Replacing private key");
|
||||
await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair(
|
||||
workingData.userId!,
|
||||
);
|
||||
logger.record("Private key replaced successfully");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
/**
|
||||
* A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers.
|
||||
*/
|
||||
export abstract class RecoveryStep {
|
||||
/** Title of the recovery step, as an i18n key. */
|
||||
abstract title: string;
|
||||
|
||||
/**
|
||||
* Runs diagnostics on the provided working data.
|
||||
* Returns true if no issues were found, false otherwise.
|
||||
*/
|
||||
abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns whether recovery can be performed
|
||||
*/
|
||||
abstract canRecover(workingData: RecoveryWorkingData): boolean;
|
||||
|
||||
/**
|
||||
* Performs recovery on the provided working data.
|
||||
*/
|
||||
abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data used during the recovery process, passed between steps.
|
||||
*/
|
||||
export type RecoveryWorkingData = {
|
||||
userId: UserId | null;
|
||||
userKey: UserKey | null;
|
||||
encryptedPrivateKey: WrappedPrivateKey | null;
|
||||
isPrivateKeyCorrupt: boolean;
|
||||
ciphers: Cipher[];
|
||||
folders: Folder[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { FolderData } from "@bitwarden/common/vault/models/data/folder.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class SyncStep implements RecoveryStep {
|
||||
title = "recoveryStepSyncTitle";
|
||||
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
// The intent of this step is to fetch the latest data from the server. Diagnostics does not
|
||||
// ever run on local data but only remote data that is recent.
|
||||
const response = await this.apiService.getSync();
|
||||
|
||||
workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c)));
|
||||
logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`);
|
||||
|
||||
workingData.folders = response.folders.map((f) => new Folder(new FolderData(f)));
|
||||
logger.record(`Fetched ${workingData.folders.length} folders from server`);
|
||||
|
||||
workingData.encryptedPrivateKey =
|
||||
response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null;
|
||||
logger.record(
|
||||
`Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { LogRecorder } from "../log-recorder";
|
||||
|
||||
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
|
||||
|
||||
export class UserInfoStep implements RecoveryStep {
|
||||
title = "recoveryStepUserInfoTitle";
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
logger.record("No active account found");
|
||||
return false;
|
||||
}
|
||||
const userId = activeAccount.id;
|
||||
workingData.userId = userId;
|
||||
logger.record(`User ID: ${userId}`);
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (!userKey) {
|
||||
logger.record("No user key found");
|
||||
return false;
|
||||
}
|
||||
workingData.userKey = userKey;
|
||||
logger.record(
|
||||
`User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canRecover(workingData: RecoveryWorkingData): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
|
||||
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
|
||||
import { RouteDataProperties } from "./core";
|
||||
import { ReportsModule } from "./dirt/reports";
|
||||
import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component";
|
||||
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
|
||||
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
|
||||
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
@@ -696,6 +697,12 @@ const routes: Routes = [
|
||||
path: "security",
|
||||
loadChildren: () => SecurityRoutingModule,
|
||||
},
|
||||
{
|
||||
path: "data-recovery",
|
||||
component: DataRecoveryComponent,
|
||||
canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)],
|
||||
data: { titleId: "dataRecovery" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "domain-rules",
|
||||
component: DomainRulesComponent,
|
||||
|
||||
Reference in New Issue
Block a user