1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 16:23:44 +00:00

Merge branch 'main' into autofill/pm-8027-inline-menu-appears-within-input-fields-that-do-not-relate-to-user-login

This commit is contained in:
Cesar Gonzalez
2024-05-21 09:56:58 -05:00
committed by GitHub
144 changed files with 3825 additions and 2243 deletions

View File

@@ -153,7 +153,7 @@ describe("AccountSwitcherService", () => {
await selectAccountPromise;
expect(accountService.switchAccount).toBeCalledWith(null);
expect(messagingService.send).toHaveBeenCalledWith("switchAccount", { userId: null });
expect(removeListenerSpy).toBeCalledTimes(1);
});
@@ -176,7 +176,7 @@ describe("AccountSwitcherService", () => {
await selectAccountPromise;
expect(accountService.switchAccount).toBeCalledWith("1");
expect(messagingService.send).toHaveBeenCalledWith("switchAccount", { userId: "1" });
expect(messagingService.send).toBeCalledWith(
"switchAccount",
matches((payload) => {

View File

@@ -134,7 +134,6 @@ export class AccountSwitcherService {
const switchAccountFinishedPromise = this.listenForSwitchAccountFinish(userId);
// Initiate the actions required to make account switching happen
await this.accountService.switchAccount(userId);
this.messagingService.send("switchAccount", { userId }); // This message should cause switchAccountFinish to be sent
// Wait until we receive the switchAccountFinished message

View File

@@ -1,4 +1,4 @@
import { mock, mockReset } from "jest-mock-extended";
import { mock, MockProxy, mockReset } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -62,7 +62,8 @@ describe("OverlayBackground", () => {
let overlayBackground: OverlayBackground;
const cipherService = mock<CipherService>();
const autofillService = mock<AutofillService>();
const authService = mock<AuthService>();
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
const environmentService = mock<EnvironmentService>();
environmentService.environment$ = new BehaviorSubject(
@@ -94,6 +95,9 @@ describe("OverlayBackground", () => {
beforeEach(() => {
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authService = mock<AuthService>();
authService.activeAccountStatus$ = activeAccountStatusMock$;
overlayBackground = new OverlayBackground(
cipherService,
autofillService,
@@ -166,11 +170,11 @@ describe("OverlayBackground", () => {
});
beforeEach(() => {
overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked;
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
});
it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => {
overlayBackground["userAuthStatus"] = AuthenticationStatus.Locked;
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
jest.spyOn(BrowserApi, "getTabFromCurrentWindowId");
jest.spyOn(cipherService, "getAllDecryptedForUrl");

View File

@@ -136,7 +136,8 @@ class OverlayBackground implements OverlayBackgroundInterface {
* list of ciphers if the extension is not unlocked.
*/
async updateOverlayCiphers() {
if (this.userAuthStatus !== AuthenticationStatus.Unlocked) {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
@@ -167,7 +168,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
private async getOverlayCipherData(): Promise<OverlayCipherData[]> {
const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$);
const overlayCiphersArray = Array.from(this.overlayLoginCiphers);
const overlayCipherData = [];
const overlayCipherData: OverlayCipherData[] = [];
let loginCipherIcon: WebsiteIconData;
for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) {

View File

@@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
@@ -109,9 +110,12 @@ export default class AutofillService implements AutofillServiceInterface {
// Autofill user settings loaded from state can await the active account state indefinitely
// if not guarded by an active account check (e.g. the user is logged in)
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
let overlayVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off;
let autoFillOnPageLoadIsEnabled = false;
const overlayVisibility = await this.getOverlayVisibility();
if (activeAccount) {
overlayVisibility = await this.getOverlayVisibility();
}
const mainAutofillScript = overlayVisibility
? "bootstrap-autofill-overlay.js"

View File

@@ -144,13 +144,11 @@ import { NotificationsService } from "@bitwarden/common/services/notifications.s
import { SearchService } from "@bitwarden/common/services/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import {
PasswordGenerationService,
PasswordGenerationServiceAbstraction,
} from "@bitwarden/common/tools/generator/password";
import {
UsernameGenerationService,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/common/tools/generator/username";
legacyPasswordGenerationServiceFactory,
legacyUsernameGenerationServiceFactory,
} from "@bitwarden/common/tools/generator";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -649,10 +647,12 @@ export default class MainBackground {
this.passwordStrengthService = new PasswordStrengthService();
this.passwordGenerationService = new PasswordGenerationService(
this.passwordGenerationService = legacyPasswordGenerationServiceFactory(
this.encryptService,
this.cryptoService,
this.policyService,
this.stateService,
this.accountService,
this.stateProvider,
);
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
@@ -1092,10 +1092,14 @@ export default class MainBackground {
this.vaultTimeoutSettingsService,
);
this.usernameGenerationService = new UsernameGenerationService(
this.cryptoService,
this.stateService,
this.usernameGenerationService = legacyUsernameGenerationServiceFactory(
this.apiService,
this.i18nService,
this.cryptoService,
this.encryptService,
this.policyService,
this.accountService,
this.stateProvider,
);
if (!this.popupOnlyContext) {

View File

@@ -20,6 +20,7 @@ export interface OffscreenDocument {
}
export abstract class OffscreenDocumentService {
abstract offscreenApiSupported(): boolean;
abstract withDocument<T>(
reasons: chrome.offscreen.Reason[],
justification: string,

View File

@@ -49,6 +49,12 @@ describe.each([
jest.resetAllMocks();
});
describe("offscreenApiSupported", () => {
it("indicates whether the offscreen API is supported", () => {
expect(sut.offscreenApiSupported()).toBe(true);
});
});
describe("withDocument", () => {
it("creates a document when none exists", async () => {
await sut.withDocument(reasons, justification, () => {});

View File

@@ -1,10 +1,16 @@
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService {
import { OffscreenDocumentService } from "./abstractions/offscreen-document";
export class DefaultOffscreenDocumentService implements OffscreenDocumentService {
private workerCount = 0;
constructor(private logService: LogService) {}
offscreenApiSupported(): boolean {
return typeof chrome.offscreen !== "undefined";
}
async withDocument<T>(
reasons: chrome.offscreen.Reason[],
justification: string,

View File

@@ -229,9 +229,7 @@ describe("Browser Utils Service", () => {
it("copies the passed text using the offscreen document if the extension is using manifest v3", async () => {
const text = "test";
jest
.spyOn(browserPlatformUtilsService, "getDevice")
.mockReturnValue(DeviceType.ChromeExtension);
offscreenDocumentService.offscreenApiSupported.mockReturnValue(true);
getManifestVersionSpy.mockReturnValue(3);
browserPlatformUtilsService.copyToClipboard(text);
@@ -304,9 +302,7 @@ describe("Browser Utils Service", () => {
});
it("reads the clipboard text using the offscreen document", async () => {
jest
.spyOn(browserPlatformUtilsService, "getDevice")
.mockReturnValue(DeviceType.ChromeExtension);
offscreenDocumentService.offscreenApiSupported.mockReturnValue(true);
getManifestVersionSpy.mockReturnValue(3);
offscreenDocumentService.withDocument.mockImplementationOnce((_, __, callback) =>
Promise.resolve("test"),

View File

@@ -243,7 +243,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
text = "\u0000";
}
if (this.isChrome() && BrowserApi.isManifestVersion(3)) {
if (BrowserApi.isManifestVersion(3) && this.offscreenDocumentService.offscreenApiSupported()) {
void this.triggerOffscreenCopyToClipboard(text).then(handleClipboardWriteCallback);
return;
@@ -268,7 +268,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return await SafariApp.sendMessageToApp("readFromClipboard");
}
if (this.isChrome() && BrowserApi.isManifestVersion(3)) {
if (BrowserApi.isManifestVersion(3) && this.offscreenDocumentService.offscreenApiSupported()) {
return await this.triggerOffscreenReadFromClipboard();
}

View File

@@ -1,5 +1,5 @@
import { Location } from "@angular/common";
import { Component } from "@angular/core";
import { Component, NgZone } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom } from "rxjs";
@@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -29,22 +28,22 @@ export class GeneratorComponent extends BaseGeneratorComponent {
usernameGenerationService: UsernameGenerationServiceAbstraction,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
stateService: StateService,
accountService: AccountService,
cipherService: CipherService,
route: ActivatedRoute,
logService: LogService,
accountService: AccountService,
ngZone: NgZone,
private location: Location,
) {
super(
passwordGenerationService,
usernameGenerationService,
platformUtilsService,
stateService,
accountService,
i18nService,
logService,
route,
accountService,
ngZone,
window,
);
this.cipherService = cipherService;

View File

@@ -29,11 +29,6 @@
<option *ngFor="let f of formatOptions" [value]="f.value">{{ f.name }}</option>
</select>
</div>
<app-user-verification ngDefaultControl formControlName="secret" name="Secret">
</app-user-verification>
</div>
<div id="confirmIdentityHelp" class="box-footer">
<p>{{ "confirmIdentity" | i18n }}</p>
</div>
</div>
</main>

View File

@@ -5,7 +5,6 @@ import { Router } from "@angular/router";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -27,7 +26,6 @@ export class ExportComponent extends BaseExportComponent {
policyService: PolicyService,
private router: Router,
logService: LogService,
userVerificationService: UserVerificationService,
formBuilder: UntypedFormBuilder,
fileDownloadService: FileDownloadService,
dialogService: DialogService,
@@ -40,7 +38,6 @@ export class ExportComponent extends BaseExportComponent {
eventCollectionService,
policyService,
logService,
userVerificationService,
formBuilder,
fileDownloadService,
dialogService,

View File

@@ -103,10 +103,8 @@ import { EventUploadService } from "@bitwarden/common/services/event/event-uploa
import { SearchService } from "@bitwarden/common/services/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import {
PasswordGenerationService,
PasswordGenerationServiceAbstraction,
} from "@bitwarden/common/tools/generator/password";
import { legacyPasswordGenerationServiceFactory } from "@bitwarden/common/tools/generator";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -499,10 +497,12 @@ export class ServiceContainer {
this.passwordStrengthService = new PasswordStrengthService();
this.passwordGenerationService = new PasswordGenerationService(
this.passwordGenerationService = legacyPasswordGenerationServiceFactory(
this.encryptService,
this.cryptoService,
this.policyService,
this.stateService,
this.accountService,
this.stateProvider,
);
this.devicesApiService = new DevicesApiServiceImplementation(this.apiService);

View File

@@ -411,7 +411,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.masterPasswordService.forceSetPasswordReason$(message.userId),
)) != ForceSetPasswordReason.None;
if (locked) {
this.messagingService.send("locked", { userId: message.userId });
this.modalService.closeAll();
await this.router.navigate(["lock"]);
} else if (forcedPasswordReset) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@@ -21,11 +21,6 @@
<option *ngFor="let f of formatOptions" [value]="f.value">{{ f.name }}</option>
</select>
</div>
<app-user-verification ngDefaultControl formControlName="secret" name="secret">
</app-user-verification>
</div>
<div id="confirmIdentityHelp" class="box-footer">
<p>{{ "confirmIdentity" | i18n }}</p>
</div>
</div>
</div>

View File

@@ -4,7 +4,6 @@ import { UntypedFormBuilder } from "@angular/forms";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -24,7 +23,6 @@ export class ExportComponent extends BaseExportComponent implements OnInit {
exportService: VaultExportServiceAbstraction,
eventCollectionService: EventCollectionService,
policyService: PolicyService,
userVerificationService: UserVerificationService,
formBuilder: UntypedFormBuilder,
logService: LogService,
fileDownloadService: FileDownloadService,
@@ -38,7 +36,6 @@ export class ExportComponent extends BaseExportComponent implements OnInit {
eventCollectionService,
policyService,
logService,
userVerificationService,
formBuilder,
fileDownloadService,
dialogService,

View File

@@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -36,10 +35,6 @@ describe("GeneratorComponent", () => {
provide: UsernameGenerationServiceAbstraction,
useValue: mock<UsernameGenerationServiceAbstraction>(),
},
{
provide: StateService,
useValue: mock<StateService>(),
},
{
provide: PlatformUtilsService,
useValue: platformUtilsServiceMock,

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { Component, NgZone } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component";
@@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
@@ -18,22 +17,22 @@ export class GeneratorComponent extends BaseGeneratorComponent {
constructor(
passwordGenerationService: PasswordGenerationServiceAbstraction,
usernameGenerationService: UsernameGenerationServiceAbstraction,
stateService: StateService,
accountService: AccountService,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
route: ActivatedRoute,
ngZone: NgZone,
logService: LogService,
accountService: AccountService,
) {
super(
passwordGenerationService,
usernameGenerationService,
platformUtilsService,
stateService,
accountService,
i18nService,
logService,
route,
accountService,
ngZone,
window,
);
}

View File

@@ -1,24 +1,5 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="bulkTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="bulkTitle">
{{ "confirmUsers" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="card-body text-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</div>
<bit-dialog dialogSize="large" [title]="'confirmUsers' | i18n" [loading]="loading">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
@@ -26,9 +7,10 @@
{{ error }}
</app-callout>
<ng-container *ngIf="!loading && !done">
<p>
<p bitTypography="body1">
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noreferrer"
@@ -36,80 +18,82 @@
{{ "learnMore" | i18n }}</a
>
</p>
<table class="table table-hover table-list">
<thead>
<bit-table>
<ng-container header>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "fingerprint" | i18n }}</th>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "fingerprint" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of filteredUsers">
<td width="30">
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of filteredUsers" alignContent="middle">
<td bitCell class="tw-w-5">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
<td bitCell>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td>
<td bitCell>
{{ fingerprints.get(user.id) }}
</td>
</tr>
<tr *ngFor="let user of excludedUsers">
<td width="30">
<tr *ngFor="let user of excludedUsers" alignContent="middle">
<td bitCell class="tw-w-5">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
<td bitCell>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td>
<td bitCell>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-template>
</bit-table>
</ng-container>
<ng-container *ngIf="!loading && done">
<table class="table table-hover table-list">
<thead>
<bit-table>
<ng-container header>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of filteredUsers">
<td width="30">
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of filteredUsers" alignContent="middle">
<td bitCell class="tw-w-5">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
<td bitCell>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td *ngIf="statuses.has(user.id)">
<td bitCell *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)">
<td bitCell *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-template>
</bit-table>
</ng-container>
</div>
<div class="modal-footer">
</ng-container>
<ng-container bitDialogFooter>
<button
type="submit"
class="btn btn-primary btn-submit"
*ngIf="!done"
[disabled]="loading"
bitButton
type="submit"
buttonType="primary"
(click)="submit()"
[disabled]="loading"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "confirm" | i18n }}</span>
{{ "confirm" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>
</ng-container>
</bit-dialog>

View File

@@ -1,4 +1,5 @@
import { Component, Input, OnInit } from "@angular/core";
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
@@ -8,16 +9,22 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { DialogService } from "@bitwarden/components";
import { BulkUserDetails } from "./bulk-status.component";
type BulkConfirmDialogData = {
organizationId: string;
users: BulkUserDetails[];
};
@Component({
selector: "app-bulk-confirm",
templateUrl: "bulk-confirm.component.html",
})
export class BulkConfirmComponent implements OnInit {
@Input() organizationId: string;
@Input() users: BulkUserDetails[];
organizationId: string;
users: BulkUserDetails[];
excludedUsers: BulkUserDetails[];
filteredUsers: BulkUserDetails[];
@@ -30,11 +37,15 @@ export class BulkConfirmComponent implements OnInit {
error: string;
constructor(
@Inject(DIALOG_DATA) protected data: BulkConfirmDialogData,
protected cryptoService: CryptoService,
protected apiService: ApiService,
private organizationUserService: OrganizationUserService,
private i18nService: I18nService,
) {}
) {
this.organizationId = data.organizationId;
this.users = data.users;
}
async ngOnInit() {
this.excludedUsers = this.users.filter((u) => !this.isAccepted(u));
@@ -110,4 +121,8 @@ export class BulkConfirmComponent implements OnInit {
request,
);
}
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogData>) {
return dialogService.open(BulkConfirmComponent, config);
}
}

View File

@@ -1,20 +1,5 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="bulkTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="bulkTitle">
{{ "removeUsers" | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
@@ -23,28 +8,29 @@
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
<p>{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning">
<p bitTypography="body1">{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning" bitTypography="body1">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
<table class="table table-hover table-list">
<thead>
<bit-table>
<ng-container header>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of users">
<td bitCell class="tw-w-5">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
<td bitCell>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
<small class="tw-text-muted tw-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="this.showNoMasterPasswordWarning">
<span class="text-muted d-block tw-lowercase">
<td bitCell *ngIf="this.showNoMasterPasswordWarning">
<span class="tw-text-muted tw-block tw-lowercase">
<ng-container *ngIf="user.hasMasterPassword === true"> - </ng-container>
<ng-container *ngIf="user.hasMasterPassword === false">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
@@ -53,49 +39,50 @@
</span>
</td>
</tr>
</table>
</ng-template>
</bit-table>
</ng-container>
<ng-container *ngIf="done">
<table class="table table-hover table-list">
<thead>
<bit-table>
<ng-container header>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let user of users">
<td bitCell class="tw-w-5">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
<td bitCell>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
<small class="tw-text-muted tw-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="statuses.has(user.id)">
<td *ngIf="statuses.has(user.id)" bitCell>
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)">
<td *ngIf="!statuses.has(user.id)" bitCell>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-template>
</bit-table>
</ng-container>
</div>
<div class="modal-footer">
</ng-container>
<ng-container bitDialogFooter>
<button
type="submit"
class="btn btn-primary btn-submit"
*ngIf="!done && users.length > 0"
bitButton
type="submit"
buttonType="primary"
[disabled]="loading"
(click)="submit()"
[bitAction]="submit"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "removeUsers" | i18n }}</span>
{{ "removeUsers" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>
</ng-container>
</bit-dialog>

View File

@@ -1,30 +1,26 @@
import { Component, Input } from "@angular/core";
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import { BulkUserDetails } from "./bulk-status.component";
type BulkRemoveDialogData = {
organizationId: string;
users: BulkUserDetails[];
};
@Component({
selector: "app-bulk-remove",
templateUrl: "bulk-remove.component.html",
})
export class BulkRemoveComponent {
@Input() organizationId: string;
@Input() set users(value: BulkUserDetails[]) {
this._users = value;
this.showNoMasterPasswordWarning = this._users.some(
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
);
}
get users(): BulkUserDetails[] {
return this._users;
}
private _users: BulkUserDetails[];
organizationId: string;
users: BulkUserDetails[];
statuses: Map<string, string> = new Map();
@@ -34,12 +30,19 @@ export class BulkRemoveComponent {
showNoMasterPasswordWarning = false;
constructor(
@Inject(DIALOG_DATA) protected data: BulkRemoveDialogData,
protected apiService: ApiService,
protected i18nService: I18nService,
private organizationUserService: OrganizationUserService,
) {}
) {
this.organizationId = data.organizationId;
this.users = data.users;
this.showNoMasterPasswordWarning = this.users.some(
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
);
}
async submit() {
submit = async () => {
this.loading = true;
try {
const response = await this.deleteUsers();
@@ -54,7 +57,7 @@ export class BulkRemoveComponent {
}
this.loading = false;
}
};
protected async deleteUsers() {
return await this.organizationUserService.deleteManyOrganizationUsers(
@@ -66,4 +69,8 @@ export class BulkRemoveComponent {
protected get removeUsersWarning() {
return this.i18nService.t("removeOrgUsersConfirmation");
}
static open(dialogService: DialogService, config: DialogConfig<BulkRemoveDialogData>) {
return dialogService.open(BulkRemoveComponent, config);
}
}

View File

@@ -14,7 +14,7 @@
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-tab-group
*ngIf="!loading && organization$ | async as organization"
@@ -22,7 +22,7 @@
>
<bit-tab [label]="'role' | i18n">
<ng-container *ngIf="!editMode">
<p>{{ "inviteUserDesc" | i18n }}</p>
<p bitTypography="body1">{{ "inviteUserDesc" | i18n }}</p>
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input id="emails" type="text" appAutoFocus bitInput formControlName="emails" />
@@ -32,13 +32,11 @@
}}</bit-hint>
</bit-form-field>
</ng-container>
<fieldset role="radiogroup" aria-labelledby="roleGroupLabel" class="tw-mb-6">
<legend
id="roleGroupLabel"
class="tw-mb-2 tw-block tw-text-base tw-font-semibold tw-text-main"
>
<bit-radio-group formControlName="type">
<bit-label>
{{ "memberRole" | i18n }}
<a
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
@@ -46,112 +44,63 @@
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</legend>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeUser"
[value]="organizationUserType.User"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeUser">
{{ "user" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "userDesc" | i18n }}
</div>
</label>
</div>
<div
</bit-label>
<bit-radio-button id="userTypeUser" [value]="organizationUserType.User">
<bit-label>{{ "user" | i18n }}</bit-label>
<bit-hint>{{ "userDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button
*ngIf="!organization.flexibleCollections"
class="tw-mb-2 tw-flex tw-items-baseline"
>
<input
type="radio"
id="userTypeManager"
[value]="organizationUserType.Manager"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeManager">
{{ "manager" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "managerDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeAdmin"
[value]="organizationUserType.Admin"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeAdmin">
{{ "admin" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "adminDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeOwner"
[value]="organizationUserType.Owner"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeOwner">
{{ "owner" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "ownerDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-flex tw-items-baseline">
<input
type="radio"
>
<bit-label>{{ "manager" | i18n }}</bit-label>
<bit-hint>{{ "managerDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button id="userTypeAdmin" [value]="organizationUserType.Admin">
<bit-label>{{ "admin" | i18n }}</bit-label>
<bit-hint>{{ "adminDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button id="userTypeOwner" [value]="organizationUserType.Owner">
<bit-label>{{ "owner" | i18n }}</bit-label>
<bit-hint>{{ "ownerDesc" | i18n }}</bit-hint>
</bit-radio-button>
<bit-radio-button
id="userTypeCustom"
[value]="organizationUserType.Custom"
formControlName="type"
name="type"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
[attr.disabled]="!organization.useCustomPermissions || null"
/>
<label class="tw-m-0" for="userTypeCustom">
{{ "custom" | i18n }}
[disabled]="!organization.useCustomPermissions || null"
>
<bit-label>{{ "custom" | i18n }}</bit-label>
<bit-hint>
<ng-container *ngIf="!organization.useCustomPermissions; else enterprise">
<div class="text-base tw-block tw-font-normal tw-text-muted">
<p>
{{ "customDescNonEnterpriseStart" | i18n
}}<a href="https://bitwarden.com/contact/" target="_blank" rel="noreferrer">{{
"customDescNonEnterpriseLink" | i18n
}}</a
}}<a
bitLink
href="https://bitwarden.com/contact/"
target="_blank"
rel="noreferrer"
>{{ "customDescNonEnterpriseLink" | i18n }}</a
>{{ "customDescNonEnterpriseEnd" | i18n }}
</div>
</p>
</ng-container>
<ng-template #enterprise>
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "customDesc" | i18n }}
</div>
<p>{{ "customDesc" | i18n }}</p>
</ng-template>
</label>
</div>
</fieldset>
</bit-hint>
</bit-radio-button>
</bit-radio-group>
<ng-container *ngIf="customUserTypeSelected">
<ng-container *ngIf="!organization.flexibleCollections; else customPermissionsFC">
<h3 class="mt-4 d-flex tw-font-semibold">
<h3 bitTypography="h3">
{{ "permissions" | i18n }}
</h3>
<div class="row" [formGroup]="permissionsGroup">
<div class="col-6">
<div class="mb-3">
<label class="tw-font-semibold">{{ "managerPermissions" | i18n }}</label>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" [formGroup]="permissionsGroup">
<div class="tw-col-span-6">
<div class="tw-mb-3">
<bit-label class="tw-font-semibold">{{
"managerPermissions" | i18n
}}</bit-label>
<hr class="tw-mb-2 tw-mr-2 tw-mt-0" />
<app-nested-checkbox
parentId="manageAssignedCollections"
@@ -160,221 +109,126 @@
</app-nested-checkbox>
</div>
</div>
<div class="col-6">
<div class="mb-3">
<label class="tw-font-semibold">{{ "adminPermissions" | i18n }}</label>
<div class="tw-col-span-6">
<div class="tw-mb-3">
<bit-label class="tw-font-semibold">{{ "adminPermissions" | i18n }}</bit-label>
<hr class="tw-mb-2 tw-mr-2 tw-mt-0" />
<div>
<input
type="checkbox"
name="accessEventLogs"
id="accessEventLogs"
formControlName="accessEventLogs"
/>
<label class="!tw-font-normal" for="accessEventLogs">
{{ "accessEventLogs" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="accessImportExport"
id="accessImportExport"
formControlName="accessImportExport"
/>
<label class="!tw-font-normal" for="accessImportExport">
{{ "accessImportExport" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="accessReports"
id="accessReports"
formControlName="accessReports"
/>
<label class="!tw-font-normal" for="accessReports">
{{ "accessReports" | i18n }}
</label>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessEventLogs" />
<bit-label>{{ "accessEventLogs" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessImportExport" />
<bit-label>{{ "accessImportExport" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessReports" />
<bit-label>{{ "accessReports" | i18n }}</bit-label>
</bit-form-control>
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
<div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageGroups" />
<bit-label>{{ "manageGroups" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageSso" />
<bit-label>{{ "manageSso" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="managePolicies" />
<bit-label>{{ "managePolicies" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
name="manageGroups"
id="manageGroups"
formControlName="manageGroups"
/>
<label class="!tw-font-normal" for="manageGroups">
{{ "manageGroups" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageSso"
id="manageSso"
formControlName="manageSso"
/>
<label class="!tw-font-normal" for="manageSso">
{{ "manageSso" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="managePolicies"
id="managePolicies"
formControlName="managePolicies"
/>
<label class="!tw-font-normal" for="managePolicies">
{{ "managePolicies" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageUsers"
id="manageUsers"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageUsers">
{{ "manageUsers" | i18n }}
</label>
</div>
<div>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
name="manageResetPassword"
id="manageResetPassword"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageResetPassword">
{{ "manageAccountRecovery" | i18n }}
</label>
</div>
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>
</div>
</div>
</ng-container>
<ng-template #customPermissionsFC>
<div class="row" [formGroup]="permissionsGroup">
<div class="col-4">
<div>
<input
type="checkbox"
name="accessEventLogs"
id="accessEventLogs"
formControlName="accessEventLogs"
/>
<label class="!tw-font-normal" for="accessEventLogs">
{{ "accessEventLogs" | i18n }}
</label>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" [formGroup]="permissionsGroup">
<div class="tw-col-span-4">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessEventLogs" />
<bit-label>{{ "accessEventLogs" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessImportExport" />
<bit-label>{{ "accessImportExport" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessReports" />
<bit-label>{{ "accessReports" | i18n }}</bit-label>
</bit-form-control>
</div>
<div>
<input
type="checkbox"
name="accessImportExport"
id="accessImportExport"
formControlName="accessImportExport"
/>
<label class="!tw-font-normal" for="accessImportExport">
{{ "accessImportExport" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="accessReports"
id="accessReports"
formControlName="accessReports"
/>
<label class="!tw-font-normal" for="accessReports">
{{ "accessReports" | i18n }}
</label>
</div>
</div>
<div class="col-4">
<div class="tw-col-span-4">
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
</div>
<div class="col-4">
<div class="mb-3">
<div>
<div class="tw-col-span-4">
<div class="tw-mb-3">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageGroups" />
<bit-label>{{ "manageGroups" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="manageSso" />
<bit-label>{{ "manageSso" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="managePolicies" />
<bit-label>{{ "managePolicies" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
name="manageGroups"
id="manageGroups"
formControlName="manageGroups"
/>
<label class="!tw-font-normal" for="manageGroups">
{{ "manageGroups" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageSso"
id="manageSso"
formControlName="manageSso"
/>
<label class="!tw-font-normal" for="manageSso">
{{ "manageSso" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="managePolicies"
id="managePolicies"
formControlName="managePolicies"
/>
<label class="!tw-font-normal" for="managePolicies">
{{ "managePolicies" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageUsers"
id="manageUsers"
bitCheckbox
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageUsers">
{{ "manageUsers" | i18n }}
</label>
</div>
<div>
<bit-label>{{ "manageUsers" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
type="checkbox"
name="manageResetPassword"
id="manageResetPassword"
bitCheckbox
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageResetPassword">
{{ "manageAccountRecovery" | i18n }}
</label>
</div>
<bit-label>{{ "manageAccountRecovery" | i18n }}</bit-label>
</bit-form-control>
</div>
</div>
</div>
</ng-template>
</ng-container>
<ng-container *ngIf="organization.useSecretsManager">
<h3 class="mt-4">
<h3 class="tw-mt-4">
{{ "secretsManager" | i18n }}
<a
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
@@ -436,6 +290,7 @@
<bit-label>
{{ "accessAllCollectionsDesc" | i18n }}
<a
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"

View File

@@ -1,28 +1,20 @@
<div [formGroup]="checkboxes">
<bit-form-control>
<input
type="checkbox"
[name]="pascalize(parentId)"
[id]="parentId"
bitCheckbox
[formControlName]="parentId"
[indeterminate]="parentIndeterminate"
/>
<label class="!tw-font-normal" [for]="parentId">
{{ parentId | i18n }}
</label>
<div class="tw-ml-6">
<bit-label>{{ parentId | i18n }}</bit-label>
</bit-form-control>
<div class="tw-ml-4">
<ng-container *ngFor="let c of checkboxes.controls | keyvalue; trackBy: key">
<div class="" *ngIf="c.key != parentId">
<input
class=""
type="checkbox"
[name]="pascalize(c.key)"
[id]="c.key"
[formControl]="c.value"
(change)="onChildCheck()"
/>
<label class="!tw-font-normal" [for]="c.key">
{{ c.key | i18n }}
</label>
<div *ngIf="c.key != parentId">
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="c.value" (change)="onChildCheck()" />
<bit-label>{{ c.key | i18n }}</bit-label>
</bit-form-control>
</div>
</ng-container>
</div>

View File

@@ -481,16 +481,13 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkRemoveComponent,
this.bulkRemoveModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
const dialogRef = BulkRemoveComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
users: this.getCheckedUsers(),
},
);
await modal.onClosedPromise();
});
await lastValueFrom(dialogRef.closed);
await this.load();
}
@@ -558,16 +555,14 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkConfirmComponent,
this.bulkConfirmModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
const dialogRef = BulkConfirmComponent.open(this.dialogService, {
data: {
organizationId: this.organization.id,
users: this.getCheckedUsers(),
},
);
});
await modal.onClosedPromise();
await lastValueFrom(dialogRef.closed);
await this.load();
}

View File

@@ -2,15 +2,7 @@
{{ "disableSendExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -1,144 +1,64 @@
<div [formGroup]="data">
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6 tw-mb-0">
<bit-label>{{ "defaultType" | i18n }}</bit-label>
<bit-select formControlName="defaultType" id="defaultType">
<bit-option *ngFor="let o of defaultTypes" [value]="o.value" [label]="o.name"></bit-option>
</bit-select>
</bit-form-field>
</div>
<div class="row">
<div class="col-6 form-group mb-0">
<label for="defaultType">{{ "defaultType" | i18n }}</label>
<select
id="defaultType"
name="defaultType"
formControlName="defaultType"
class="form-control"
>
<option *ngFor="let o of defaultTypes" [ngValue]="o.value">{{ o.name }}</option>
</select>
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input bitInput type="number" min="5" max="128" formControlName="minLength" />
</bit-form-field>
</div>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minNumbers" />
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minSpecial" />
</bit-form-field>
</div>
<h3 class="mt-4">{{ "password" | i18n }}</h3>
<div class="row">
<div class="col-6 form-group">
<label for="minLength">{{ "minLength" | i18n }}</label>
<input
id="minLength"
class="form-control"
type="number"
name="minLength"
min="5"
max="128"
formControlName="minLength"
/>
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="minNumbers">{{ "minNumbers" | i18n }}</label>
<input
id="minNumbers"
class="form-control"
type="number"
name="minNumbers"
min="0"
max="9"
formControlName="minNumbers"
/>
</div>
<div class="col-6 form-group">
<label for="minSpecial">{{ "minSpecial" | i18n }}</label>
<input
id="minSpecial"
class="form-control"
type="number"
name="minSpecial"
min="0"
max="9"
formControlName="minSpecial"
/>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useUpper"
formControlName="useUpper"
name="useUpper"
/>
<label class="form-check-label" for="useUpper">A-Z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useLower"
name="useLower"
formControlName="useLower"
/>
<label class="form-check-label" for="useLower">a-z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useNumbers"
name="useNumbers"
formControlName="useNumbers"
/>
<label class="form-check-label" for="useNumbers">0-9</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useSpecial"
name="useSpecial"
formControlName="useSpecial"
/>
<label class="form-check-label" for="useSpecial">!@#$%^&amp;*</label>
</div>
<h3 class="mt-4">{{ "passphrase" | i18n }}</h3>
<div class="row">
<div class="col-6 form-group">
<label for="minNumberWords">{{ "minimumNumberOfWords" | i18n }}</label>
<input
id="minNumberWords"
class="form-control"
type="number"
name="minNumberWords"
min="3"
max="20"
formControlName="minNumberWords"
/>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="capitalize"
name="capitalize"
formControlName="capitalize"
/>
<label class="form-check-label" for="capitalize">{{ "capitalize" | i18n }}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="includeNumber"
name="includeNumber"
formControlName="includeNumber"
/>
<label class="form-check-label" for="includeNumber">{{ "includeNumber" | i18n }}</label>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
<bit-label>A-Z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useLower" id="useLower" />
<bit-label>a-z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useNumbers" id="useNumbers" />
<bit-label>0-9</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useSpecial" id="useSpecial" />
<bit-label>!@#$%^&amp;*</bit-label>
</bit-form-control>
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
<input bitInput type="number" min="3" max="20" formControlName="minNumberWords" />
</bit-form-field>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="includeNumber" id="includeNumber" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</div>

View File

@@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -20,14 +20,14 @@ export class PasswordGeneratorPolicy extends BasePolicy {
export class PasswordGeneratorPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
defaultType: [null],
minLength: [null],
minLength: [null, [Validators.min(5), Validators.max(128)]],
useUpper: [null],
useLower: [null],
useNumbers: [null],
useSpecial: [null],
minNumbers: [null],
minSpecial: [null],
minNumberWords: [null],
minNumbers: [null, [Validators.min(0), Validators.max(9)]],
minSpecial: [null, [Validators.min(0), Validators.max(9)]],
minNumberWords: [null, [Validators.min(3), Validators.max(20)]],
capitalize: [null],
includeNumber: [null],
});

View File

@@ -5,15 +5,7 @@
{{ "requireSsoExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -2,29 +2,15 @@
{{ "sendOptionsExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<h3 class="mt-4">{{ "options" | i18n }}</h3>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="disableHideEmail"
name="DisableHideEmail"
formControlName="disableHideEmail"
/>
<label class="form-check-label" for="disableHideEmail">{{ "disableHideEmail" | i18n }}</label>
</div>
<h3 bitTypography="h3" class="tw-mt-4">{{ "options" | i18n }}</h3>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="disableHideEmail" id="disableHideEmail" />
<bit-label>{{ "disableHideEmail" | i18n }}</bit-label>
</bit-form-control>
</div>

View File

@@ -2,15 +2,7 @@
{{ "singleOrgPolicyWarning" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -104,12 +104,11 @@
<button type="button" bitButton buttonType="danger" (click)="deleteOrganization()">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" bitButton buttonType="danger" (click)="purgeVault()">
<button type="button" bitButton buttonType="danger" [bitAction]="purgeVault">
{{ "purgeVault" | i18n }}
</button>
</app-danger-zone>
<ng-template #purgeOrganizationTemplate></ng-template>
<ng-template #apiKeyTemplate></ng-template>
<ng-template #rotateApiKeyTemplate></ng-template>
</bit-container>

View File

@@ -28,8 +28,6 @@ import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./
templateUrl: "account.component.html",
})
export class AccountComponent {
@ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true })
purgeModalRef: ViewContainerRef;
@ViewChild("apiKeyTemplate", { read: ViewContainerRef, static: true })
apiKeyModalRef: ViewContainerRef;
@ViewChild("rotateApiKeyTemplate", { read: ViewContainerRef, static: true })
@@ -232,11 +230,14 @@ export class AccountComponent {
}
}
async purgeVault() {
await this.modalService.openViewRef(PurgeVaultComponent, this.purgeModalRef, (comp) => {
comp.organizationId = this.organizationId;
purgeVault = async () => {
const dialogRef = PurgeVaultComponent.open(this.dialogService, {
data: {
organizationId: this.organizationId,
},
});
}
await lastValueFrom(dialogRef.closed);
};
async viewApiKey() {
await this.modalService.openViewRef(ApiKeyComponent, this.apiKeyModalRef, (comp) => {

View File

@@ -5,7 +5,6 @@ import { ActivatedRoute } from "@angular/router";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -30,7 +29,6 @@ export class OrganizationVaultExportComponent extends ExportComponent {
private route: ActivatedRoute,
policyService: PolicyService,
logService: LogService,
userVerificationService: UserVerificationService,
formBuilder: UntypedFormBuilder,
fileDownloadService: FileDownloadService,
dialogService: DialogService,
@@ -43,7 +41,6 @@ export class OrganizationVaultExportComponent extends ExportComponent {
eventCollectionService,
policyService,
logService,
userVerificationService,
formBuilder,
fileDownloadService,
dialogService,

View File

@@ -1,50 +1,36 @@
<td>
<td bitCell>
{{ sponsoringOrg.familySponsorshipFriendlyName }}
</td>
<td>{{ sponsoringOrg.name }}</td>
<td>
<td bitCell>{{ sponsoringOrg.name }}</td>
<td bitCell>
<span [ngClass]="statusClass">{{ statusMessage }}</span>
</td>
<td class="table-action-right">
<div class="dropdown" appListDropdown>
<td bitCell>
<button
*ngIf="!sponsoringOrg.familySponsorshipToDelete"
class="btn btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
></button>
<bit-menu #appListDropdown>
<button
type="button"
#resendEmailBtn
bitMenuItem
*ngIf="!isSelfHosted && !sponsoringOrg.familySponsorshipValidUntil"
[appApiAction]="resendEmailPromise"
class="dropdown-item btn-submit"
[disabled]="$any(resendEmailBtn).loading"
(click)="resendEmail()"
[attr.aria-label]="'resendEmailLabel' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "resendEmail" | i18n }}</span>
{{ "resendEmail" | i18n }}
</button>
<button
type="button"
#revokeSponsorshipBtn
[appApiAction]="revokeSponsorshipPromise"
class="dropdown-item text-danger btn-submit"
[disabled]="$any(revokeSponsorshipBtn).loading"
bitMenuItem
(click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "remove" | i18n }}</span>
<span class="tw-text-danger">{{ "remove" | i18n }}</span>
</button>
</div>
</div>
</bit-menu>
</td>

View File

@@ -20,10 +20,7 @@ export class SponsoringOrgRowComponent implements OnInit {
@Output() sponsorshipRemoved = new EventEmitter();
statusMessage = "loading";
statusClass: "text-success" | "text-danger" = "text-success";
revokeSponsorshipPromise: Promise<any>;
resendEmailPromise: Promise<any>;
statusClass: "tw-text-success" | "tw-text-danger" = "tw-text-success";
private locale = "";
@@ -48,20 +45,15 @@ export class SponsoringOrgRowComponent implements OnInit {
async revokeSponsorship() {
try {
this.revokeSponsorshipPromise = this.doRevokeSponsorship();
await this.revokeSponsorshipPromise;
await this.doRevokeSponsorship();
} catch (e) {
this.logService.error(e);
}
this.revokeSponsorshipPromise = null;
}
async resendEmail() {
this.resendEmailPromise = this.apiService.postResendSponsorshipOffer(this.sponsoringOrg.id);
await this.resendEmailPromise;
await this.apiService.postResendSponsorshipOffer(this.sponsoringOrg.id);
this.platformUtilsService.showToast("success", null, this.i18nService.t("emailSent"));
this.resendEmailPromise = null;
}
get isSentAwaitingSync() {
@@ -106,31 +98,31 @@ export class SponsoringOrgRowComponent implements OnInit {
"revokeWhenExpired",
formatDate(validUntil, "MM/dd/yyyy", this.locale),
);
this.statusClass = "text-danger";
this.statusClass = "tw-text-danger";
} else if (toDelete) {
// They want to delete and we don't have a valid until date so we can
// this should only happen on a self-hosted install
this.statusMessage = this.i18nService.t("requestRemoved");
this.statusClass = "text-danger";
this.statusClass = "tw-text-danger";
} else if (validUntil) {
// They don't want to delete and they have a valid until date
// that means they are actively sponsoring someone
this.statusMessage = this.i18nService.t("active");
this.statusClass = "text-success";
this.statusClass = "tw-text-success";
} else if (selfHosted && lastSyncDate) {
// We are on a self-hosted install and it has been synced but we have not gotten
// a valid until date so we can't know if they are actively sponsoring someone
this.statusMessage = this.i18nService.t("sent");
this.statusClass = "text-success";
this.statusClass = "tw-text-success";
} else if (!selfHosted) {
// We are in cloud and all other status checks have been false therefore we have
// sent the request but it hasn't been accepted yet
this.statusMessage = this.i18nService.t("sent");
this.statusClass = "text-success";
this.statusClass = "tw-text-success";
} else {
// We are on a self-hosted install and we have not synced yet
this.statusMessage = this.i18nService.t("requested");
this.statusClass = "text-success";
this.statusClass = "tw-text-success";
}
}
}

View File

@@ -12,7 +12,7 @@
<button type="button" bitButton buttonType="danger" (click)="deauthorizeSessions()">
{{ "deauthorizeSessions" | i18n }}
</button>
<button type="button" bitButton buttonType="danger" (click)="purgeVault()">
<button type="button" bitButton buttonType="danger" [bitAction]="purgeVault">
{{ "purgeVault" | i18n }}
</button>
<button type="button" bitButton buttonType="danger" (click)="deleteAccount()">
@@ -21,7 +21,6 @@
</app-danger-zone>
<ng-template #deauthorizeSessionsTemplate></ng-template>
<ng-template #purgeVaultTemplate></ng-template>
<ng-template #deleteAccountTemplate></ng-template>
<ng-template #viewUserApiKeyTemplate></ng-template>
<ng-template #rotateUserApiKeyTemplate></ng-template>

View File

@@ -1,7 +1,9 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.component";
@@ -15,8 +17,6 @@ import { DeleteAccountComponent } from "./delete-account.component";
export class AccountComponent {
@ViewChild("deauthorizeSessionsTemplate", { read: ViewContainerRef, static: true })
deauthModalRef: ViewContainerRef;
@ViewChild("purgeVaultTemplate", { read: ViewContainerRef, static: true })
purgeModalRef: ViewContainerRef;
@ViewChild("deleteAccountTemplate", { read: ViewContainerRef, static: true })
deleteModalRef: ViewContainerRef;
@@ -24,6 +24,7 @@ export class AccountComponent {
constructor(
private modalService: ModalService,
private dialogService: DialogService,
private userVerificationService: UserVerificationService,
) {}
@@ -35,9 +36,10 @@ export class AccountComponent {
await this.modalService.openViewRef(DeauthorizeSessionsComponent, this.deauthModalRef);
}
async purgeVault() {
await this.modalService.openViewRef(PurgeVaultComponent, this.purgeModalRef);
}
purgeVault = async () => {
const dialogRef = PurgeVaultComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed);
};
async deleteAccount() {
await this.modalService.openViewRef(DeleteAccountComponent, this.deleteModalRef);

View File

@@ -1,46 +1,35 @@
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<form
*ngIf="profile && !loading"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="name">{{ "name" | i18n }}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="profile.name" />
<form *ngIf="profile && !loading" [formGroup]="formGroup" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-6">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput formControlName="email" readonly />
</bit-form-field>
</div>
<div class="form-group">
<label for="email">{{ "email" | i18n }}</label>
<input
id="email"
class="form-control"
type="text"
name="Email"
[(ngModel)]="profile.email"
readonly
/>
</div>
</div>
<div class="col-6">
<div class="mb-3">
<div class="tw-col-span-6">
<div class="tw-mb-3">
<dynamic-avatar text="{{ profile | userName }}" [id]="profile.id" [size]="'large'">
</dynamic-avatar>
<button
type="button"
class="btn btn-outline-secondary tw-ml-3.5"
buttonType="secondary"
bitButton
bitFormButton
appStopClick
appStopProp
(click)="openChangeAvatar()"
[bitAction]="openChangeAvatar"
>
<i class="bwi bwi-lg bwi-pencil-square" aria-hidden="true"></i>
Customize
@@ -53,9 +42,6 @@
</app-account-fingerprint>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button bitButton bitFormButton type="submit" buttonType="primary">{{ "save" | i18n }}</button>
</form>
<ng-template #avatarModalTemplate></ng-template>

View File

@@ -1,4 +1,5 @@
import { ViewChild, ViewContainerRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -6,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@@ -21,16 +21,19 @@ export class ProfileComponent implements OnInit, OnDestroy {
profile: ProfileResponse;
fingerprintMaterial: string;
formPromise: Promise<any>;
@ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true })
avatarModalRef: ViewContainerRef;
private destroy$ = new Subject<void>();
protected formGroup = new FormGroup({
name: new FormControl(null),
email: new FormControl(null),
});
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private stateService: StateService,
private modalService: ModalService,
) {}
@@ -39,6 +42,15 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.profile = await this.apiService.getProfile();
this.loading = false;
this.fingerprintMaterial = await this.stateService.getUserId();
this.formGroup.get("name").setValue(this.profile.name);
this.formGroup.get("email").setValue(this.profile.email);
this.formGroup
.get("name")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((name) => {
this.profile.name = name;
});
}
async ngOnDestroy() {
@@ -46,7 +58,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async openChangeAvatar() {
openChangeAvatar = async () => {
const modalOpened = await this.modalService.openViewRef(
ChangeAvatarComponent,
this.avatarModalRef,
@@ -57,16 +69,14 @@ export class ProfileComponent implements OnInit, OnDestroy {
});
},
);
}
};
async submit() {
try {
const request = new UpdateProfileRequest(this.profile.name, this.profile.masterPasswordHint);
this.formPromise = this.apiService.putProfile(request);
await this.formPromise;
submit = async () => {
const request = new UpdateProfileRequest(
this.formGroup.get("name").value,
this.profile.masterPasswordHint,
);
await this.apiService.putProfile(request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("accountUpdated"));
} catch (e) {
this.logService.error(e);
}
}
};
}

View File

@@ -1,11 +1,14 @@
<div class="tw-rounded tw-border tw-border-solid tw-border-warning-600 tw-bg-background">
<div class="tw-bg-warning-600 tw-px-5 tw-py-2.5 tw-font-bold tw-uppercase tw-text-contrast">
<i class="bwi bwi-envelope bwi-fw" aria-hidden="true"></i> {{ "verifyEmail" | i18n }}
</div>
<div class="tw-p-5">
<p>{{ "verifyEmailDesc" | i18n }}</p>
<button id="sendBtn" bitButton type="button" block [bitAction]="send">
<bit-banner bannerType="warning" (onClose)="onDismiss.emit()">
{{ "verifyEmailDesc" | i18n }}
<button
id="sendBtn"
bitLink
linkType="contrast"
bitButton
type="button"
buttonType="unstyled"
[bitAction]="send"
>
{{ "sendEmail" | i18n }}
</button>
</div>
</div>
</bit-banner>

View File

@@ -1,25 +1,29 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AsyncActionsModule, BannerModule, ButtonModule, LinkModule } from "@bitwarden/components";
@Component({
standalone: true,
selector: "app-verify-email",
templateUrl: "verify-email.component.html",
imports: [AsyncActionsModule, BannerModule, ButtonModule, CommonModule, JslibModule, LinkModule],
})
export class VerifyEmailComponent {
actionPromise: Promise<unknown>;
@Output() onVerified = new EventEmitter<boolean>();
@Output() onDismiss = new EventEmitter<void>();
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private tokenService: TokenService,
) {}

View File

@@ -51,8 +51,8 @@
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "chooseYourPlan" | i18n }}</h2>
<div *ngFor="let selectableProduct of selectableProducts">
<bit-radio-group formControlName="product" [block]="true">
<div *ngFor="let selectableProduct of selectableProducts" class="tw-mb-3">
<bit-radio-button [value]="selectableProduct.product" (change)="changedProduct()">
<bit-label>{{ selectableProduct.nameLocalizationKey | i18n }}</bit-label>
<bit-hint class="tw-text-sm"
@@ -147,7 +147,7 @@
</ng-template>
</bit-hint>
</bit-radio-button>
<span *ngIf="selectableProduct.product != productTypes.Free">
<span *ngIf="selectableProduct.product != productTypes.Free" class="tw-pl-4">
<ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
>
@@ -176,6 +176,7 @@
!selectableProduct.PasswordManager.basePrice &&
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
class="tw-pl-4"
>
{{
"costPerUser"
@@ -188,11 +189,11 @@
}}
/{{ "month" | i18n }}
</span>
<span *ngIf="selectableProduct.product == productTypes.Free">{{
<span *ngIf="selectableProduct.product == productTypes.Free" class="tw-pl-4">{{
"freeForever" | i18n
}}</span>
</bit-radio-group>
</div>
</bit-radio-group>
</bit-section>
<bit-section *ngIf="formGroup.value.product !== productTypes.Free">
<bit-section
@@ -277,9 +278,10 @@
</bit-form-control>
</div>
</bit-section>
<bit-section *ngFor="let selectablePlan of selectablePlans">
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
<bit-radio-group formControlName="plan">
<div *ngFor="let selectablePlan of selectablePlans">
<bit-radio-button
type="radio"
id="interval{{ selectablePlan.type }}"
@@ -397,6 +399,7 @@
</p>
</bit-hint>
</bit-radio-button>
</div>
</bit-radio-group>
</bit-section>
</bit-section>

View File

@@ -0,0 +1,61 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'addCredit' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "creditDelayed" | i18n }}</p>
<div class="tw-grid tw-grid-cols-2">
<bit-radio-group formControlName="method">
<bit-radio-button id="credit-method-paypal" [value]="paymentMethodType.PayPal">
<bit-label> <i class="bwi bwi-paypal"></i>PayPal</bit-label>
</bit-radio-button>
<bit-radio-button id="credit-method-bitcoin" [value]="paymentMethodType.BitPay">
<bit-label> <i class="bwi bwi-bitcoin"></i>Bitcoin</bit-label>
</bit-radio-button>
</bit-radio-group>
</div>
<div class="tw-grid tw-grid-cols-2">
<bit-form-field>
<bit-label>{{ "amount" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="creditAmount"
(blur)="formatAmount()"
required
/>
<span bitPrefix>$USD</span>
</bit-form-field>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitDialogClose]="DialogResult.Cancelled"
>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>
<form #ppButtonForm action="{{ ppButtonFormAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ ppButtonBusinessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ formGroup.get('creditAmount').value }}" />
<input type="hidden" name="custom" value="{{ ppButtonCustomField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ subject }}" />
</form>

View File

@@ -1,12 +1,6 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -17,6 +11,16 @@ import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/b
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
export interface AddCreditDialogData {
organizationId: string;
}
export enum AddCreditDialogResult {
Added = "added",
Cancelled = "cancelled",
}
export type PayPalConfig = {
businessId?: string;
@@ -24,17 +28,9 @@ export type PayPalConfig = {
};
@Component({
selector: "app-add-credit",
templateUrl: "add-credit.component.html",
templateUrl: "add-credit-dialog.component.html",
})
export class AddCreditComponent implements OnInit {
@Input() creditAmount: string;
@Input() showOptions = true;
@Input() method = PaymentMethodType.PayPal;
@Input() organizationId: string;
@Output() onAdded = new EventEmitter();
@Output() onCanceled = new EventEmitter();
export class AddCreditDialogComponent implements OnInit {
@ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef;
paymentMethodType = PaymentMethodType;
@@ -44,14 +40,22 @@ export class AddCreditComponent implements OnInit {
ppLoading = false;
subject: string;
returnUrl: string;
formPromise: Promise<any>;
organizationId: string;
private userId: string;
private name: string;
private email: string;
private region: string;
protected DialogResult = AddCreditDialogResult;
protected formGroup = new FormGroup({
method: new FormControl(PaymentMethodType.PayPal),
creditAmount: new FormControl(null, [Validators.required]),
});
constructor(
private dialogRef: DialogRef,
@Inject(DIALOG_DATA) protected data: AddCreditDialogData,
private accountService: AccountService,
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@@ -59,6 +63,7 @@ export class AddCreditComponent implements OnInit {
private logService: LogService,
private configService: ConfigService,
) {
this.organizationId = data.organizationId;
const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig;
this.ppButtonFormAction = payPalConfig.buttonAction;
this.ppButtonBusinessId = payPalConfig.businessId;
@@ -93,7 +98,18 @@ export class AddCreditComponent implements OnInit {
this.returnUrl = window.location.href;
}
async submit() {
get creditAmount() {
return this.formGroup.value.creditAmount;
}
set creditAmount(value: string) {
this.formGroup.get("creditAmount").setValue(value);
}
get method() {
return this.formGroup.value.method;
}
submit = async () => {
if (this.creditAmount == null || this.creditAmount === "") {
return;
}
@@ -104,7 +120,6 @@ export class AddCreditComponent implements OnInit {
return;
}
if (this.method === PaymentMethodType.BitPay) {
try {
const req = new BitPayInvoiceRequest();
req.email = this.email;
req.name = this.name;
@@ -113,24 +128,12 @@ export class AddCreditComponent implements OnInit {
req.organizationId = this.organizationId;
req.userId = this.userId;
req.returnUrl = this.returnUrl;
this.formPromise = this.apiService.postBitPayInvoice(req);
const bitPayUrl: string = await this.formPromise;
const bitPayUrl: string = await this.apiService.postBitPayInvoice(req);
this.platformUtilsService.launchUri(bitPayUrl);
} catch (e) {
this.logService.error(e);
}
return;
}
try {
this.onAdded.emit();
} catch (e) {
this.logService.error(e);
}
}
cancel() {
this.onCanceled.emit();
}
this.dialogRef.close(AddCreditDialogResult.Added);
};
formatAmount() {
try {
@@ -160,3 +163,15 @@ export class AddCreditComponent implements OnInit {
return null;
}
}
/**
* Strongly typed helper to open a AddCreditDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openAddCreditDialog(
dialogService: DialogService,
config: DialogConfig<AddCreditDialogData>,
) {
return dialogService.open<AddCreditDialogResult>(AddCreditDialogComponent, config);
}

View File

@@ -1,80 +0,0 @@
<form #form class="card" (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="card-body">
<button type="button" class="close" appA11yTitle="{{ 'cancel' | i18n }}" (click)="cancel()">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="card-body-header">{{ "addCredit" | i18n }}</h3>
<div class="mb-4 text-lg" *ngIf="showOptions">
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="Method"
id="credit-method-paypal"
[value]="paymentMethodType.PayPal"
[(ngModel)]="method"
/>
<label class="form-check-label" for="credit-method-paypal">
<i class="bwi bwi-fw bwi-paypal" aria-hidden="true"></i> PayPal</label
>
</div>
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
name="Method"
id="credit-method-bitcoin"
[value]="paymentMethodType.BitPay"
[(ngModel)]="method"
/>
<label class="form-check-label" for="credit-method-bitcoin">
<i class="bwi bwi-fw bwi-bitcoin" aria-hidden="true"></i> Bitcoin</label
>
</div>
</div>
<div class="form-group">
<div class="row">
<div class="col-4">
<label for="creditAmount">{{ "amount" | i18n }}</label>
<div class="input-group">
<div class="input-group-prepend"><span class="input-group-text">$USD</span></div>
<input
id="creditAmount"
class="form-control"
type="text"
name="CreditAmount"
[(ngModel)]="creditAmount"
(blur)="formatAmount()"
required
/>
</div>
</div>
</div>
<small class="form-text text-muted">{{ "creditDelayed" | i18n }}</small>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading || ppLoading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "submit" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</div>
</form>
<form #ppButtonForm action="{{ ppButtonFormAction }}" method="post" target="_top">
<input type="hidden" name="cmd" value="_xclick" />
<input type="hidden" name="business" value="{{ ppButtonBusinessId }}" />
<input type="hidden" name="button_subtype" value="services" />
<input type="hidden" name="no_note" value="1" />
<input type="hidden" name="no_shipping" value="1" />
<input type="hidden" name="rm" value="1" />
<input type="hidden" name="return" value="{{ returnUrl }}" />
<input type="hidden" name="cancel_return" value="{{ returnUrl }}" />
<input type="hidden" name="currency_code" value="USD" />
<input type="hidden" name="image_url" value="https://bitwarden.com/images/paypal-banner.png" />
<input type="hidden" name="bn" value="PP-BuyNowBF:btn_buynow_LG.gif:NonHosted" />
<input type="hidden" name="amount" value="{{ creditAmount }}" />
<input type="hidden" name="custom" value="{{ ppButtonCustomField }}" />
<input type="hidden" name="item_name" value="Bitwarden Account Credit" />
<input type="hidden" name="item_number" value="{{ subject }}" />
</form>

View File

@@ -3,7 +3,7 @@ import { NgModule } from "@angular/core";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { AddCreditComponent } from "./add-credit.component";
import { AddCreditDialogComponent } from "./add-credit-dialog.component";
import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog.component";
import { AdjustStorageComponent } from "./adjust-storage.component";
import { BillingHistoryComponent } from "./billing-history.component";
@@ -17,7 +17,7 @@ import { UpdateLicenseComponent } from "./update-license.component";
@NgModule({
imports: [SharedModule, PaymentComponent, TaxInfoComponent, HeaderModule],
declarations: [
AddCreditComponent,
AddCreditDialogComponent,
AdjustPaymentDialogComponent,
AdjustStorageComponent,
BillingHistoryComponent,

View File

@@ -33,22 +33,9 @@
<strong>{{ creditOrBalance | currency: "$" }}</strong>
</p>
<p>{{ "creditAppliedDesc" | i18n }}</p>
<button
type="button"
bitButton
buttonType="secondary"
(click)="addCredit()"
*ngIf="!showAddCredit"
>
<button type="button" bitButton buttonType="secondary" [bitAction]="addCredit">
{{ "addCredit" | i18n }}
</button>
<app-add-credit
[organizationId]="organizationId"
(onAdded)="closeAddCredit(true)"
(onCanceled)="closeAddCredit(false)"
*ngIf="showAddCredit"
>
</app-add-credit>
<h2 class="spaced-header">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!paymentSource">{{ "noPaymentMethod" | i18n }}</p>
<ng-container *ngIf="paymentSource">

View File

@@ -15,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import {
AdjustPaymentDialogResult,
openAdjustPaymentDialog,
@@ -30,7 +31,6 @@ export class PaymentMethodComponent implements OnInit {
loading = false;
firstLoaded = false;
showAddCredit = false;
billing: BillingPaymentResponse;
org: OrganizationSubscriptionResponse;
sub: SubscriptionResponse;
@@ -111,18 +111,17 @@ export class PaymentMethodComponent implements OnInit {
this.loading = false;
}
addCredit() {
this.showAddCredit = true;
}
closeAddCredit(load: boolean) {
this.showAddCredit = false;
if (load) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
}
addCredit = async () => {
const dialogRef = openAddCreditDialog(this.dialogService, {
data: {
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AddCreditDialogResult.Added) {
await this.load();
}
};
changePayment = async () => {
const dialogRef = openAdjustPaymentDialog(this.dialogService, {

View File

@@ -1,17 +0,0 @@
<div class="tw-rounded tw-border tw-border-solid tw-border-warning-600 tw-bg-background">
<div class="tw-bg-warning-600 tw-px-5 tw-py-2.5 tw-font-bold tw-uppercase tw-text-contrast">
<i class="bwi bwi-exclamation-triangle bwi-fw" aria-hidden="true"></i>
{{ "lowKdfIterations" | i18n }}
</div>
<div class="tw-p-5">
<p>{{ "updateLowKdfIterationsDesc" | i18n }}</p>
<a
bitButton
buttonType="secondary"
[block]="true"
routerLink="/settings/security/security-keys"
>
{{ "updateKdfSettings" | i18n }}
</a>
</div>
</div>

View File

@@ -1,7 +0,0 @@
import { Component } from "@angular/core";
@Component({
selector: "app-low-kdf",
templateUrl: "low-kdf.component.html",
})
export class LowKdfComponent {}

View File

@@ -53,7 +53,6 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor-setup.compo
import { TwoFactorVerifyComponent } from "../auth/settings/two-factor-verify.component";
import { TwoFactorWebAuthnComponent } from "../auth/settings/two-factor-webauthn.component";
import { TwoFactorYubiKeyComponent } from "../auth/settings/two-factor-yubikey.component";
import { VerifyEmailComponent } from "../auth/settings/verify-email.component";
import { UserVerificationModule } from "../auth/shared/components/user-verification";
import { SsoComponent } from "../auth/sso.component";
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
@@ -70,7 +69,6 @@ import { HeaderModule } from "../layouts/header/header.module";
import { ProductSwitcherModule } from "../layouts/product-switcher/product-switcher.module";
import { UserLayoutComponent } from "../layouts/user-layout.component";
import { DomainRulesComponent } from "../settings/domain-rules.component";
import { LowKdfComponent } from "../settings/low-kdf.component";
import { PreferencesComponent } from "../settings/preferences.component";
import { VaultTimeoutInputComponent } from "../settings/vault-timeout-input.component";
import { GeneratorComponent } from "../tools/generator.component";
@@ -186,11 +184,9 @@ import { SharedModule } from "./shared.module";
UpdatePasswordComponent,
UpdateTempPasswordComponent,
VaultTimeoutInputComponent,
VerifyEmailComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
VerifyRecoverDeleteProviderComponent,
LowKdfComponent,
],
exports: [
UserVerificationModule,
@@ -264,11 +260,9 @@ import { SharedModule } from "./shared.module";
UpdateTempPasswordComponent,
UserLayoutComponent,
VaultTimeoutInputComponent,
VerifyEmailComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
VerifyRecoverDeleteProviderComponent,
LowKdfComponent,
HeaderModule,
DangerZoneComponent,
],

View File

@@ -1,4 +1,4 @@
import { Component } from "@angular/core";
import { Component, NgZone } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component";
@@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import { DialogService } from "@bitwarden/components";
@@ -21,23 +20,23 @@ export class GeneratorComponent extends BaseGeneratorComponent {
constructor(
passwordGenerationService: PasswordGenerationServiceAbstraction,
usernameGenerationService: UsernameGenerationServiceAbstraction,
stateService: StateService,
accountService: AccountService,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
logService: LogService,
route: ActivatedRoute,
ngZone: NgZone,
private dialogService: DialogService,
accountService: AccountService,
) {
super(
passwordGenerationService,
usernameGenerationService,
platformUtilsService,
stateService,
accountService,
i18nService,
logService,
route,
accountService,
ngZone,
window,
);
if (platformUtilsService.isSelfHost()) {

View File

@@ -1,11 +1,9 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -26,7 +24,6 @@ export class ExportComponent extends BaseExportComponent {
eventCollectionService: EventCollectionService,
policyService: PolicyService,
logService: LogService,
userVerificationService: UserVerificationService,
formBuilder: UntypedFormBuilder,
fileDownloadService: FileDownloadService,
dialogService: DialogService,
@@ -39,84 +36,10 @@ export class ExportComponent extends BaseExportComponent {
eventCollectionService,
policyService,
logService,
userVerificationService,
formBuilder,
fileDownloadService,
dialogService,
organizationService,
);
}
submit = async () => {
if (this.isFileEncryptedExport && this.filePassword != this.confirmFilePassword) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("filePasswordAndConfirmFilePasswordDoNotMatch"),
);
return;
}
this.exportForm.markAllAsTouched();
if (this.exportForm.invalid) {
return;
}
if (this.disabledByPolicy) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("personalVaultExportPolicyInEffect"),
);
return;
}
const userVerified = await this.verifyUser();
if (!userVerified) {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.doExport();
};
protected saved() {
super.saved();
this.platformUtilsService.showToast("success", null, this.i18nService.t("exportSuccess"));
}
private async verifyUser(): Promise<boolean> {
let confirmDescription = "exportWarningDesc";
if (this.isFileEncryptedExport) {
confirmDescription = "fileEncryptedExportWarningDesc";
} else if (this.isAccountEncryptedExport) {
confirmDescription = "encExportKeyWarningDesc";
}
const result = await UserVerificationDialogComponent.open(this.dialogService, {
title: "confirmVaultExport",
bodyText: confirmDescription,
confirmButtonOptions: {
text: "exportVault",
type: "primary",
},
});
// Handle the result of the dialog based on user action and verification success
if (result.userAction === "cancel") {
// User cancelled the dialog
return false;
}
// User confirmed the dialog so check verification success
if (!result.verificationSuccess) {
if (result.noAvailableClientVerificationMethods) {
// No client-side verification methods are available
// Could send user to configure a verification method like PIN or biometrics
}
return false;
}
return true;
}
}

View File

@@ -85,14 +85,26 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageUsers;
const allowAdminAccessToAllCollectionItems =
!flexibleCollectionsV1Enabled || org.allowAdminAccessToAllCollectionItems;
return (
(org.permissions.manageUsers && allowAdminAccessToAllCollectionItems) ||
this.canEdit(org, flexibleCollectionsV1Enabled)
);
}
/**
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups;
const allowAdminAccessToAllCollectionItems =
!flexibleCollectionsV1Enabled || org.allowAdminAccessToAllCollectionItems;
return (
(org.permissions.manageGroups && allowAdminAccessToAllCollectionItems) ||
this.canEdit(org, flexibleCollectionsV1Enabled)
);
}
/**

View File

@@ -0,0 +1,265 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
PREMIUM_BANNER_REPROMPT_KEY,
VaultBannersService,
VisibleVaultBanner,
} from "./vault-banners.service";
describe("VaultBannersService", () => {
let service: VaultBannersService;
const isSelfHost = jest.fn().mockReturnValue(false);
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
const getEmailVerified = jest.fn().mockResolvedValue(true);
const hasMasterPassword = jest.fn().mockResolvedValue(true);
const getKdfConfig = jest
.fn()
.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 });
const getLastSync = jest.fn().mockResolvedValue(null);
beforeEach(() => {
jest.useFakeTimers();
getLastSync.mockClear().mockResolvedValue(new Date("2024-05-14"));
isSelfHost.mockClear();
getEmailVerified.mockClear().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
VaultBannersService,
{
provide: PlatformUtilsService,
useValue: { isSelfHost },
},
{
provide: BillingAccountProfileStateService,
useValue: { hasPremiumFromAnySource$: hasPremiumFromAnySource$ },
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: PlatformUtilsService,
useValue: { isSelfHost },
},
{
provide: TokenService,
useValue: { getEmailVerified },
},
{
provide: UserVerificationService,
useValue: { hasMasterPassword },
},
{
provide: KdfConfigService,
useValue: { getKdfConfig },
},
{
provide: SyncService,
useValue: { getLastSync },
},
],
});
});
afterEach(() => {
jest.useRealTimers();
});
describe("Premium", () => {
it("waits until sync is completed before showing premium banner", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(false);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(true);
});
it("does not show a premium banner for self-hosted users", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(true);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
});
it("does not show a premium banner when they have access to premium", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(true);
isSelfHost.mockReturnValue(false);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
});
describe("dismissing", () => {
beforeEach(async () => {
jest.useFakeTimers();
const date = new Date("2023-06-08");
date.setHours(0, 0, 0, 0);
jest.setSystemTime(date.getTime());
service = TestBed.inject(VaultBannersService);
await service.dismissBanner(VisibleVaultBanner.Premium);
});
afterEach(() => {
jest.useRealTimers();
});
it("updates state on first dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneWeekLater = new Date("2023-06-15");
oneWeekLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 1,
nextPromptDate: oneWeekLater.getTime(),
});
});
it("updates state on second dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneMonthLater = new Date("2023-07-08");
oneMonthLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 2,
nextPromptDate: oneMonthLater.getTime(),
});
});
it("updates state on third dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneYearLater = new Date("2024-06-08");
oneYearLater.setHours(0, 0, 0, 0);
expect(state).toEqual({
numberOfDismissals: 3,
nextPromptDate: oneYearLater.getTime(),
});
});
});
});
describe("KDFSettings", () => {
beforeEach(async () => {
hasMasterPassword.mockResolvedValue(true);
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 });
});
it("shows low KDF iteration banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(true);
});
it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => {
getKdfConfig.mockResolvedValue({ kdfType: KdfType.Argon2id, iterations: 600001 });
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
});
it("does not show low KDF for iterations about 600,000", async () => {
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 });
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
});
it("dismisses low KDF iteration banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(true);
await service.dismissBanner(VisibleVaultBanner.KDFSettings);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
});
});
describe("OutdatedBrowser", () => {
beforeEach(async () => {
// Hardcode `MSIE` in userAgent string
const userAgent = "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 MSIE";
Object.defineProperty(navigator, "userAgent", {
configurable: true,
get: () => userAgent,
});
});
it("shows outdated browser banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
});
it("dismisses outdated browser banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
await service.dismissBanner(VisibleVaultBanner.OutdatedBrowser);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(false);
});
});
describe("VerifyEmail", () => {
beforeEach(async () => {
getEmailVerified.mockResolvedValue(false);
});
it("shows verify email banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
});
it("dismisses verify email banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
await service.dismissBanner(VisibleVaultBanner.VerifyEmail);
expect(await service.shouldShowVerifyEmailBanner()).toBe(false);
});
});
});

View File

@@ -0,0 +1,215 @@
import { Injectable } from "@angular/core";
import { Subject, Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { mergeMap, take } from "rxjs/operators";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums";
import {
StateProvider,
ActiveUserState,
KeyDefinition,
PREMIUM_BANNER_DISK_LOCAL,
BANNERS_DISMISSED_DISK,
} from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
export enum VisibleVaultBanner {
KDFSettings = "kdf-settings",
OutdatedBrowser = "outdated-browser",
Premium = "premium",
VerifyEmail = "verify-email",
}
type PremiumBannerReprompt = {
numberOfDismissals: number;
/** Timestamp representing when to show the prompt next */
nextPromptDate: number;
};
/** Banners that will be re-shown on a new session */
type SessionBanners = Omit<VisibleVaultBanner, VisibleVaultBanner.Premium>;
export const PREMIUM_BANNER_REPROMPT_KEY = new KeyDefinition<PremiumBannerReprompt>(
PREMIUM_BANNER_DISK_LOCAL,
"bannerReprompt",
{
deserializer: (bannerReprompt) => bannerReprompt,
},
);
export const BANNERS_DISMISSED_DISK_KEY = new KeyDefinition<SessionBanners[]>(
BANNERS_DISMISSED_DISK,
"bannersDismissed",
{
deserializer: (bannersDismissed) => bannersDismissed,
},
);
@Injectable()
export class VaultBannersService {
shouldShowPremiumBanner$: Observable<boolean>;
private premiumBannerState: ActiveUserState<PremiumBannerReprompt>;
private sessionBannerState: ActiveUserState<SessionBanners[]>;
/**
* Emits when the sync service has completed a sync
*
* This is needed because `hasPremiumFromAnySource$` will emit false until the sync is completed
* resulting in the premium banner being shown briefly on startup when the user has access to
* premium features.
*/
private syncCompleted$ = new Subject<void>();
constructor(
private tokenService: TokenService,
private userVerificationService: UserVerificationService,
private stateProvider: StateProvider,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private platformUtilsService: PlatformUtilsService,
private kdfConfigService: KdfConfigService,
private syncService: SyncService,
) {
this.pollUntilSynced();
this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY);
this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY);
const premiumSources$ = combineLatest([
this.billingAccountProfileStateService.hasPremiumFromAnySource$,
this.premiumBannerState.state$,
]);
this.shouldShowPremiumBanner$ = this.syncCompleted$.pipe(
take(1), // Wait until the first sync is complete before considering the premium status
mergeMap(() => premiumSources$),
map(([canAccessPremium, dismissedState]) => {
const shouldShowPremiumBanner =
!canAccessPremium && !this.platformUtilsService.isSelfHost();
// Check if nextPromptDate is in the past passed
if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) {
const nextPromptDate = new Date(dismissedState.nextPromptDate);
const now = new Date();
return now >= nextPromptDate;
}
return shouldShowPremiumBanner;
}),
);
}
/** Returns true when the update browser banner should be shown */
async shouldShowUpdateBrowserBanner(): Promise<boolean> {
const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1;
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
VisibleVaultBanner.OutdatedBrowser,
);
return outdatedBrowser && !alreadyDismissed;
}
/** Returns true when the verify email banner should be shown */
async shouldShowVerifyEmailBanner(): Promise<boolean> {
const needsVerification = !(await this.tokenService.getEmailVerified());
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
VisibleVaultBanner.VerifyEmail,
);
return needsVerification && !alreadyDismissed;
}
/** Returns true when the low KDF iteration banner should be shown */
async shouldShowLowKDFBanner(): Promise<boolean> {
const hasLowKDF = (await this.userVerificationService.hasMasterPassword())
? await this.isLowKdfIteration()
: false;
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
VisibleVaultBanner.KDFSettings,
);
return hasLowKDF && !alreadyDismissed;
}
/** Dismiss the given banner and perform any respective side effects */
async dismissBanner(banner: SessionBanners): Promise<void> {
if (banner === VisibleVaultBanner.Premium) {
await this.dismissPremiumBanner();
} else {
await this.sessionBannerState.update((current) => {
const bannersDismissed = current ?? [];
return [...bannersDismissed, banner];
});
}
}
/** Returns banners that have already been dismissed */
private async getBannerDismissedState(): Promise<SessionBanners[]> {
// `state$` can emit null when a value has not been set yet,
// use nullish coalescing to default to an empty array
return (await firstValueFrom(this.sessionBannerState.state$)) ?? [];
}
/** Increment dismissal state of the premium banner */
private async dismissPremiumBanner(): Promise<void> {
await this.premiumBannerState.update((current) => {
const numberOfDismissals = current?.numberOfDismissals ?? 0;
const now = new Date();
// Set midnight of the current day
now.setHours(0, 0, 0, 0);
// First dismissal, re-prompt in 1 week
if (numberOfDismissals === 0) {
now.setDate(now.getDate() + 7);
return {
numberOfDismissals: 1,
nextPromptDate: now.getTime(),
};
}
// Second dismissal, re-prompt in 1 month
if (numberOfDismissals === 1) {
now.setMonth(now.getMonth() + 1);
return {
numberOfDismissals: 2,
nextPromptDate: now.getTime(),
};
}
// 3+ dismissals, re-prompt each year
// Avoid day/month edge cases and only increment year
const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
nextYear.setHours(0, 0, 0, 0);
return {
numberOfDismissals: numberOfDismissals + 1,
nextPromptDate: nextYear.getTime(),
};
});
}
private async isLowKdfIteration() {
const kdfConfig = await this.kdfConfigService.getKdfConfig();
return (
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
kdfConfig.iterations < PBKDF2_ITERATIONS.defaultValue
);
}
/** Poll the `syncService` until a sync is completed */
private pollUntilSynced() {
const interval = setInterval(async () => {
const lastSync = await this.syncService.getLastSync();
if (lastSync !== null) {
clearInterval(interval);
this.syncCompleted$.next();
}
}, 200);
}
}

View File

@@ -0,0 +1,52 @@
<bit-banner
id="update-browser-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="warning"
*ngIf="visibleBanners.includes(VisibleVaultBanner.OutdatedBrowser)"
(onClose)="dismissBanner(VisibleVaultBanner.OutdatedBrowser)"
>
{{ "updateBrowserDesc" | i18n }}
<a
bitLink
linkType="contrast"
target="_blank"
href="https://browser-update.org/update-browser.html"
rel="noreferrer noopener"
>
{{ "updateBrowser" | i18n }}
</a>
</bit-banner>
<bit-banner
id="kdf-settings-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="warning"
*ngIf="visibleBanners.includes(VisibleVaultBanner.KDFSettings)"
(onClose)="dismissBanner(VisibleVaultBanner.KDFSettings)"
>
{{ "lowKDFIterationsBanner" | i18n }}
<a bitLink linkType="contrast" routerLink="/settings/security/security-keys">
{{ "changeKDFSettings" | i18n }}
</a>
</bit-banner>
<app-verify-email
id="verify-email-banner"
*ngIf="visibleBanners.includes(VisibleVaultBanner.VerifyEmail)"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
(onDismiss)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
(onVerified)="dismissBanner(VisibleVaultBanner.VerifyEmail)"
></app-verify-email>
<bit-banner
id="premium-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="premium"
*ngIf="premiumBannerVisible$ | async"
(onClose)="dismissBanner(VisibleVaultBanner.Premium)"
>
{{ "premiumUpgradeUnlockFeatures" | i18n }}
<a bitLink linkType="contrast" routerLink="/settings/subscription/premium">
{{ "goPremium" | i18n }}
</a>
</bit-banner>

View File

@@ -0,0 +1,140 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BannerComponent, BannerModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
import { LooseComponentsModule } from "../../../shared";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
import { VaultBannersComponent } from "./vault-banners.component";
describe("VaultBannersComponent", () => {
let component: VaultBannersComponent;
let fixture: ComponentFixture<VaultBannersComponent>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const bannerService = mock<VaultBannersService>({
shouldShowPremiumBanner$: premiumBanner$,
shouldShowUpdateBrowserBanner: jest.fn(),
shouldShowVerifyEmailBanner: jest.fn(),
shouldShowLowKDFBanner: jest.fn(),
dismissBanner: jest.fn(),
});
beforeEach(async () => {
bannerService.shouldShowPremiumBanner$ = premiumBanner$;
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
await TestBed.configureTestingModule({
imports: [BannerModule, LooseComponentsModule, VerifyEmailComponent],
declarations: [VaultBannersComponent, I18nPipe],
providers: [
{
provide: VaultBannersService,
useValue: bannerService,
},
{
provide: I18nService,
useValue: mock<I18nService>({ t: (key) => key }),
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
{
provide: TokenService,
useValue: mock<TokenService>(),
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(VaultBannersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe("premiumBannerVisible$", () => {
it("shows premium banner", async () => {
premiumBanner$.next(true);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner.componentInstance.bannerType).toBe("premium");
});
it("dismisses premium banner", async () => {
premiumBanner$.next(false);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner).toBeNull();
});
});
describe("determineVisibleBanner", () => {
[
{
name: "OutdatedBrowser",
method: bannerService.shouldShowUpdateBrowserBanner,
banner: VisibleVaultBanner.OutdatedBrowser,
},
{
name: "VerifyEmail",
method: bannerService.shouldShowVerifyEmailBanner,
banner: VisibleVaultBanner.VerifyEmail,
},
{
name: "LowKDF",
method: bannerService.shouldShowLowKDFBanner,
banner: VisibleVaultBanner.KDFSettings,
},
].forEach(({ name, method, banner }) => {
describe(name, () => {
beforeEach(async () => {
method.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
});
it(`shows ${name} banner`, async () => {
expect(component.visibleBanners).toEqual([banner]);
});
it(`dismisses ${name} banner`, async () => {
const dismissButton = fixture.debugElement.nativeElement.querySelector(
'button[biticonbutton="bwi-close"]',
);
// Mock out the banner service returning false after dismissing
method.mockResolvedValue(false);
dismissButton.dispatchEvent(new Event("click"));
expect(bannerService.dismissBanner).toHaveBeenCalledWith(banner);
expect(component.visibleBanners).toEqual([]);
});
});
});
});
});

View File

@@ -0,0 +1,41 @@
import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs";
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
@Component({
selector: "app-vault-banners",
templateUrl: "./vault-banners.component.html",
})
export class VaultBannersComponent implements OnInit {
visibleBanners: VisibleVaultBanner[] = [];
premiumBannerVisible$: Observable<boolean>;
VisibleVaultBanner = VisibleVaultBanner;
constructor(private vaultBannerService: VaultBannersService) {
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
}
async ngOnInit(): Promise<void> {
await this.determineVisibleBanners();
}
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
await this.vaultBannerService.dismissBanner(banner);
await this.determineVisibleBanners();
}
/** Determine which banners should be present */
private async determineVisibleBanners(): Promise<void> {
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner();
const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner();
this.visibleBanners = [
showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null,
showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null,
showLowKdf ? VisibleVaultBanner.KDFSettings : null,
].filter(Boolean); // remove all falsy values, i.e. null
}
}

View File

@@ -1,3 +1,5 @@
<app-vault-banners></app-vault-banners>
<app-vault-header
[filter]="filter"
[loading]="refreshing && !performingInitialLoad"
@@ -14,8 +16,8 @@
<app-vault-onboarding [ciphers]="ciphers" [orgs]="allOrganizations" (onAddCipher)="addCipher()">
</app-vault-onboarding>
<div class="row">
<div class="col-3">
<div class="tw-flex tw-flex-row -tw-mx-2.5">
<div class="tw-basis-1/4 tw-max-w-1/4 tw-px-2.5">
<div class="groupings">
<div class="content">
<div class="inner-content">
@@ -30,7 +32,7 @@
</div>
</div>
</div>
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
{{ trashCleanupWarning }}
</app-callout>
@@ -81,44 +83,6 @@
</button>
</div>
</div>
<div class="col-3">
<app-low-kdf class="d-block mb-4" *ngIf="showLowKdf"> </app-low-kdf>
<app-verify-email
*ngIf="showVerifyEmail"
class="d-block mb-4"
(onVerified)="emailVerified($event)"
></app-verify-email>
<div class="card border-warning mb-4" *ngIf="showBrowserOutdated">
<div class="card-header bg-warning text-white">
<i class="bwi bwi-exclamation-triangle bwi-fw" aria-hidden="true"></i>
{{ "updateBrowser" | i18n }}
</div>
<div class="card-body">
<p>{{ "updateBrowserDesc" | i18n }}</p>
<a
class="btn btn-block btn-outline-secondary"
target="_blank"
href="https://browser-update.org/update-browser.html"
rel="noreferrer"
>
{{ "updateBrowser" | i18n }}
</a>
</div>
</div>
<div class="card border-success mb-4" *ngIf="showPremiumCallout">
<div class="card-header bg-success text-white">
<i class="bwi bwi-star-f bwi-fw" aria-hidden="true"></i> {{ "goPremium" | i18n }}
</div>
<div class="card-body">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<a class="btn btn-block btn-outline-secondary" routerLink="/settings/subscription/premium">
{{ "goPremium" | i18n }}
</a>
</div>
</div>
</div>
</div>
<ng-template #attachments></ng-template>

View File

@@ -35,9 +35,6 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -47,7 +44,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
@@ -122,10 +118,6 @@ export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
collectionsModalRef: ViewContainerRef;
showVerifyEmail = false;
showBrowserOutdated = false;
showPremiumCallout = false;
showLowKdf = false;
trashCleanupWarning: string = null;
kdfIterations: number;
activeFilter: VaultFilter = new VaultFilter();
@@ -161,7 +153,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private i18nService: I18nService,
private modalService: ModalService,
private dialogService: DialogService,
private tokenService: TokenService,
private messagingService: MessagingService,
private platformUtilsService: PlatformUtilsService,
private broadcasterService: BroadcasterService,
@@ -180,14 +171,11 @@ export class VaultComponent implements OnInit, OnDestroy {
private searchPipe: SearchPipe,
private configService: ConfigService,
private apiService: ApiService,
private userVerificationService: UserVerificationService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
protected kdfConfigService: KdfConfigService,
) {}
async ngOnInit() {
this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1;
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
@@ -197,18 +185,8 @@ export class VaultComponent implements OnInit, OnDestroy {
const firstSetup$ = this.route.queryParams.pipe(
first(),
switchMap(async (params: Params) => {
this.showVerifyEmail = !(await this.tokenService.getEmailVerified());
this.showLowKdf = (await this.userVerificationService.hasMasterPassword())
? await this.isLowKdfIteration()
: false;
await this.syncService.fullSync(false);
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$,
);
this.showPremiumCallout =
!this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost();
const cipherId = getCipherIdFromParams(params);
if (!cipherId) {
return;
@@ -412,16 +390,6 @@ export class VaultComponent implements OnInit, OnDestroy {
);
}
get isShowingCards() {
return (
this.showBrowserOutdated || this.showPremiumCallout || this.showVerifyEmail || this.showLowKdf
);
}
emailVerified(verified: boolean) {
this.showVerifyEmail = !verified;
}
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.destroy$.next();
@@ -1005,14 +973,6 @@ export class VaultComponent implements OnInit, OnDestroy {
: this.cipherService.softDeleteWithServer(id);
}
async isLowKdfIteration() {
const kdfConfig = await this.kdfConfigService.getKdfConfig();
return (
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
kdfConfig.iterations < PBKDF2_ITERATIONS.defaultValue
);
}
protected async repromptCipher(ciphers: CipherView[]) {
const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None);

View File

@@ -1,7 +1,8 @@
import { NgModule } from "@angular/core";
import { BreadcrumbsModule } from "@bitwarden/components";
import { BannerModule, BreadcrumbsModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../auth/settings/verify-email.component";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { CollectionDialogModule } from "../components/collection-dialog";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
@@ -11,6 +12,8 @@ import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module";
import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module";
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
import { PipesModule } from "./pipes/pipes.module";
import { VaultBannersService } from "./vault-banners/services/vault-banners.service";
import { VaultBannersComponent } from "./vault-banners/vault-banners.component";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./vault-onboarding/services/abstraction/vault-onboarding.service";
@@ -34,10 +37,13 @@ import { VaultComponent } from "./vault.component";
VaultItemsModule,
CollectionDialogModule,
VaultOnboardingComponent,
BannerModule,
VerifyEmailComponent,
],
declarations: [VaultComponent, VaultHeaderComponent],
declarations: [VaultComponent, VaultHeaderComponent, VaultBannersComponent],
exports: [VaultComponent],
providers: [
VaultBannersService,
{
provide: VaultOnboardingServiceAbstraction,
useClass: VaultOnboardingService,

View File

@@ -1,38 +1,19 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="purgeVaultTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="purgeVaultTitle">{{ "purgeVault" | i18n }}</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ (organizationId ? "purgeOrgVaultDesc" : "purgeVaultDesc") | i18n }}</p>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'purgeVault' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">
{{ (organizationId ? "purgeOrgVaultDesc" : "purgeVaultDesc") | i18n }}
</p>
<app-callout type="warning">{{ "purgeVaultWarning" | i18n }}</app-callout>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-user-verification>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "purgeVault" | i18n }}</span>
<app-user-verification formControlName="masterPassword"></app-user-verification>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="submit" buttonType="danger">
{{ "purgeVault" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>
</div>
</div>

View File

@@ -1,55 +1,60 @@
import { Component, Input } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService } from "@bitwarden/components";
export interface PurgeVaultDialogData {
organizationId: string;
}
@Component({
selector: "app-purge-vault",
templateUrl: "purge-vault.component.html",
})
export class PurgeVaultComponent {
@Input() organizationId?: string = null;
organizationId: string = null;
masterPassword: Verification;
formPromise: Promise<unknown>;
formGroup = new FormGroup({
masterPassword: new FormControl<Verification>(null),
});
constructor(
@Inject(DIALOG_DATA) protected data: PurgeVaultDialogData,
private dialogRef: DialogRef,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private userVerificationService: UserVerificationService,
private router: Router,
private logService: LogService,
private syncService: SyncService,
) {}
) {
this.organizationId = data && data.organizationId ? data.organizationId : null;
}
async submit() {
try {
this.formPromise = this.userVerificationService
.buildRequest(this.masterPassword)
submit = async () => {
const response = this.userVerificationService
.buildRequest(this.formGroup.value.masterPassword)
.then((request) => this.apiService.postPurgeCiphers(request, this.organizationId));
await this.formPromise;
await response;
this.platformUtilsService.showToast("success", null, this.i18nService.t("vaultPurged"));
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.syncService.fullSync(true);
await this.syncService.fullSync(true);
if (this.organizationId != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["organizations", this.organizationId, "vault"]);
await this.router.navigate(["organizations", this.organizationId, "vault"]);
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["vault"]);
}
} catch (e) {
this.logService.error(e);
await this.router.navigate(["vault"]);
}
this.dialogRef.close();
};
static open(dialogService: DialogService, config?: DialogConfig<PurgeVaultDialogData>) {
return dialogService.open(PurgeVaultComponent, config);
}
}

View File

@@ -8223,6 +8223,12 @@
}
}
},
"lowKDFIterationsBanner": {
"message": "Low KDF iterations. Increase your iterations to improve the security of your account."
},
"changeKDFSettings": {
"message": "Change KDF settings"
},
"secureYourInfrastructure": {
"message": "Secure your infrastructure"
},

View File

@@ -193,13 +193,11 @@ import { SearchService } from "@bitwarden/common/services/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import {
PasswordGenerationService,
PasswordGenerationServiceAbstraction,
} from "@bitwarden/common/tools/generator/password";
import {
UsernameGenerationService,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/common/tools/generator/username";
legacyPasswordGenerationServiceFactory,
legacyUsernameGenerationServiceFactory,
} from "@bitwarden/common/tools/generator";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -559,13 +557,27 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: PasswordGenerationServiceAbstraction,
useClass: PasswordGenerationService,
deps: [CryptoServiceAbstraction, PolicyServiceAbstraction, StateServiceAbstraction],
useFactory: legacyPasswordGenerationServiceFactory,
deps: [
EncryptService,
CryptoServiceAbstraction,
PolicyServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
],
}),
safeProvider({
provide: UsernameGenerationServiceAbstraction,
useClass: UsernameGenerationService,
deps: [CryptoServiceAbstraction, StateServiceAbstraction, ApiServiceAbstraction],
useFactory: legacyUsernameGenerationServiceFactory,
deps: [
ApiServiceAbstraction,
I18nServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
PolicyServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
],
}),
safeProvider({
provide: ApiServiceAbstraction,

View File

@@ -1,15 +1,14 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Directive, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { debounceTime, first, map } from "rxjs/operators";
import { BehaviorSubject, combineLatest, firstValueFrom, Subject } from "rxjs";
import { debounceTime, first, map, skipWhile, takeUntil } from "rxjs/operators";
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { GeneratorOptions } from "@bitwarden/common/tools/generator/generator-options";
import { GeneratorType } from "@bitwarden/common/tools/generator/generator-type";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
@@ -22,9 +21,9 @@ import {
import { EmailForwarderOptions } from "@bitwarden/common/tools/models/domain/email-forwarder-options";
@Directive()
export class GeneratorComponent implements OnInit {
export class GeneratorComponent implements OnInit, OnDestroy {
@Input() comingFromAddEdit = false;
@Input() type: string;
@Input() type: GeneratorType | "";
@Output() onSelected = new EventEmitter<string>();
usernameGeneratingPromise: Promise<string>;
@@ -43,6 +42,9 @@ export class GeneratorComponent implements OnInit {
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
usernameWebsite: string = null;
private destroy$ = new Subject<void>();
private isInitialized$ = new BehaviorSubject(false);
// update screen reader minimum password length with 500ms debounce
// so that the user isn't flooded with status updates
private _passwordOptionsMinLengthForReader = new BehaviorSubject<number>(
@@ -53,15 +55,17 @@ export class GeneratorComponent implements OnInit {
debounceTime(500),
);
private _password = new BehaviorSubject<string>("-");
constructor(
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected usernameGenerationService: UsernameGenerationServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected stateService: StateService,
protected accountService: AccountService,
protected i18nService: I18nService,
protected logService: LogService,
protected route: ActivatedRoute,
protected accountService: AccountService,
protected ngZone: NgZone,
private win: Window,
) {
this.typeOptions = [
@@ -92,22 +96,41 @@ export class GeneratorComponent implements OnInit {
];
this.subaddressOptions = [{ name: i18nService.t("random"), value: "random" }];
this.catchallOptions = [{ name: i18nService.t("random"), value: "random" }];
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.initForwardOptions();
this.forwardOptions = [
{ name: "", value: "", validForSelfHosted: false },
{ name: "addy.io", value: "anonaddy", validForSelfHosted: true },
{ name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false },
{ name: "Fastmail", value: "fastmail", validForSelfHosted: true },
{ name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false },
{ name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true },
{ name: "Forward Email", value: "forwardemail", validForSelfHosted: true },
].sort((a, b) => a.name.localeCompare(b.name));
this._password.pipe(debounceTime(250)).subscribe((password) => {
ngZone.run(() => {
this.password = password;
});
this.passwordGenerationService.addHistory(this.password).catch((e) => {
this.logService.error(e);
});
});
}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
const passwordOptionsResponse = await this.passwordGenerationService.getOptions();
this.passwordOptions = passwordOptionsResponse[0];
this.enforcedPasswordPolicyOptions = passwordOptionsResponse[1];
cascadeOptions(navigationType: GeneratorType = undefined, accountEmail: string) {
this.avoidAmbiguous = !this.passwordOptions.ambiguous;
if (!this.type) {
if (navigationType) {
this.type = navigationType;
} else {
this.type = this.passwordOptions.type === "username" ? "username" : "password";
}
}
this.passwordOptions.type =
this.passwordOptions.type === "passphrase" ? "passphrase" : "password";
this.usernameOptions = await this.usernameGenerationService.getOptions();
if (this.usernameOptions.type == null) {
this.usernameOptions.type = "word";
}
@@ -115,9 +138,7 @@ export class GeneratorComponent implements OnInit {
this.usernameOptions.subaddressEmail == null ||
this.usernameOptions.subaddressEmail === ""
) {
this.usernameOptions.subaddressEmail = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.usernameOptions.subaddressEmail = accountEmail;
}
if (this.usernameWebsite == null) {
this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random";
@@ -127,26 +148,63 @@ export class GeneratorComponent implements OnInit {
this.subaddressOptions.push(websiteOption);
this.catchallOptions.push(websiteOption);
}
}
async ngOnInit() {
combineLatest([
this.route.queryParams.pipe(first()),
this.accountService.activeAccount$.pipe(first()),
this.passwordGenerationService.getOptions$(),
this.usernameGenerationService.getOptions$(),
])
.pipe(
map(([qParams, account, [passwordOptions, passwordPolicy], usernameOptions]) => ({
navigationType: qParams.type as GeneratorType,
accountEmail: account.email,
passwordOptions,
passwordPolicy,
usernameOptions,
})),
takeUntil(this.destroy$),
)
.subscribe((options) => {
this.passwordOptions = options.passwordOptions;
this.enforcedPasswordPolicyOptions = options.passwordPolicy;
this.usernameOptions = options.usernameOptions;
this.cascadeOptions(options.navigationType, options.accountEmail);
this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
if (this.type !== "username" && this.type !== "password") {
if (qParams.type === "username" || qParams.type === "password") {
this.type = qParams.type;
} else {
const generatorOptions = await this.stateService.getGeneratorOptions();
this.type = generatorOptions?.type ?? "password";
}
}
if (this.regenerateWithoutButtonPress()) {
await this.regenerate();
}
this.regenerate().catch((e) => {
this.logService.error(e);
});
}
async typeChanged() {
await this.stateService.setGeneratorOptions({ type: this.type } as GeneratorOptions);
if (this.regenerateWithoutButtonPress()) {
await this.regenerate();
this.isInitialized$.next(true);
});
// once initialization is complete, `ngOnInit` should return.
//
// FIXME(#6944): if a sync is in progress, wait to complete until after
// the sync completes.
await firstValueFrom(
this.isInitialized$.pipe(
skipWhile((initialized) => !initialized),
takeUntil(this.destroy$),
),
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.isInitialized$.complete();
this._passwordOptionsMinLengthForReader.complete();
}
async typeChanged() {
await this.savePasswordOptions();
}
async regenerate() {
@@ -160,7 +218,7 @@ export class GeneratorComponent implements OnInit {
async sliderChanged() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.savePasswordOptions(false);
this.savePasswordOptions();
await this.passwordGenerationService.addHistory(this.password);
}
@@ -204,31 +262,34 @@ export class GeneratorComponent implements OnInit {
async sliderInput() {
await this.normalizePasswordOptions();
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
}
async savePasswordOptions(regenerate = true) {
async savePasswordOptions() {
// map navigation state into generator type
const restoreType = this.passwordOptions.type;
if (this.type === "username") {
this.passwordOptions.type = this.type;
}
// save options
await this.normalizePasswordOptions();
await this.passwordGenerationService.saveOptions(this.passwordOptions);
if (regenerate && this.regenerateWithoutButtonPress()) {
await this.regeneratePassword();
}
// restore the original format
this.passwordOptions.type = restoreType;
}
async saveUsernameOptions(regenerate = true) {
async saveUsernameOptions() {
await this.usernameGenerationService.saveOptions(this.usernameOptions);
if (this.usernameOptions.type === "forwarded") {
this.username = "-";
}
if (regenerate && this.regenerateWithoutButtonPress()) {
await this.regenerateUsername();
}
}
async regeneratePassword() {
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
await this.passwordGenerationService.addHistory(this.password);
this._password.next(
await this.passwordGenerationService.generatePassword(this.passwordOptions),
);
}
regenerateUsername() {
@@ -297,28 +358,5 @@ export class GeneratorComponent implements OnInit {
await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions(
this.passwordOptions,
);
this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
}
private async initForwardOptions() {
this.forwardOptions = [
{ name: "addy.io", value: "anonaddy", validForSelfHosted: true },
{ name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false },
{ name: "Fastmail", value: "fastmail", validForSelfHosted: true },
{ name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false },
{ name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true },
{ name: "Forward Email", value: "forwardemail", validForSelfHosted: true },
];
this.usernameOptions = await this.usernameGenerationService.getOptions();
if (
this.usernameOptions.forwardedService == null ||
this.usernameOptions.forwardedService === ""
) {
this.forwardOptions.push({ name: "", value: null, validForSelfHosted: false });
}
this.forwardOptions = this.forwardOptions.sort((a, b) => a.name.localeCompare(b.name));
}
}

View File

@@ -23,8 +23,7 @@ export class PasswordGeneratorHistoryComponent implements OnInit {
}
clear = async () => {
this.history = [];
await this.passwordGenerationService.clear();
this.history = await this.passwordGenerationService.clear();
};
copy(password: string) {

View File

@@ -5,6 +5,12 @@ import { AnonLayoutComponent } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Icon } from "@bitwarden/components";
export interface AnonLayoutWrapperData {
pageTitle?: string;
pageSubtitle?: string;
pageIcon?: Icon;
}
@Component({
standalone: true,
templateUrl: "anon-layout-wrapper.component.html",

View File

@@ -13,7 +13,7 @@
</h1>
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
</div>
<div class="tw-mb-auto tw-mx-auto tw-flex tw-flex-col tw-items-center">
<div class="tw-mb-auto tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center">
<div
class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
>

View File

@@ -31,14 +31,13 @@ writing:
Instead the AnonLayoutComponent is implemented solely in the router via routable composition, which
gives us the advantages of nested routes in Angular.
To allow for routable composition, Auth will also provide a wrapper component in each client, called
AnonLayout**Wrapper**Component.
To allow for routable composition, Auth also provides an AnonLayout**Wrapper**Component which embeds
the AnonLayoutComponent.
For clarity:
- AnonLayoutComponent = the Auth-owned library component - `<auth-anon-layout>`
- AnonLayout**Wrapper**Component = the client-specific wrapper component to be used in a client
routing module
- AnonLayoutComponent = the base, Auth-owned library component - `<auth-anon-layout>`
- AnonLayout**Wrapper**Component = the wrapper to be used in client routing modules
The AnonLayout**Wrapper**Component embeds the AnonLayoutComponent along with the router outlets:
@@ -79,7 +78,7 @@ example) to construct the page via routable composition:
pageTitle: "logIn", // example of a translation key from messages.json
pageSubtitle: "loginWithMasterPassword", // example of a translation key from messages.json
pageIcon: LockIcon, // example of an icon to pass in
},
} satisfies AnonLayoutWrapperData,
},
],
},
@@ -99,7 +98,7 @@ In the `oss-routing.module.ts` example above, notice the data properties being p
All 3 of these properties are optional.
```javascript
import { LockIcon } from "@bitwarden/auth/angular";
import { AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
// ...
@@ -109,7 +108,7 @@ import { LockIcon } from "@bitwarden/auth/angular";
pageTitle: "logIn",
pageSubtitle: "loginWithMasterPassword",
pageIcon: LockIcon,
},
} satisfies AnonLayoutWrapperData,
}
```

View File

@@ -41,7 +41,7 @@ export const WithPrimaryContent: Story = {
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle">
<div class="tw-max-w-md">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
@@ -58,12 +58,12 @@ export const WithSecondaryContent: Story = {
// Notice that slot="secondary" is requred to project any secondary content.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle">
<div class="tw-max-w-md">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
<div slot="secondary" class="text-center tw-max-w-md">
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<button bitButton>Perform Action</button>
</div>
@@ -79,12 +79,12 @@ export const WithLongContent: Story = {
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?">
<div class="tw-max-w-md">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
</div>
<div slot="secondary" class="text-center tw-max-w-md">
<div slot="secondary" class="text-center">
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est? </p>
<button bitButton>Perform Action</button>
@@ -101,7 +101,7 @@ export const WithIcon: Story = {
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [icon]="icon">
<div class="tw-max-w-md">
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>

View File

@@ -6,6 +6,7 @@
export * from "./icons";
export * from "./anon-layout/anon-layout.component";
export * from "./anon-layout/anon-layout-wrapper.component";
export * from "./fingerprint-dialog/fingerprint-dialog.component";
export * from "./password-callout/password-callout.component";

View File

@@ -97,6 +97,7 @@ describe("AuthRequestLoginStrategy", () => {
authRequestLoginStrategy = new AuthRequestLoginStrategy(
cache,
deviceTrustService,
accountService,
masterPasswordService,
cryptoService,
@@ -109,7 +110,6 @@ describe("AuthRequestLoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptions,
deviceTrustService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,

View File

@@ -1,28 +1,13 @@
import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
import { CacheData } from "../services/login-strategies/login-strategy.state";
@@ -51,40 +36,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
constructor(
data: AuthRequestLoginStrategyData,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
cryptoService: CryptoService,
apiService: ApiService,
tokenService: TokenService,
appIdService: AppIdService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private deviceTrustService: DeviceTrustServiceAbstraction,
billingAccountProfileStateService: BillingAccountProfileStateService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
kdfConfigService: KdfConfigService,
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
) {
super(
accountService,
masterPasswordService,
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
super(...sharedDeps);
this.cache = new BehaviorSubject(data);
this.email$ = this.cache.pipe(map((data) => data.tokenRequest.email));

View File

@@ -150,6 +150,9 @@ describe("LoginStrategy", () => {
// The base class is abstract so we test it via PasswordLoginStrategy
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
passwordStrengthService,
policyService,
loginStrategyService,
accountService,
masterPasswordService,
cryptoService,
@@ -162,9 +165,6 @@ describe("LoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptionsService,
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
@@ -461,6 +461,9 @@ describe("LoginStrategy", () => {
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
passwordStrengthService,
policyService,
loginStrategyService,
accountService,
masterPasswordService,
cryptoService,
@@ -473,9 +476,6 @@ describe("LoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptionsService,
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,

View File

@@ -121,6 +121,9 @@ describe("PasswordLoginStrategy", () => {
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
passwordStrengthService,
policyService,
loginStrategyService,
accountService,
masterPasswordService,
cryptoService,
@@ -133,9 +136,6 @@ describe("PasswordLoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptionsService,
passwordStrengthService,
policyService,
loginStrategyService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,

View File

@@ -1,15 +1,8 @@
import { BehaviorSubject, firstValueFrom, map, Observable } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
@@ -17,13 +10,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -31,7 +17,6 @@ import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { LoginStrategyServiceAbstraction } from "../abstractions";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
import { CacheData } from "../services/login-strategies/login-strategy.state";
@@ -75,42 +60,12 @@ export class PasswordLoginStrategy extends LoginStrategy {
constructor(
data: PasswordLoginStrategyData,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
cryptoService: CryptoService,
apiService: ApiService,
tokenService: TokenService,
appIdService: AppIdService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
protected stateService: StateService,
twoFactorService: TwoFactorService,
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private policyService: PolicyService,
private loginStrategyService: LoginStrategyServiceAbstraction,
billingAccountProfileStateService: BillingAccountProfileStateService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
kdfConfigService: KdfConfigService,
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
) {
super(
accountService,
masterPasswordService,
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
super(...sharedDeps);
this.cache = new BehaviorSubject(data);
this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email));

View File

@@ -118,6 +118,10 @@ describe("SsoLoginStrategy", () => {
ssoLoginStrategy = new SsoLoginStrategy(
null,
keyConnectorService,
deviceTrustService,
authRequestService,
i18nService,
accountService,
masterPasswordService,
cryptoService,
@@ -130,10 +134,6 @@ describe("SsoLoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptionsService,
keyConnectorService,
deviceTrustService,
authRequestService,
i18nService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,

View File

@@ -1,36 +1,19 @@
import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import {
InternalUserDecryptionOptionsServiceAbstraction,
AuthRequestServiceAbstraction,
} from "../abstractions";
import { AuthRequestServiceAbstraction } from "../abstractions";
import { SsoLoginCredentials } from "../models/domain/login-credentials";
import { CacheData } from "../services/login-strategies/login-strategy.state";
@@ -84,43 +67,13 @@ export class SsoLoginStrategy extends LoginStrategy {
constructor(
data: SsoLoginStrategyData,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
cryptoService: CryptoService,
apiService: ApiService,
tokenService: TokenService,
appIdService: AppIdService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private keyConnectorService: KeyConnectorService,
private deviceTrustService: DeviceTrustServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private i18nService: I18nService,
billingAccountProfileStateService: BillingAccountProfileStateService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
kdfConfigService: KdfConfigService,
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
) {
super(
accountService,
masterPasswordService,
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
super(...sharedDeps);
this.cache = new BehaviorSubject(data);
this.email$ = this.cache.pipe(map((state) => state.email));

View File

@@ -94,6 +94,8 @@ describe("UserApiLoginStrategy", () => {
apiLogInStrategy = new UserApiLoginStrategy(
cache,
environmentService,
keyConnectorService,
accountService,
masterPasswordService,
cryptoService,
@@ -106,8 +108,6 @@ describe("UserApiLoginStrategy", () => {
stateService,
twoFactorService,
userDecryptionOptionsService,
environmentService,
keyConnectorService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,

View File

@@ -1,28 +1,13 @@
import { firstValueFrom, BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
import { CacheData } from "../services/login-strategies/login-strategy.state";
@@ -44,41 +29,12 @@ export class UserApiLoginStrategy extends LoginStrategy {
constructor(
data: UserApiLoginStrategyData,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
cryptoService: CryptoService,
apiService: ApiService,
tokenService: TokenService,
appIdService: AppIdService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private environmentService: EnvironmentService,
private keyConnectorService: KeyConnectorService,
billingAccountProfileStateService: BillingAccountProfileStateService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
protected kdfConfigService: KdfConfigService,
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
) {
super(
accountService,
masterPasswordService,
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
super(...sharedDeps);
this.cache = new BehaviorSubject(data);
}

View File

@@ -1,28 +1,13 @@
import { BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions";
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
import { CacheData } from "../services/login-strategies/login-strategy.state";
@@ -46,39 +31,9 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
constructor(
data: WebAuthnLoginStrategyData,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
cryptoService: CryptoService,
apiService: ApiService,
tokenService: TokenService,
appIdService: AppIdService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
stateService: StateService,
twoFactorService: TwoFactorService,
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
billingAccountProfileStateService: BillingAccountProfileStateService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
kdfConfigService: KdfConfigService,
...sharedDeps: ConstructorParameters<typeof LoginStrategy>
) {
super(
accountService,
masterPasswordService,
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
super(...sharedDeps);
this.cache = new BehaviorSubject(data);
}

View File

@@ -48,6 +48,7 @@ import { MasterKey } from "@bitwarden/common/types/key";
import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy";
import { LoginStrategy } from "../../login-strategies/login.strategy";
import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy";
import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy";
import { UserApiLoginStrategy } from "../../login-strategies/user-api-login.strategy";
@@ -338,6 +339,24 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
private initializeLoginStrategy(
source: Observable<[AuthenticationType | null, CacheData | null]>,
) {
const sharedDeps: ConstructorParameters<typeof LoginStrategy> = [
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
];
return source.pipe(
map(([strategy, data]) => {
if (strategy == null) {
@@ -347,108 +366,35 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
case AuthenticationType.Password:
return new PasswordLoginStrategy(
data?.password,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.passwordStrengthService,
this.policyService,
this,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
...sharedDeps,
);
case AuthenticationType.Sso:
return new SsoLoginStrategy(
data?.sso,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.keyConnectorService,
this.deviceTrustService,
this.authRequestService,
this.i18nService,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
...sharedDeps,
);
case AuthenticationType.UserApiKey:
return new UserApiLoginStrategy(
data?.userApiKey,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.environmentService,
this.keyConnectorService,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
...sharedDeps,
);
case AuthenticationType.AuthRequest:
return new AuthRequestLoginStrategy(
data?.authRequest,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.deviceTrustService,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
...sharedDeps,
);
case AuthenticationType.WebAuthn:
return new WebAuthnLoginStrategy(
data?.webAuthn,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.userDecryptionOptionsService,
this.billingAccountProfileStateService,
this.vaultTimeoutSettingsService,
this.kdfConfigService,
);
return new WebAuthnLoginStrategy(data?.webAuthn, ...sharedDeps);
}
}),
);

View File

@@ -70,7 +70,7 @@ export class PasswordGeneratorPolicyOptions extends Domain {
*/
inEffect() {
return (
this.defaultType !== "" ||
this.defaultType ||
this.minLength > 0 ||
this.numberCount > 0 ||
this.specialCount > 0 ||

View File

@@ -174,7 +174,7 @@ export class CryptoService implements CryptoServiceAbstraction {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId));
return await this.validateUserKey(masterKey as unknown as UserKey);
return await this.validateUserKey(masterKey as unknown as UserKey, userId);
}
// TODO: legacy support for user key is no longer needed since we require users to migrate on login
@@ -193,9 +193,10 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: UserId): Promise<UserKey> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
const userKey = await this.getKeyFromStorage(keySuffix, userId);
if (userKey) {
if (!(await this.validateUserKey(userKey))) {
if (!(await this.validateUserKey(userKey, userId))) {
this.logService.warning("Invalid key, throwing away stored keys");
await this.clearAllStoredUserKeys(userId);
}
@@ -663,13 +664,15 @@ export class CryptoService implements CryptoServiceAbstraction {
}
// ---HELPERS---
protected async validateUserKey(key: UserKey): Promise<boolean> {
protected async validateUserKey(key: UserKey, userId: UserId): Promise<boolean> {
if (!key) {
return false;
}
try {
const encPrivateKey = await firstValueFrom(this.activeUserEncryptedPrivateKeyState.state$);
const encPrivateKey = await firstValueFrom(
this.stateProvider.getUserState$(USER_ENCRYPTED_PRIVATE_KEY, userId),
);
if (encPrivateKey == null) {
return false;
}

View File

@@ -160,3 +160,7 @@ export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", {
export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", {
browser: "memory-large-object",
});
export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerReprompt", "disk", {
web: "disk-local",
});
export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk");

View File

@@ -60,13 +60,16 @@ import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
import { PinStateMigrator } from "./migrations/61-move-pin-state-to-providers";
import { VaultTimeoutSettingsServiceStateProviderMigrator } from "./migrations/62-migrate-vault-timeout-settings-svc-to-state-provider";
import { PasswordOptionsMigrator } from "./migrations/63-migrate-password-settings";
import { GeneratorHistoryMigrator } from "./migrations/64-migrate-generator-history";
import { ForwarderOptionsMigrator } from "./migrations/65-migrate-forwarder-settings";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 62;
export const CURRENT_VERSION = 65;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -130,7 +133,10 @@ export function createMigrationBuilder() {
.with(KdfConfigMigrator, 58, 59)
.with(KnownAccountsMigrator, 59, 60)
.with(PinStateMigrator, 60, 61)
.with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, CURRENT_VERSION);
.with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, 62)
.with(PasswordOptionsMigrator, 62, 63)
.with(GeneratorHistoryMigrator, 63, 64)
.with(ForwarderOptionsMigrator, 64, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -13,6 +13,10 @@ import {
// Represents data in state service pre-migration
function preMigrationJson() {
return {
// desktop only global data format
"global.vaultTimeout": -1,
"global.vaultTimeoutAction": "lock",
global: {
vaultTimeout: 30,
vaultTimeoutAction: "lock",
@@ -267,6 +271,10 @@ describe("VaultTimeoutSettingsServiceStateProviderMigrator", () => {
otherStuff: "otherStuff",
});
// Expect we removed desktop specially formatted global data
expect(helper.remove).toHaveBeenCalledWith("global\\.vaultTimeout");
expect(helper.remove).toHaveBeenCalledWith("global\\.vaultTimeoutAction");
// User data
expect(helper.set).toHaveBeenCalledWith("user1", {
settings: {

View File

@@ -122,10 +122,15 @@ export class VaultTimeoutSettingsServiceStateProviderMigrator extends Migrator<6
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
// Delete global data
// Delete global data (works for browser extension and web; CLI doesn't have these as global settings).
delete globalData?.vaultTimeout;
delete globalData?.vaultTimeoutAction;
await helper.set("global", globalData);
// Remove desktop only settings. These aren't found by the above global key removal b/c of
// the different storage key format. This removal does not cause any issues on migrating for other clients.
await helper.remove("global\\.vaultTimeout");
await helper.remove("global\\.vaultTimeoutAction");
}
async rollback(helper: MigrationHelper): Promise<void> {

View File

@@ -0,0 +1,123 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ExpectedOptions,
PasswordOptionsMigrator,
NAVIGATION,
PASSWORD,
PASSPHRASE,
} from "./63-migrate-password-settings";
function migrationHelper(passwordGenerationOptions: ExpectedOptions) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
settings: {
passwordGenerationOptions,
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
62,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
settings: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("PasswordOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator type", async () => {
const helper = migrationHelper({
type: "password",
});
helper.getFromUser.mockResolvedValue({ some: { other: "data" } });
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, {
type: "password",
some: { other: "data" },
});
expectOtherSettingsRemain(helper);
});
it("migrates password settings", async () => {
const helper = migrationHelper({
length: 20,
ambiguous: true,
uppercase: false,
minUppercase: 4,
lowercase: true,
minLowercase: 3,
number: false,
minNumber: 2,
special: true,
minSpecial: 1,
});
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSWORD, {
length: 20,
ambiguous: true,
uppercase: false,
minUppercase: 4,
lowercase: true,
minLowercase: 3,
number: false,
minNumber: 2,
special: true,
minSpecial: 1,
});
expectOtherSettingsRemain(helper);
});
it("migrates passphrase settings", async () => {
const helper = migrationHelper({
numWords: 5,
wordSeparator: "4",
capitalize: true,
includeNumber: false,
});
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSPHRASE, {
numWords: 5,
wordSeparator: "4",
capitalize: true,
includeNumber: false,
});
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,150 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
settings?: {
passwordGenerationOptions?: ExpectedOptions;
};
};
export type GeneratorType = "password" | "passphrase" | "username";
/** username generation options prior to refactoring */
export type ExpectedOptions = {
type?: GeneratorType;
length?: number;
minLength?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
};
/** username generation options after refactoring */
type ConvertedOptions = {
generator: GeneratorNavigation;
password: PasswordGenerationOptions;
passphrase: PassphraseGenerationOptions;
};
export const NAVIGATION: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "generatorSettings",
};
export const PASSWORD: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "passwordGeneratorSettings",
};
export const PASSPHRASE: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "passphraseGeneratorSettings",
};
export type GeneratorNavigation = {
type?: string;
};
export type PassphraseGenerationOptions = {
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
};
export type PasswordGenerationOptions = {
length?: number;
minLength?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
};
export class PasswordOptionsMigrator extends Migrator<62, 63> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const legacyOptions = account?.settings?.passwordGenerationOptions;
if (legacyOptions) {
const converted = convertSettings(legacyOptions);
await storeSettings(helper, userId, converted);
await deleteSettings(helper, userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}
function convertSettings(options: ExpectedOptions): ConvertedOptions {
const password = {
length: options.length,
ambiguous: options.ambiguous,
uppercase: options.uppercase,
minUppercase: options.minUppercase,
lowercase: options.lowercase,
minLowercase: options.minLowercase,
number: options.number,
minNumber: options.minNumber,
special: options.special,
minSpecial: options.minSpecial,
};
const generator = {
type: options.type,
};
const passphrase = {
numWords: options.numWords,
wordSeparator: options.wordSeparator,
capitalize: options.capitalize,
includeNumber: options.includeNumber,
};
return { generator, password, passphrase };
}
async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) {
const existing = (await helper.getFromUser(userId, NAVIGATION)) ?? {};
const updated = Object.assign(existing, converted.generator);
await Promise.all([
helper.setToUser(userId, NAVIGATION, updated),
helper.setToUser(userId, PASSPHRASE, converted.passphrase),
helper.setToUser(userId, PASSWORD, converted.password),
]);
}
async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) {
delete account?.settings?.passwordGenerationOptions;
await helper.set(userId, account);
}

View File

@@ -0,0 +1,68 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
EncryptedHistory,
GeneratorHistoryMigrator,
HISTORY,
} from "./64-migrate-generator-history";
function migrationHelper(encrypted: EncryptedHistory) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
data: {
passwordGenerationHistory: {
encrypted,
},
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
63,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
data: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("PasswordOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator type", async () => {
const helper = migrationHelper([{ this: "should be copied" }, { this: "too" }]);
const migrator = new GeneratorHistoryMigrator(63, 64);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", HISTORY, [
{ this: "should be copied" },
{ this: "too" },
]);
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,42 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
data?: {
passwordGenerationHistory?: {
encrypted: EncryptedHistory;
};
};
};
/** the actual data stored in the history is opaque to the migrator */
export type EncryptedHistory = Array<unknown>;
export const HISTORY: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "localGeneratorHistoryBuffer",
};
export class GeneratorHistoryMigrator extends Migrator<63, 64> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const data = account?.data?.passwordGenerationHistory;
if (data && data.encrypted) {
await helper.setToUser(userId, HISTORY, data.encrypted);
delete account.data.passwordGenerationHistory;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}

View File

@@ -0,0 +1,218 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ADDY_IO,
CATCHALL,
DUCK_DUCK_GO,
EFF_USERNAME,
ExpectedOptions,
FASTMAIL,
FIREFOX_RELAY,
FORWARD_EMAIL,
ForwarderOptionsMigrator,
NAVIGATION,
SIMPLE_LOGIN,
SUBADDRESS,
} from "./65-migrate-forwarder-settings";
function migrationHelper(usernameGenerationOptions: ExpectedOptions) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
settings: {
usernameGenerationOptions,
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
64,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
settings: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("ForwarderOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator settings", async () => {
const helper = migrationHelper({
type: "catchall",
forwardedService: "simplelogin",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, {
username: "catchall",
forwarder: "simplelogin",
});
expectOtherSettingsRemain(helper);
});
it("migrates catchall settings", async () => {
const helper = migrationHelper({
catchallType: "random",
catchallDomain: "example.com",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", CATCHALL, {
catchallType: "random",
catchallDomain: "example.com",
});
expectOtherSettingsRemain(helper);
});
it("migrates EFF username settings", async () => {
const helper = migrationHelper({
wordCapitalize: true,
wordIncludeNumber: false,
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", EFF_USERNAME, {
wordCapitalize: true,
wordIncludeNumber: false,
});
expectOtherSettingsRemain(helper);
});
it("migrates subaddress settings", async () => {
const helper = migrationHelper({
subaddressType: "random",
subaddressEmail: "j.d@example.com",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SUBADDRESS, {
subaddressType: "random",
subaddressEmail: "j.d@example.com",
});
expectOtherSettingsRemain(helper);
});
it("migrates addyIo settings", async () => {
const helper = migrationHelper({
forwardedAnonAddyBaseUrl: "some_addyio_base",
forwardedAnonAddyApiToken: "some_addyio_token",
forwardedAnonAddyDomain: "some_addyio_domain",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", ADDY_IO, {
baseUrl: "some_addyio_base",
token: "some_addyio_token",
domain: "some_addyio_domain",
});
expectOtherSettingsRemain(helper);
});
it("migrates DuckDuckGo settings", async () => {
const helper = migrationHelper({
forwardedDuckDuckGoToken: "some_duckduckgo_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", DUCK_DUCK_GO, {
token: "some_duckduckgo_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates Firefox Relay settings", async () => {
const helper = migrationHelper({
forwardedFirefoxApiToken: "some_firefox_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FIREFOX_RELAY, {
token: "some_firefox_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates Fastmail settings", async () => {
const helper = migrationHelper({
forwardedFastmailApiToken: "some_fastmail_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FASTMAIL, {
token: "some_fastmail_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates ForwardEmail settings", async () => {
const helper = migrationHelper({
forwardedForwardEmailApiToken: "some_forwardemail_token",
forwardedForwardEmailDomain: "some_forwardemail_domain",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FORWARD_EMAIL, {
token: "some_forwardemail_token",
domain: "some_forwardemail_domain",
});
expectOtherSettingsRemain(helper);
});
it("migrates SimpleLogin settings", async () => {
const helper = migrationHelper({
forwardedSimpleLoginApiKey: "some_simplelogin_token",
forwardedSimpleLoginBaseUrl: "some_simplelogin_baseurl",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SIMPLE_LOGIN, {
token: "some_simplelogin_token",
baseUrl: "some_simplelogin_baseurl",
});
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,245 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
settings?: {
usernameGenerationOptions?: ExpectedOptions;
};
};
/** username generation options prior to refactoring */
export type ExpectedOptions = {
type?: "word" | "subaddress" | "catchall" | "forwarded";
wordCapitalize?: boolean;
wordIncludeNumber?: boolean;
subaddressType?: "random" | "website-name";
subaddressEmail?: string;
catchallType?: "random" | "website-name";
catchallDomain?: string;
forwardedService?: string;
forwardedAnonAddyApiToken?: string;
forwardedAnonAddyDomain?: string;
forwardedAnonAddyBaseUrl?: string;
forwardedDuckDuckGoToken?: string;
forwardedFirefoxApiToken?: string;
forwardedFastmailApiToken?: string;
forwardedForwardEmailApiToken?: string;
forwardedForwardEmailDomain?: string;
forwardedSimpleLoginApiKey?: string;
forwardedSimpleLoginBaseUrl?: string;
};
/** username generation options after refactoring */
type ConvertedOptions = {
generator: GeneratorNavigation;
algorithms: {
catchall: CatchallGenerationOptions;
effUsername: EffUsernameGenerationOptions;
subaddress: SubaddressGenerationOptions;
};
forwarders: {
addyIo: SelfHostedApiOptions & EmailDomainOptions;
duckDuckGo: ApiOptions;
fastmail: ApiOptions;
firefoxRelay: ApiOptions;
forwardEmail: ApiOptions & EmailDomainOptions;
simpleLogin: SelfHostedApiOptions;
};
};
export const NAVIGATION: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "generatorSettings",
};
export const CATCHALL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "catchallGeneratorSettings",
};
export const EFF_USERNAME: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "effUsernameGeneratorSettings",
};
export const SUBADDRESS: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "subaddressGeneratorSettings",
};
export const ADDY_IO: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "addyIoBuffer",
};
export const DUCK_DUCK_GO: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "duckDuckGoBuffer",
};
export const FASTMAIL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "fastmailBuffer",
};
export const FIREFOX_RELAY: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "firefoxRelayBuffer",
};
export const FORWARD_EMAIL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "forwardEmailBuffer",
};
export const SIMPLE_LOGIN: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "simpleLoginBuffer",
};
export type GeneratorNavigation = {
type?: string;
username?: string;
forwarder?: string;
};
type UsernameGenerationMode = "random" | "website-name";
type CatchallGenerationOptions = {
catchallType?: UsernameGenerationMode;
catchallDomain?: string;
};
type EffUsernameGenerationOptions = {
wordCapitalize?: boolean;
wordIncludeNumber?: boolean;
};
type SubaddressGenerationOptions = {
subaddressType?: UsernameGenerationMode;
subaddressEmail?: string;
};
type ApiOptions = {
token?: string;
};
type SelfHostedApiOptions = ApiOptions & {
baseUrl: string;
};
type EmailDomainOptions = {
domain: string;
};
export class ForwarderOptionsMigrator extends Migrator<64, 65> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const legacyOptions = account?.settings?.usernameGenerationOptions;
if (legacyOptions) {
const converted = convertSettings(legacyOptions);
await storeSettings(helper, userId, converted);
await deleteSettings(helper, userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}
function convertSettings(options: ExpectedOptions): ConvertedOptions {
const forwarders = {
addyIo: {
baseUrl: options.forwardedAnonAddyBaseUrl,
token: options.forwardedAnonAddyApiToken,
domain: options.forwardedAnonAddyDomain,
},
duckDuckGo: {
token: options.forwardedDuckDuckGoToken,
},
fastmail: {
token: options.forwardedFastmailApiToken,
},
firefoxRelay: {
token: options.forwardedFirefoxApiToken,
},
forwardEmail: {
token: options.forwardedForwardEmailApiToken,
domain: options.forwardedForwardEmailDomain,
},
simpleLogin: {
token: options.forwardedSimpleLoginApiKey,
baseUrl: options.forwardedSimpleLoginBaseUrl,
},
};
const generator = {
username: options.type,
forwarder: options.forwardedService,
};
const algorithms = {
effUsername: {
wordCapitalize: options.wordCapitalize,
wordIncludeNumber: options.wordIncludeNumber,
},
subaddress: {
subaddressType: options.subaddressType,
subaddressEmail: options.subaddressEmail,
},
catchall: {
catchallType: options.catchallType,
catchallDomain: options.catchallDomain,
},
};
return { generator, algorithms, forwarders };
}
async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) {
await Promise.all([
helper.setToUser(userId, NAVIGATION, converted.generator),
helper.setToUser(userId, CATCHALL, converted.algorithms.catchall),
helper.setToUser(userId, EFF_USERNAME, converted.algorithms.effUsername),
helper.setToUser(userId, SUBADDRESS, converted.algorithms.subaddress),
helper.setToUser(userId, ADDY_IO, converted.forwarders.addyIo),
helper.setToUser(userId, DUCK_DUCK_GO, converted.forwarders.duckDuckGo),
helper.setToUser(userId, FASTMAIL, converted.forwarders.fastmail),
helper.setToUser(userId, FIREFOX_RELAY, converted.forwarders.firefoxRelay),
helper.setToUser(userId, FORWARD_EMAIL, converted.forwarders.forwardEmail),
helper.setToUser(userId, SIMPLE_LOGIN, converted.forwarders.simpleLogin),
]);
}
async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) {
delete account?.settings?.usernameGenerationOptions;
await helper.set(userId, account);
}

Some files were not shown because too many files have changed in this diff Show More