1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +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,115 +1,99 @@
<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 }}"
<bit-dialog dialogSize="large" [title]="'confirmUsers' | i18n" [loading]="loading">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!loading && !done">
<p bitTypography="body1">
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noreferrer"
>
<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>
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!loading && !done">
<p>
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noreferrer"
>
{{ "learnMore" | i18n }}</a
>
</p>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "fingerprint" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of filteredUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td>
{{ fingerprints.get(user.id) }}
</td>
</tr>
<tr *ngFor="let user of excludedUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-container>
<ng-container *ngIf="!loading && done">
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of filteredUsers">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
</ng-container>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
*ngIf="!done"
[disabled]="loading"
(click)="submit()"
{{ "learnMore" | i18n }}</a
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "confirm" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>
</p>
<bit-table>
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "fingerprint" | i18n }}</th>
</tr>
</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 bitCell>
{{ user.email }}
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td bitCell>
{{ fingerprints.get(user.id) }}
</td>
</tr>
<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 bitCell>
{{ user.email }}
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td bitCell>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
<ng-container *ngIf="!loading && done">
<bit-table>
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</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 bitCell>
{{ user.email }}
<p class="tw-text-muted tw-text-sm" *ngIf="user.name">{{ user.name }}</p>
</td>
<td bitCell *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td bitCell *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="!done"
bitButton
type="submit"
buttonType="primary"
(click)="submit()"
[disabled]="loading"
>
{{ "confirm" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</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,101 +1,88 @@
<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">
<app-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
<p>{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="this.showNoMasterPasswordWarning">
<span class="text-muted d-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>
{{ "noMasterPassword" | i18n }}
</ng-container>
</span>
</td>
</tr>
</table>
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
<ng-container bitDialogContent>
<app-callout type="danger" *ngIf="users.length <= 0">
{{ "noSelectedUsersApplicable" | i18n }}
</app-callout>
<app-callout type="error" *ngIf="error">
{{ error }}
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
<p bitTypography="body1">{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning" bitTypography="body1">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
<bit-table>
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</ng-container>
<ng-container *ngIf="done">
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th>{{ "status" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
<td width="30">
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
</td>
<td>
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="statuses.has(user.id)">
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)">
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</table>
<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 bitCell>
{{ user.email }}
<small class="tw-text-muted tw-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<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>
{{ "noMasterPassword" | i18n }}
</ng-container>
</span>
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
<ng-container *ngIf="done">
<bit-table>
<ng-container header>
<tr>
<th bitCell colspan="2">{{ "user" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</ng-container>
</div>
<div class="modal-footer">
<button
type="submit"
class="btn btn-primary btn-submit"
*ngIf="!done && users.length > 0"
[disabled]="loading"
(click)="submit()"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "removeUsers" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>
<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 bitCell>
{{ user.email }}
<small class="tw-text-muted tw-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="statuses.has(user.id)" bitCell>
{{ statuses.get(user.id) }}
</td>
<td *ngIf="!statuses.has(user.id)" bitCell>
{{ "bulkFilteredMessage" | i18n }}
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="!done && users.length > 0"
bitButton
type="submit"
buttonType="primary"
[disabled]="loading"
[bitAction]="submit"
>
{{ "removeUsers" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</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"
id="userTypeManager"
[value]="organizationUserType.Manager"
>
<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"
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 }}
<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"
[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>
<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 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 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">
<input
type="checkbox"
[name]="pascalize(parentId)"
[id]="parentId"
[formControlName]="parentId"
[indeterminate]="parentIndeterminate"
/>
<label class="!tw-font-normal" [for]="parentId">
{{ parentId | i18n }}
</label>
<div class="tw-ml-6">
<bit-form-control>
<input
type="checkbox"
bitCheckbox
[formControlName]="parentId"
[indeterminate]="parentIndeterminate"
/>
<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>
</div>
<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>
<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 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>
<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"
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
*ngIf="!sponsoringOrg.familySponsorshipToDelete"
class="btn btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
bitMenuItem
*ngIf="!isSelfHosted && !sponsoringOrg.familySponsorshipValidUntil"
(click)="resendEmail()"
[attr.aria-label]="'resendEmailLabel' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
{{ "resendEmail" | i18n }}
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<button
type="button"
#resendEmailBtn
*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>
</button>
<button
type="button"
#revokeSponsorshipBtn
[appApiAction]="revokeSponsorshipPromise"
class="dropdown-item text-danger btn-submit"
[disabled]="$any(revokeSponsorshipBtn).loading"
(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>
</button>
</div>
</div>
<button
type="button"
bitMenuItem
(click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<span class="tw-text-danger">{{ "remove" | i18n }}</span>
</button>
</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

@@ -1,4 +0,0 @@
<auth-anon-layout [title]="pageTitle" [subtitle]="pageSubtitle" [icon]="pageIcon">
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
</auth-anon-layout>

View File

@@ -1,26 +0,0 @@
import { Component } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Icon } from "@bitwarden/components";
@Component({
standalone: true,
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})
export class AnonLayoutWrapperComponent {
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
constructor(
private route: ActivatedRoute,
private i18nService: I18nService,
) {
this.pageTitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageTitle"]);
this.pageSubtitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageSubtitle"]);
this.pageIcon = this.route.snapshot.firstChild.data["pageIcon"]; // don't translate
}
}

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" />
</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>
<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="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;
this.platformUtilsService.showToast("success", null, this.i18nService.t("accountUpdated"));
} catch (e) {
this.logService.error(e);
}
}
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"));
};
}

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">
{{ "sendEmail" | i18n }}
</button>
</div>
</div>
<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>
</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">
<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>
</div>
</bit-radio-group>
</bit-section>
<bit-section *ngIf="formGroup.value.product !== productTypes.Free">
<bit-section
@@ -277,126 +278,128 @@
</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">
<bit-radio-button
type="radio"
id="interval{{ selectablePlan.type }}"
[value]="selectablePlan.type"
>
<bit-label>{{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }}</bit-label>
<bit-hint *ngIf="selectablePlan.isAnnual">
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}:
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.basePrice / 12
: selectablePlan.PasswordManager.basePrice
) | currency: "$"
}}
&times; 12
{{ "monthAbbr" | i18n }}
=
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship">
<span class="tw-line-through">{{
selectablePlan.PasswordManager.basePrice | currency: "$"
}}</span>
{{ "freeWithSponsorship" | i18n }}
</ng-container>
<ng-template #notAcceptingSponsorship>
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
<div *ngFor="let selectablePlan of selectablePlans">
<bit-radio-button
type="radio"
id="interval{{ selectablePlan.type }}"
[value]="selectablePlan.type"
>
<bit-label>{{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }}</bit-label>
<bit-hint *ngIf="selectablePlan.isAnnual">
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}:
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.basePrice / 12
: selectablePlan.PasswordManager.basePrice
) | currency: "$"
}}
&times; 12
{{ "monthAbbr" | i18n }}
=
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship">
<span class="tw-line-through">{{
selectablePlan.PasswordManager.basePrice | currency: "$"
}}</span>
{{ "freeWithSponsorship" | i18n }}
</ng-container>
<ng-template #notAcceptingSponsorship>
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "year" | i18n }}
</ng-template>
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.seatPrice / 12
: selectablePlan.PasswordManager.seatPrice
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "year" | i18n }}
</ng-template>
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.seatPrice / 12
: selectablePlan.PasswordManager.seatPrice
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "year" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12
: selectablePlan.PasswordManager.additionalStoragePricePerGb
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }}
</p>
</bit-hint>
<bit-hint *ngIf="!selectablePlan.isAnnual">
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}:
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }}
=
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "month" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12
: selectablePlan.PasswordManager.additionalStoragePricePerGb
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }}
</p>
</bit-hint>
<bit-hint *ngIf="!selectablePlan.isAnnual">
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.seatPrice | currency: "$" }}
{{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "month" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }}
</p>
</bit-hint>
</bit-radio-button>
{{ "basePrice" | i18n }}:
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }}
=
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "month" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.seatPrice | currency: "$" }}
{{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "month" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }}
</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,33 +120,20 @@ export class AddCreditComponent implements OnInit {
return;
}
if (this.method === PaymentMethodType.BitPay) {
try {
const req = new BitPayInvoiceRequest();
req.email = this.email;
req.name = this.name;
req.credit = true;
req.amount = this.creditAmountNumber;
req.organizationId = this.organizationId;
req.userId = this.userId;
req.returnUrl = this.returnUrl;
this.formPromise = this.apiService.postBitPayInvoice(req);
const bitPayUrl: string = await this.formPromise;
this.platformUtilsService.launchUri(bitPayUrl);
} catch (e) {
this.logService.error(e);
}
const req = new BitPayInvoiceRequest();
req.email = this.email;
req.name = this.name;
req.credit = true;
req.amount = this.creditAmountNumber;
req.organizationId = this.organizationId;
req.userId = this.userId;
req.returnUrl = this.returnUrl;
const bitPayUrl: string = await this.apiService.postBitPayInvoice(req);
this.platformUtilsService.launchUri(bitPayUrl);
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>
<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>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</form>
</div>
</div>
<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 formControlName="masterPassword"></app-user-verification>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="submit" buttonType="danger">
{{ "purgeVault" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

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)
.then((request) => this.apiService.postPurgeCiphers(request, this.organizationId));
await this.formPromise;
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);
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"]);
} 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);
submit = async () => {
const response = this.userVerificationService
.buildRequest(this.formGroup.value.masterPassword)
.then((request) => this.apiService.postPurgeCiphers(request, this.organizationId));
await response;
this.platformUtilsService.showToast("success", null, this.i18nService.t("vaultPurged"));
await this.syncService.fullSync(true);
if (this.organizationId != null) {
await this.router.navigate(["organizations", this.organizationId, "vault"]);
} else {
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"
},