mirror of
https://github.com/bitwarden/browser.git
synced 2025-12-05 09:14:28 +01:00
Merge 9be811eb3e into d32365fbba
This commit is contained in:
commit
b8def5251d
@ -0,0 +1,84 @@
|
||||
<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"
|
||||
[disabled]="isRunning()"
|
||||
(click)="runDiagnostics()"
|
||||
class="tw-mb-6"
|
||||
>
|
||||
@if (isRunning()) {
|
||||
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i>
|
||||
{{ "runningDiagnostics" | i18n }}
|
||||
} @else {
|
||||
{{ "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].completed || steps()[$index - 1].failed))
|
||||
) {
|
||||
<div class="tw-flex tw-items-start tw-gap-3">
|
||||
<div class="tw-mt-1">
|
||||
@if (step.failed) {
|
||||
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
|
||||
} @else if (step.completed) {
|
||||
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
|
||||
} @else if (step.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.failed"
|
||||
[class.tw-text-success]="step.completed"
|
||||
[class.tw-text-primary-600]="step.inProgress"
|
||||
[class.tw-font-semibold]="step.inProgress"
|
||||
[class.tw-text-secondary-500]="!step.completed && !step.inProgress && !step.failed"
|
||||
>
|
||||
{{ 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]="isRunning()"
|
||||
(click)="runRecovery()"
|
||||
>
|
||||
@if (isRunning()) {
|
||||
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i>
|
||||
{{ "repairing" | i18n }}
|
||||
} @else {
|
||||
{{ "repairIssues" | i18n }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button type="button" bitButton buttonType="secondary" (click)="saveDiagnosticLogs()">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "saveDiagnosticLogs" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@ -0,0 +1,356 @@
|
||||
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 { 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 } 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>;
|
||||
|
||||
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>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
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 },
|
||||
],
|
||||
}).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.isRunning()).toBe(false);
|
||||
expect(component.isCompleted()).toBe(false);
|
||||
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");
|
||||
expect(steps[1].title).toBe("recoveryStepSyncTitle");
|
||||
expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle");
|
||||
expect(steps[3].title).toBe("recoveryStepFoldersTitle");
|
||||
expect(steps[4].title).toBe("recoveryStepCipherTitle");
|
||||
});
|
||||
});
|
||||
|
||||
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["isRunning"].set(true);
|
||||
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.completed).toBe(true);
|
||||
expect(step.failed).toBe(false);
|
||||
expect(step.inProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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].completed).toBe(false);
|
||||
expect(steps[2].failed).toBe(true);
|
||||
});
|
||||
|
||||
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].failed).toBe(true);
|
||||
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 stop running when complete", async () => {
|
||||
await component.runDiagnostics();
|
||||
|
||||
expect(component.diagnosticsCompleted()).toBe(true);
|
||||
expect(component.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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["isRunning"].set(true);
|
||||
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 isCompleted when successful", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.recoveryCompleted()).toBe(true);
|
||||
expect(component.isCompleted()).toBe(true);
|
||||
expect(component.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("should stop running animation if recovery is cancelled", async () => {
|
||||
mockSteps[1].canRecover.mockReturnValue(true);
|
||||
mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
|
||||
|
||||
await component.runRecovery();
|
||||
|
||||
expect(component.isRunning()).toBe(false);
|
||||
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 create and download a log file", () => {
|
||||
const mockCreateElement = jest.spyOn(document, "createElement");
|
||||
const mockClick = jest.fn();
|
||||
const mockLink = {
|
||||
href: "",
|
||||
download: "",
|
||||
click: mockClick,
|
||||
} as any;
|
||||
mockCreateElement.mockReturnValue(mockLink);
|
||||
|
||||
const mockCreateObjectURL = jest.fn().mockReturnValue("blob:mock-url");
|
||||
const mockRevokeObjectURL = jest.fn();
|
||||
global.URL.createObjectURL = mockCreateObjectURL;
|
||||
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
||||
|
||||
component.saveDiagnosticLogs();
|
||||
|
||||
expect(mockCreateElement).toHaveBeenCalledWith("a");
|
||||
expect(mockLink.download).toContain("data-recovery-logs-");
|
||||
expect(mockLink.download).toContain(".txt");
|
||||
expect(mockClick).toHaveBeenCalled();
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,223 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.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";
|
||||
|
||||
interface StepState {
|
||||
title: string;
|
||||
completed: boolean;
|
||||
inProgress: boolean;
|
||||
failed: boolean;
|
||||
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 {
|
||||
private logger: LogRecorder;
|
||||
private recoverySteps: RecoveryStep[] = [];
|
||||
private workingData: RecoveryWorkingData | null = null;
|
||||
|
||||
readonly isRunning = signal(false);
|
||||
readonly isCompleted = signal(false);
|
||||
readonly hasStarted = signal(false);
|
||||
readonly diagnosticsCompleted = signal(false);
|
||||
readonly recoveryCompleted = signal(false);
|
||||
readonly steps = signal<StepState[]>([]);
|
||||
readonly hasIssues = signal(false);
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private apiService: ApiService,
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
private folderApiService: FolderApiServiceAbstraction,
|
||||
private cipherEncryptService: CipherEncryptionService,
|
||||
private dialogService: DialogService,
|
||||
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
logService: LogService,
|
||||
) {
|
||||
this.logger = new LogRecorder(logService);
|
||||
this.recoverySteps = [
|
||||
new UserInfoStep(this.accountService, this.keyService),
|
||||
new SyncStep(this.apiService),
|
||||
new PrivateKeyStep(
|
||||
this.keyService,
|
||||
this.privateKeyRegenerationService,
|
||||
this.dialogService,
|
||||
this.cryptoFunctionService,
|
||||
),
|
||||
new FolderStep(this.folderApiService, this.dialogService),
|
||||
new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
|
||||
];
|
||||
|
||||
// Initialize step states for UI
|
||||
this.steps.set(
|
||||
this.recoverySteps.map((step) => ({
|
||||
title: this.i18nService.t(step.title),
|
||||
completed: false,
|
||||
inProgress: false,
|
||||
failed: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async runDiagnostics() {
|
||||
if (this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure SDK is loaded
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
this.hasStarted.set(true);
|
||||
this.isRunning.set(true);
|
||||
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.isRunning.set(false);
|
||||
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].inProgress = true;
|
||||
currentSteps[i].completed = false;
|
||||
currentSteps[i].failed = false;
|
||||
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].inProgress = false;
|
||||
currentSteps[i].completed = success;
|
||||
if (!success) {
|
||||
currentSteps[i].failed = true;
|
||||
hasAnyFailures = true;
|
||||
}
|
||||
this.steps.set([...currentSteps]);
|
||||
this.logger.record(`Diagnostics completed for step: ${step.title}`);
|
||||
} catch (error) {
|
||||
currentSteps[i].inProgress = false;
|
||||
currentSteps[i].failed = true;
|
||||
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);
|
||||
}
|
||||
|
||||
async runRecovery() {
|
||||
if (this.isRunning() || !this.workingData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning.set(true);
|
||||
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.isCompleted.set(true);
|
||||
} catch (error) {
|
||||
this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
this.isRunning.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
const blob = new Blob([logContent], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
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,83 @@
|
||||
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 extends RecoveryStep {
|
||||
title = "recoveryStepCipherTitle";
|
||||
|
||||
private undecryptableCipherIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private cipherService: CipherEncryptionService,
|
||||
private dialogService: DialogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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,99 @@
|
||||
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 extends RecoveryStep {
|
||||
title = "recoveryStepFoldersTitle";
|
||||
|
||||
private undecryptableFolderIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private folderService: FolderApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.record(`Failed to delete folder ${folderId}: ${errorMessage}`);
|
||||
throw 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,97 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, 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 extends RecoveryStep {
|
||||
title = "recoveryStepPrivateKeyTitle";
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private dialogService: DialogService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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 = await firstValueFrom(
|
||||
this.keyService.userEncryptedPrivateKey$(workingData.userId),
|
||||
);
|
||||
if (!encryptedPrivateKey) {
|
||||
logger.record("No encrypted private key found");
|
||||
return false;
|
||||
}
|
||||
logger.record("Private key length: " + encryptedPrivateKey.length);
|
||||
let privateKey: Uint8Array;
|
||||
try {
|
||||
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. 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,45 @@
|
||||
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 extends RecoveryStep {
|
||||
title = "recoveryStepSyncTitle";
|
||||
|
||||
constructor(private apiService: ApiService) {
|
||||
super();
|
||||
}
|
||||
|
||||
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,51 @@
|
||||
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 extends RecoveryStep {
|
||||
title = "recoveryStepUserInfoTitle";
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@ -12214,5 +12214,59 @@
|
||||
},
|
||||
"userVerificationFailed": {
|
||||
"message": "User verification failed."
|
||||
},
|
||||
"recoveryDeleteCiphersTitle": {
|
||||
"message": "Delete unrecoverable vault items"
|
||||
},
|
||||
"recoveryDeleteCiphersDesc": {
|
||||
"message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?"
|
||||
},
|
||||
"recoveryDeleteFoldersTitle": {
|
||||
"message": "Delete unrecoverable folders"
|
||||
},
|
||||
"recoveryDeleteFoldersDesc": {
|
||||
"message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?"
|
||||
},
|
||||
"recoveryReplacePrivateKeyTitle": {
|
||||
"message": "Replace encryption key"
|
||||
},
|
||||
"recoveryReplacePrivateKeyDesc": {
|
||||
"message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again."
|
||||
},
|
||||
"recoveryStepSyncTitle": {
|
||||
"message": "Synchronizing data"
|
||||
},
|
||||
"recoveryStepPrivateKeyTitle": {
|
||||
"message": "Verifying encryption key integrity"
|
||||
},
|
||||
"recoveryStepUserInfoTitle": {
|
||||
"message": "Verifying user information"
|
||||
},
|
||||
"recoveryStepCipherTitle": {
|
||||
"message": "Verifying vault item integrity"
|
||||
},
|
||||
"recoveryStepFoldersTitle": {
|
||||
"message": "Verifying folder integrity"
|
||||
},
|
||||
"dataRecoveryTitle": {
|
||||
"message": "Data Recovery and Diagnostics"
|
||||
},
|
||||
"dataRecoveryDescription": {
|
||||
"message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues."
|
||||
},
|
||||
"runDiagnostics": {
|
||||
"message": "Run Diagnostics"
|
||||
},
|
||||
"runningDiagnostics": {
|
||||
"message": "Running Diagnostics..."
|
||||
},
|
||||
"repairIssues": {
|
||||
"message": "Repair Issues"
|
||||
},
|
||||
"repairing": {
|
||||
"message": "Repairing..."
|
||||
},
|
||||
"saveDiagnosticLogs": {
|
||||
"message": "Save Diagnostic Logs"
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
@ -143,6 +144,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
|
||||
@ -7,4 +7,6 @@ export abstract class UserAsymmetricKeysRegenerationService {
|
||||
* @param userId The user id.
|
||||
*/
|
||||
abstract regenerateIfNeeded(userId: UserId): Promise<void>;
|
||||
|
||||
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
|
||||
if (privateKeyRegenerationFlag) {
|
||||
const shouldRegenerate = await this.shouldRegenerate(userId);
|
||||
if (shouldRegenerate) {
|
||||
await this.regenerateUserAsymmetricKeys(userId);
|
||||
await this.regenerateUserPublicKeyEncryptionKeyPair(userId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -125,7 +125,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
|
||||
return false;
|
||||
}
|
||||
|
||||
private async regenerateUserAsymmetricKeys(userId: UserId): Promise<void> {
|
||||
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("User key not found");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user