1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 05:00:10 +00:00

Merge branch 'main' into tools/pm-18793/port-credential-generator-service-to-providers

This commit is contained in:
✨ Audrey ✨
2025-05-01 13:50:14 -04:00
114 changed files with 2369 additions and 755 deletions

View File

@@ -149,6 +149,8 @@
{
matchPackageNames: [
"@angular-eslint/schematics",
"@typescript-eslint/rule-tester",
"@typescript-eslint/utils",
"angular-eslint",
"eslint-config-prettier",
"eslint-import-resolver-typescript",
@@ -313,8 +315,6 @@
"@storybook/angular",
"@storybook/manager-api",
"@storybook/theming",
"@typescript-eslint/utils",
"@typescript-eslint/rule-tester",
"@types/react",
"autoprefixer",
"bootstrap",

View File

@@ -196,7 +196,7 @@ jobs:
}
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0

View File

@@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the
/bitwarden_license directory.
GPL v3.0:
https://github.com/bitwarden/web/blob/master/LICENSE_GPL.txt
https://github.com/bitwarden/clients/blob/main/LICENSE_GPL.txt
Bitwarden License v1.0:
https://github.com/bitwarden/web/blob/master/LICENSE_BITWARDEN.txt
https://github.com/bitwarden/clients/blob/main/LICENSE_BITWARDEN.txt
No grant of any rights in the trademarks, service marks, or logos of Bitwarden is
made (except as may be necessary to comply with the notice requirements as
applicable), and use of any Bitwarden trademarks must comply with Bitwarden
Trademark Guidelines
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.

View File

@@ -56,7 +56,7 @@ such Open Source Software only.
logos of any Contributor (except as may be necessary to comply with the notice
requirements in Section 2.3), and use of any Bitwarden trademarks must comply with
Bitwarden Trademark Guidelines
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.
3. TERMINATION

View File

@@ -1,4 +1,4 @@
[![Github Workflow build browser on master](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master)
[![Github Workflow build browser on main](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-browser/localized.svg)](https://crowdin.com/project/bitwarden-browser)
[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby)
@@ -15,7 +15,7 @@
The Bitwarden browser extension is written using the Web Extension API and Angular.
![My Vault](https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/browser-chrome.png)
![My Vault](https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-browser-extension-generator.png)
## Documentation

View File

@@ -5201,6 +5201,12 @@
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
"settingsVaultOptions": {
"message": "Vault options"
},
"emptyVaultDescription": {
"message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here."
},
"introCarouselLabel": {
"message": "Welcome to Bitwarden"
},
@@ -5227,5 +5233,50 @@
},
"secureDevicesBody": {
"message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
},
"emptyVaultNudgeTitle": {
"message": "Import existing passwords"
},
"emptyVaultNudgeBody": {
"message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them."
},
"emptyVaultNudgeButton": {
"message": "Import now"
},
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"hasItemsVaultNudgeBody": {
"message": "Autofill items for the current page\nFavorite items for easy access\nSearch your vault for something else"
},
"newLoginNudgeTitle": {
"message": "Save time with autofill"
},
"newLoginNudgeBody": {
"message": "Include a Website so this login appears as an autofill suggestion."
},
"newCardNudgeTitle": {
"message": "Seamless online checkout"
},
"newCardNudgeBody": {
"message": "With cards, easily autofill payment forms securely and accurately."
},
"newIdentityNudgeTitle": {
"message": "Simplify creating accounts"
},
"newIdentityNudgeBody": {
"message": "With identities, quickly autofill long registration or contact forms."
},
"newNoteNudgeTitle": {
"message": "Keep your sensitive data safe"
},
"newNoteNudgeBody": {
"message": "With notes, securely store sensitive data like banking or insurance details."
},
"newSshNudgeTitle": {
"message": "Developer-friendly SSH access"
},
"newSshNudgeBody": {
"message": "Store your keys and connect with the SSH agent for fast, encrypted authentication."
}
}
}

View File

@@ -14,7 +14,7 @@ export function CipherInfo({ cipher, theme }: { cipher: NotificationCipherData;
return html`
<div>
<span class=${cipherInfoPrimaryTextStyles(theme)}>
<span title=${name} class=${cipherInfoPrimaryTextStyles(theme)}>
${[
name,
hasIndicatorIcons

View File

@@ -8,6 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@@ -80,7 +81,72 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(true, false);
expect(sut.syncInProgress).toBe(true);
const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false });
const requestId = getAndAssertRequestId({
forceSync: true,
options: { allowThrowOnError: false, skipTokenRefresh: false },
});
// Pretend the sync has finished
messages.next({ successfully: true, errorMessage: null, requestId: requestId });
const result = await fullSyncPromise;
expect(sut.syncInProgress).toBe(false);
expect(result).toBe(true);
});
const testData: {
input: boolean | SyncOptions | undefined;
normalized: Required<SyncOptions>;
}[] = [
{
input: undefined,
normalized: { allowThrowOnError: false, skipTokenRefresh: false },
},
{
input: true,
normalized: { allowThrowOnError: true, skipTokenRefresh: false },
},
{
input: false,
normalized: { allowThrowOnError: false, skipTokenRefresh: false },
},
{
input: { allowThrowOnError: false },
normalized: { allowThrowOnError: false, skipTokenRefresh: false },
},
{
input: { allowThrowOnError: true },
normalized: { allowThrowOnError: true, skipTokenRefresh: false },
},
{
input: { allowThrowOnError: false, skipTokenRefresh: false },
normalized: { allowThrowOnError: false, skipTokenRefresh: false },
},
{
input: { allowThrowOnError: true, skipTokenRefresh: false },
normalized: { allowThrowOnError: true, skipTokenRefresh: false },
},
{
input: { allowThrowOnError: true, skipTokenRefresh: true },
normalized: { allowThrowOnError: true, skipTokenRefresh: true },
},
{
input: { allowThrowOnError: false, skipTokenRefresh: true },
normalized: { allowThrowOnError: false, skipTokenRefresh: true },
},
];
it.each(testData)("normalize input $input options correctly", async ({ input, normalized }) => {
const messages = new Subject<FullSyncFinishedMessage>();
messageListener.messages$.mockReturnValue(messages);
const fullSyncPromise = sut.fullSync(true, input);
expect(sut.syncInProgress).toBe(true);
const requestId = getAndAssertRequestId({
forceSync: true,
options: normalized,
});
// Pretend the sync has finished
messages.next({ successfully: true, errorMessage: null, requestId: requestId });
@@ -97,7 +163,10 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(false, false);
expect(sut.syncInProgress).toBe(true);
const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false });
const requestId = getAndAssertRequestId({
forceSync: false,
options: { allowThrowOnError: false, skipTokenRefresh: false },
});
// Pretend the sync has finished
messages.next({
@@ -118,7 +187,10 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(true, true);
expect(sut.syncInProgress).toBe(true);
const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true });
const requestId = getAndAssertRequestId({
forceSync: true,
options: { allowThrowOnError: true, skipTokenRefresh: false },
});
// Pretend the sync has finished
messages.next({

View File

@@ -14,6 +14,7 @@ import {
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CoreSyncService } from "@bitwarden/common/platform/sync/internal";
import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -22,7 +23,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold
import { FULL_SYNC_FINISHED } from "./sync-service.listener";
export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string };
export type FullSyncMessage = { forceSync: boolean; options: SyncOptions; requestId: string };
export const DO_FULL_SYNC = new CommandDefinition<FullSyncMessage>("doFullSync");
@@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService {
);
}
async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise<boolean> {
async fullSync(
forceSync: boolean,
allowThrowOnErrorOrOptions?: boolean | SyncOptions,
): Promise<boolean> {
this.syncInProgress = true;
try {
// Normalize options
const options =
typeof allowThrowOnErrorOrOptions === "boolean"
? { allowThrowOnError: allowThrowOnErrorOrOptions, skipTokenRefresh: false }
: {
allowThrowOnError: allowThrowOnErrorOrOptions?.allowThrowOnError ?? false,
skipTokenRefresh: allowThrowOnErrorOrOptions?.skipTokenRefresh ?? false,
};
const requestId = Utils.newGuid();
const syncCompletedPromise = firstValueFrom(
this.messageListener.messages$(FULL_SYNC_FINISHED).pipe(
@@ -79,10 +91,10 @@ export class ForegroundSyncService extends CoreSyncService {
}),
),
);
this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId });
this.messageSender.send(DO_FULL_SYNC, { forceSync, options, requestId });
const result = await syncCompletedPromise;
if (allowThrowOnError && result.errorMessage != null) {
if (options.allowThrowOnError && result.errorMessage != null) {
throw new Error(result.errorMessage);
}

View File

@@ -27,11 +27,18 @@ describe("SyncServiceListener", () => {
const emissionPromise = firstValueFrom(listener);
syncService.fullSync.mockResolvedValueOnce(value);
messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
messages.next({
forceSync: true,
options: { allowThrowOnError: false, skipTokenRefresh: false },
requestId: "1",
});
await emissionPromise;
expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
expect(syncService.fullSync).toHaveBeenCalledWith(true, {
allowThrowOnError: false,
skipTokenRefresh: false,
});
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
successfully: value,
errorMessage: null,
@@ -45,11 +52,18 @@ describe("SyncServiceListener", () => {
const emissionPromise = firstValueFrom(listener);
syncService.fullSync.mockRejectedValueOnce(new Error("SyncError"));
messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
messages.next({
forceSync: true,
options: { allowThrowOnError: false, skipTokenRefresh: false },
requestId: "1",
});
await emissionPromise;
expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
expect(syncService.fullSync).toHaveBeenCalledWith(true, {
allowThrowOnError: false,
skipTokenRefresh: false,
});
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
successfully: false,
errorMessage: "SyncError",

View File

@@ -9,6 +9,7 @@ import {
MessageSender,
isExternalMessage,
} from "@bitwarden/common/platform/messaging";
import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DO_FULL_SYNC } from "./foreground-sync.service";
@@ -34,15 +35,15 @@ export class SyncServiceListener {
listener$(): Observable<void> {
return this.messageListener.messages$(DO_FULL_SYNC).pipe(
filter((message) => isExternalMessage(message)),
concatMap(async ({ forceSync, allowThrowOnError, requestId }) => {
await this.doFullSync(forceSync, allowThrowOnError, requestId);
concatMap(async ({ forceSync, options, requestId }) => {
await this.doFullSync(forceSync, options, requestId);
}),
);
}
private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) {
private async doFullSync(forceSync: boolean, options: SyncOptions, requestId: string) {
try {
const result = await this.syncService.fullSync(forceSync, allowThrowOnError);
const result = await this.syncService.fullSync(forceSync, options);
this.messageSender.send(FULL_SYNC_FINISHED, {
successfully: result,
errorMessage: null,

View File

@@ -18,9 +18,9 @@ export class TabsV2Component {
protected navButtons$ = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge),
this.hasNudgeService.shouldShowNudge$(),
this.hasNudgeService.nudgeStatus$(),
]).pipe(
map(([onboardingFeatureEnabled, showNudge]) => {
map(([onboardingFeatureEnabled, nudgeStatus]) => {
return [
{
label: "vault",
@@ -45,7 +45,7 @@ export class TabsV2Component {
page: "/tabs/settings",
iconKey: "cog",
iconKeyActive: "cog-f",
showBerry: onboardingFeatureEnabled && showNudge,
showBerry: onboardingFeatureEnabled && !nudgeStatus.hasSpotlightDismissed,
},
];
}),

View File

@@ -29,9 +29,26 @@
</a>
</bit-item>
<bit-item>
<a bit-item-content routerLink="/vault-settings">
<a
bit-item-content
routerLink="/vault-settings"
(click)="dismissBadge(VaultNudgeType.EmptyVaultNudge)"
>
<i slot="start" class="bwi bwi-vault" aria-hidden="true"></i>
{{ "vault" | i18n }}
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "settingsVaultOptions" | i18n }}</p>
<!--
Currently can be only 1 item for notification.
Will make this dynamic when more nudges are added
-->
<span
*ngIf="!(showVaultBadge$ | async)?.hasBadgeDismissed"
bitBadge
variant="notification"
>1</span
>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>

View File

@@ -1,9 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Component, OnInit } from "@angular/core";
import { RouterModule } from "@angular/router";
import { firstValueFrom, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ItemModule } from "@bitwarden/components";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BadgeComponent, ItemModule } from "@bitwarden/components";
import { NudgeStatus, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
@@ -22,6 +27,29 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent,
ItemModule,
CurrentAccountComponent,
BadgeComponent,
],
})
export class SettingsV2Component {}
export class SettingsV2Component implements OnInit {
VaultNudgeType = VaultNudgeType;
showVaultBadge$: Observable<NudgeStatus> = new Observable();
activeUserId: UserId | null = null;
constructor(
private readonly vaultNudgesService: VaultNudgesService,
private readonly accountService: AccountService,
) {}
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.showVaultBadge$ = this.vaultNudgesService.showNudge$(
VaultNudgeType.EmptyVaultNudge,
this.activeUserId,
);
}
async dismissBadge(type: VaultNudgeType) {
if (!(await firstValueFrom(this.showVaultBadge$)).hasBadgeDismissed) {
await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId, true);
}
}
}

View File

@@ -1,4 +1,4 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>

View File

@@ -14,11 +14,12 @@
>
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
<app-new-item-dropdown
slot="button"
[initialValues]="newItemItemValues$ | async"
></app-new-item-dropdown>
<ng-container slot="description">
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">{{ "emptyVaultDescription" | i18n }}</p>
</ng-container>
<a slot="button" bitButton buttonType="secondary" [routerLink]="['/add-cipher']">
{{ "newLogin" | i18n }}
</a>
</bit-no-items>
</div>
@@ -28,11 +29,31 @@
></blocked-injection-banner>
<!-- Show search & filters outside of the scroll area of the page -->
<ng-container slot="above-scroll-area" *ngIf="vaultState !== VaultStateEnum.Empty">
<vault-at-risk-password-callout
*appIfFeature="FeatureFlag.SecurityTasks"
></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
<ng-container slot="above-scroll-area">
<ng-container *ngIf="vaultState === VaultStateEnum.Empty && showEmptyVaultSpotlight$ | async">
<bit-spotlight
[title]="'emptyVaultNudgeTitle' | i18n"
[subtitle]="'emptyVaultNudgeBody' | i18n"
[buttonText]="'emptyVaultNudgeButton' | i18n"
(onButtonClick)="navigateToImport()"
(onDismiss)="dismissVaultNudgeSpotlight(VaultNudgeType.EmptyVaultNudge)"
>
</bit-spotlight>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
<div class="tw-mb-4" *ngIf="showHasItemsVaultSpotlight$ | async">
<bit-spotlight
[title]="'hasItemsVaultNudgeTitle' | i18n"
[subtitle]="'hasItemsVaultNudgeBody' | i18n"
(onDismiss)="dismissVaultNudgeSpotlight(VaultNudgeType.HasVaultItems)"
>
</bit-spotlight>
</div>
<vault-at-risk-password-callout
*appIfFeature="FeatureFlag.SecurityTasks"
></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
</ng-container>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">

View File

@@ -2,30 +2,38 @@ import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrol
import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
combineLatest,
filter,
map,
firstValueFrom,
map,
Observable,
shareReplay,
startWith,
switchMap,
take,
startWith,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
import {
DecryptionFailureDialogComponent,
SpotlightComponent,
VaultIcons,
VaultNudgesService,
VaultNudgeType,
} from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
@@ -74,14 +82,29 @@ enum VaultState {
VaultHeaderV2Component,
AtRiskPasswordCalloutComponent,
NewSettingsCalloutComponent,
SpotlightComponent,
RouterModule,
],
providers: [VaultPageService],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
VaultNudgeType = VaultNudgeType;
cipherType = CipherType;
private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
showEmptyVaultSpotlight$: Observable<boolean> = this.activeUserId$.pipe(
switchMap((userId) =>
this.vaultNudgesService.showNudge$(VaultNudgeType.EmptyVaultNudge, userId),
),
map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed),
);
showHasItemsVaultSpotlight$: Observable<boolean> = this.activeUserId$.pipe(
switchMap((userId) => this.vaultNudgesService.showNudge$(VaultNudgeType.HasVaultItems, userId)),
map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed),
);
activeUserId: UserId | null = null;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
@@ -131,7 +154,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private dialogService: DialogService,
private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private introCarouselService: IntroCarouselService,
private configService: ConfigService,
private vaultNudgesService: VaultNudgesService,
private router: Router,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
@@ -169,16 +193,12 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
}
async ngOnInit() {
const hasVaultNudgeFlag = await this.configService.getFeatureFlag(
FeatureFlag.PM8851_BrowserOnboardingNudge,
);
if (hasVaultNudgeFlag) {
await this.introCarouselService.setIntroCarouselDismissed();
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.introCarouselService.setIntroCarouselDismissed();
this.cipherService
.failedToDecryptCiphers$(activeUserId)
.failedToDecryptCiphers$(this.activeUserId)
.pipe(
map((ciphers) => (ciphers ? ciphers.filter((c) => !c.isDeleted) : [])),
filter((ciphers) => ciphers.length > 0),
@@ -196,5 +216,16 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
this.vaultScrollPositionService.stop();
}
async navigateToImport() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
await BrowserPopupUtils.openCurrentPagePopout(window);
}
}
async dismissVaultNudgeSpotlight(type: VaultNudgeType) {
await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId);
}
protected readonly FeatureFlag = FeatureFlag;
}

View File

@@ -1,6 +1,8 @@
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
GlobalState,
KeyDefinition,
@@ -26,9 +28,17 @@ export class IntroCarouselService {
map((x) => x ?? false),
);
constructor(private stateProvider: StateProvider) {}
constructor(
private stateProvider: StateProvider,
private configService: ConfigService,
) {}
async setIntroCarouselDismissed(): Promise<void> {
await this.introCarouselState.update(() => true);
const hasVaultNudgeFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge),
);
if (hasVaultNudgeFlag) {
await this.introCarouselState.update(() => true);
}
}
}

View File

@@ -1,7 +1,13 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'folders' | i18n" showBackButton>
<ng-container slot="end">
<button bitButton buttonType="primary" type="button" (click)="openAddEditFolderDialog()">
<button
bitButton
size="small"
buttonType="primary"
type="button"
(click)="openAddEditFolderDialog()"
>
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>

View File

@@ -1,4 +1,4 @@
[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master)
[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main)
[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby)
# Bitwarden Command-line Interface

View File

@@ -5,7 +5,7 @@ import * as http from "http";
import { OptionValues } from "commander";
import * as inquirer from "inquirer";
import Separator from "inquirer/lib/objects/separator";
import { firstValueFrom, map, switchMap } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import {
LoginStrategyServiceAbstraction,
@@ -29,7 +29,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
@@ -40,6 +39,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -367,9 +367,9 @@ export class LoginCommand {
clientSecret == null
) {
if (response.forcePasswordReset === ForceSetPasswordReason.AdminForcePasswordReset) {
return await this.updateTempPassword();
return await this.updateTempPassword(response.userId);
} else if (response.forcePasswordReset === ForceSetPasswordReason.WeakMasterPassword) {
return await this.updateWeakPassword(password);
return await this.updateWeakPassword(response.userId, password);
}
}
@@ -431,7 +431,7 @@ export class LoginCommand {
return Response.success(res);
}
private async updateWeakPassword(currentPassword: string) {
private async updateWeakPassword(userId: UserId, currentPassword: string) {
// If no interaction available, alert user to use web vault
if (!this.canInteract) {
await this.logoutCallback();
@@ -448,6 +448,7 @@ export class LoginCommand {
try {
const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails(
userId,
"Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now.",
);
@@ -469,7 +470,7 @@ export class LoginCommand {
}
}
private async updateTempPassword() {
private async updateTempPassword(userId: UserId) {
// If no interaction available, alert user to use web vault
if (!this.canInteract) {
await this.logoutCallback();
@@ -486,6 +487,7 @@ export class LoginCommand {
try {
const { newPasswordHash, newUserKey, hint } = await this.collectNewMasterPasswordDetails(
userId,
"An organization administrator recently changed your master password. In order to access the vault, you must update your master password now.",
);
@@ -510,10 +512,12 @@ export class LoginCommand {
* Collect new master password and hint from the CLI. The collected password
* is validated against any applicable master password policies, a new master
* key is generated, and we use it to re-encrypt the user key
* @param userId - User ID of the account
* @param prompt - Message that is displayed during the initial prompt
* @param error
*/
private async collectNewMasterPasswordDetails(
userId: UserId,
prompt: string,
error?: string,
): Promise<{
@@ -539,11 +543,12 @@ export class LoginCommand {
// Master Password Validation
if (masterPassword == null || masterPassword === "") {
return this.collectNewMasterPasswordDetails(prompt, "Master password is required.\n");
return this.collectNewMasterPasswordDetails(userId, prompt, "Master password is required.\n");
}
if (masterPassword.length < Utils.minimumPasswordLength) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
`Master password must be at least ${Utils.minimumPasswordLength} characters long.\n`,
);
@@ -556,10 +561,7 @@ export class LoginCommand {
);
const enforcedPolicyOptions = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
),
this.policyService.masterPasswordPolicyOptions$(userId),
);
// Verify master password meets policy requirements
@@ -572,6 +574,7 @@ export class LoginCommand {
)
) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
"Your new master password does not meet the policy requirements.\n",
);
@@ -589,6 +592,7 @@ export class LoginCommand {
// Re-type Validation
if (masterPassword !== masterPasswordRetype) {
return this.collectNewMasterPasswordDetails(
userId,
prompt,
"Master password confirmation does not match.\n",
);
@@ -601,7 +605,7 @@ export class LoginCommand {
message: "Master Password Hint (optional):",
});
const masterPasswordHint = hint.input;
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
// Create new key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(

View File

@@ -1,4 +1,4 @@
[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master)
[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-desktop/localized.svg)](https://crowdin.com/project/bitwarden-desktop)
[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby)

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.4.2",
"version": "2025.5.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -3712,5 +3712,35 @@
},
"move": {
"message": "Move"
},
"newLoginNudgeTitle": {
"message": "Save time with autofill"
},
"newLoginNudgeBody": {
"message": "Include a Website so this login appears as an autofill suggestion."
},
"newCardNudgeTitle": {
"message": "Seamless online checkout"
},
"newCardNudgeBody": {
"message": "With cards, easily autofill payment forms securely and accurately."
},
"newIdentityNudgeTitle": {
"message": "Simplify creating accounts"
},
"newIdentityNudgeBody": {
"message": "With identities, quickly autofill long registration or contact forms."
},
"newNoteNudgeTitle": {
"message": "Keep your sensitive data safe"
},
"newNoteNudgeBody": {
"message": "With notes, securely store sensitive data like banking or insurance details."
},
"newSshNudgeTitle": {
"message": "Developer-friendly SSH access"
},
"newSshNudgeBody": {
"message": "Store your keys and connect with the SSH agent for fast, encrypted authentication."
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.4.2",
"version": "2025.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.4.2",
"version": "2025.5.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.4.2",
"version": "2025.5.0",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -1,12 +1,12 @@
<p align="center">
<img src="https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/web-vault-macbook.png" alt="" width="600" height="358" />
<img src="https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-vault.png" alt="" width="600" height="358" />
</p>
<p align="center">
The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).
</p>
<p align="center">
<a href="https://github.com/bitwarden/clients/actions/workflows/build-web.yml?query=branch:master" target="_blank">
<img src="https://github.com/bitwarden/clients/actions/workflows/build-web.yml/badge.svg?branch=master" alt="Github Workflow build on master" />
<a href="https://github.com/bitwarden/clients/actions/workflows/build-web.yml?query=branch:main" target="_blank">
<img src="https://github.com/bitwarden/clients/actions/workflows/build-web.yml/badge.svg?branch=main" alt="Github Workflow build on main" />
</a>
<a href="https://crowdin.com/project/bitwarden-web" target="_blank">
<img src="https://d322cqt584bo4o.cloudfront.net/bitwarden-web/localized.svg" alt="Crowdin" />

View File

@@ -1,6 +1,7 @@
import { CollectionView } from "@bitwarden/admin-console/common";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { getNestedCollectionTree } from "./collection-utils";
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
describe("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => {
@@ -36,4 +37,63 @@ describe("CollectionUtils Service", () => {
expect(result).toEqual([]);
});
});
describe("getFlatCollectionTree", () => {
it("should flatten a tree node with no children", () => {
// Arrange
const collection = new CollectionView();
collection.name = "Test Collection";
collection.id = "test-id";
const treeNodes: TreeNode<CollectionView>[] = [
new TreeNode<CollectionView>(collection, null),
];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(1);
expect(result[0]).toBe(collection);
});
it("should flatten a tree node with children", () => {
// Arrange
const parentCollection = new CollectionView();
parentCollection.name = "Parent";
parentCollection.id = "parent-id";
const child1Collection = new CollectionView();
child1Collection.name = "Child 1";
child1Collection.id = "child1-id";
const child2Collection = new CollectionView();
child2Collection.name = "Child 2";
child2Collection.id = "child2-id";
const grandchildCollection = new CollectionView();
grandchildCollection.name = "Grandchild";
grandchildCollection.id = "grandchild-id";
const parentNode = new TreeNode<CollectionView>(parentCollection, null);
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
parentNode.children = [child1Node, child2Node];
child1Node.children = [grandchildNode];
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(4);
expect(result[0]).toBe(parentCollection);
expect(result).toContain(child1Collection);
expect(result).toContain(child2Collection);
expect(result).toContain(grandchildCollection);
});
});
});

View File

@@ -37,6 +37,27 @@ export function getNestedCollectionTree(
return nodes;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
export function getFlatCollectionTree(
nodes: TreeNode<CollectionView | CollectionAdminView>[],
): (CollectionView | CollectionAdminView)[] {
if (!nodes || nodes.length === 0) {
return [];
}
return nodes.flatMap((node) => {
if (!node.children || node.children.length === 0) {
return [node.node];
}
const children = getFlatCollectionTree(node.children);
return [node.node, ...children];
});
}
function cloneCollection(collection: CollectionView): CollectionView;
function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
function cloneCollection(

View File

@@ -121,7 +121,7 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree } from "./utils";
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -432,23 +432,33 @@ export class VaultComponent implements OnInit, OnDestroy {
}
this.showAddAccessToggle = false;
let collectionsToReturn = [];
let searchableCollectionNodes: TreeNode<CollectionAdminView>[] = [];
if (filter.collectionId === undefined || filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
searchableCollectionNodes = collections;
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId,
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
searchableCollectionNodes = selectedCollection?.children ?? [];
}
let collectionsToReturn: CollectionAdminView[] = [];
if (await this.searchService.isSearchable(this.userId, searchText)) {
// Flatten the tree for searching through all levels
const flatCollectionTree: CollectionAdminView[] =
getFlatCollectionTree(searchableCollectionNodes);
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
flatCollectionTree,
searchText,
(collection: CollectionAdminView) => collection.name,
(collection: CollectionAdminView) => collection.id,
(collection) => collection.name,
(collection) => collection.id,
);
} else {
collectionsToReturn = searchableCollectionNodes.map(
(treeNode: TreeNode<CollectionAdminView>): CollectionAdminView => treeNode.node,
);
}

View File

@@ -310,13 +310,16 @@ export class ChangePasswordComponent
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString],
) {
const masterKey = await this.keyService.makeMasterKey(
this.currentMasterPassword,
await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))),
await this.kdfConfigService.getKdfConfig(),
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
const masterKey = await this.keyService.makeMasterKey(
this.currentMasterPassword,
email,
await this.kdfConfigService.getKdfConfig(userId),
);
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const newLocalKeyHash = await this.keyService.hashMasterKey(
this.masterPassword,
newMasterKey,

View File

@@ -2,8 +2,10 @@
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DialogService } from "@bitwarden/components";
import {
KdfConfigService,
@@ -43,6 +45,7 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
constructor(
private dialogService: DialogService,
private kdfConfigService: KdfConfigService,
private accountService: AccountService,
private formBuilder: FormBuilder,
) {
this.kdfOptions = [
@@ -52,7 +55,8 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType);
this.setFormControlValues(this.kdfConfig);

View File

@@ -34,7 +34,15 @@
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="button" buttonType="primary" (click)="save()">
<button
bitButton
bitFormButton
type="button"
buttonType="primary"
[loading]="loading"
[disabled]="loading"
(click)="save()"
>
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="false">

View File

@@ -1,5 +1,5 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import {
AbstractControl,
FormBuilder,
@@ -10,32 +10,30 @@ import {
ValidationErrors,
Validators,
} from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlanSponsorshipType } from "@bitwarden/common/billing/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogService, FormFieldModule } from "@bitwarden/components";
import { OrgKey } from "@bitwarden/common/types/key";
import {
ButtonModule,
DialogModule,
DialogService,
FormFieldModule,
ToastService,
} from "@bitwarden/components";
interface RequestSponsorshipForm {
sponsorshipEmail: FormControl<string | null>;
sponsorshipNote: FormControl<string | null>;
}
export interface AddSponsorshipDialogResult {
action: AddSponsorshipDialogAction;
value: Partial<AddSponsorshipFormValue> | null;
}
interface AddSponsorshipFormValue {
sponsorshipEmail: string;
sponsorshipNote: string;
status: string;
}
enum AddSponsorshipDialogAction {
Saved = "saved",
Canceled = "canceled",
interface AddSponsorshipDialogParams {
organizationId: string;
organizationKey: OrgKey;
}
@Component({
@@ -53,54 +51,82 @@ enum AddSponsorshipDialogAction {
export class AddSponsorshipDialogComponent {
sponsorshipForm: FormGroup<RequestSponsorshipForm>;
loading = false;
organizationId: string;
organizationKey: OrgKey;
constructor(
private dialogRef: DialogRef<AddSponsorshipDialogResult>,
private dialogRef: DialogRef,
private formBuilder: FormBuilder,
private accountService: AccountService,
private i18nService: I18nService,
private organizationUserApiService: OrganizationUserApiService,
private toastService: ToastService,
private apiService: ApiService,
private encryptService: EncryptService,
@Inject(DIALOG_DATA) protected dialogParams: AddSponsorshipDialogParams,
) {
this.organizationId = this.dialogParams?.organizationId;
this.organizationKey = this.dialogParams.organizationKey;
this.sponsorshipForm = this.formBuilder.group<RequestSponsorshipForm>({
sponsorshipEmail: new FormControl<string | null>("", {
validators: [Validators.email, Validators.required],
asyncValidators: [this.validateNotCurrentUserEmail.bind(this)],
asyncValidators: [this.isOrganizationMember.bind(this)],
updateOn: "change",
}),
sponsorshipNote: new FormControl<string | null>("", {}),
});
}
static open(dialogService: DialogService): DialogRef<AddSponsorshipDialogResult> {
return dialogService.open<AddSponsorshipDialogResult>(AddSponsorshipDialogComponent);
static open(dialogService: DialogService, config: DialogConfig<AddSponsorshipDialogParams>) {
return dialogService.open(AddSponsorshipDialogComponent, {
...config,
data: config.data,
} as unknown as DialogConfig<unknown, DialogRef>);
}
protected async save() {
if (this.sponsorshipForm.invalid) {
return;
}
this.loading = true;
// TODO: This is a mockup implementation - needs to be updated with actual API integration
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
const formValue = this.sponsorshipForm.getRawValue();
const dialogValue: Partial<AddSponsorshipFormValue> = {
status: "Sent",
sponsorshipEmail: formValue.sponsorshipEmail ?? "",
sponsorshipNote: formValue.sponsorshipNote ?? "",
};
try {
const notes = this.sponsorshipForm.value.sponsorshipNote || "";
const email = this.sponsorshipForm.value.sponsorshipEmail || "";
this.dialogRef.close({
action: AddSponsorshipDialogAction.Saved,
value: dialogValue,
});
const encryptedNotes = await this.encryptService.encryptString(notes, this.organizationKey);
const isAdminInitiated = true;
await this.apiService.postCreateSponsorship(this.organizationId, {
sponsoredEmail: email,
planSponsorshipType: PlanSponsorshipType.FamiliesForEnterprise,
friendlyName: email,
isAdminInitiated,
notes: encryptedNotes.encryptedString,
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("sponsorshipCreated"),
});
await this.resetForm();
} catch (e: any) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e?.message || this.i18nService.t("unexpectedError"),
});
}
this.loading = false;
this.dialogRef.close();
}
protected close = () => {
this.dialogRef.close({ action: AddSponsorshipDialogAction.Canceled, value: null });
};
private async resetForm() {
this.sponsorshipForm.reset();
}
get sponsorshipEmailControl() {
return this.sponsorshipForm.controls.sponsorshipEmail;
@@ -110,24 +136,21 @@ export class AddSponsorshipDialogComponent {
return this.sponsorshipForm.controls.sponsorshipNote;
}
private async validateNotCurrentUserEmail(
control: AbstractControl,
): Promise<ValidationErrors | null> {
private async isOrganizationMember(control: AbstractControl): Promise<ValidationErrors | null> {
const value = control.value;
if (!value) {
return null;
}
const currentUserEmail = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email ?? "")),
const users = await this.organizationUserApiService.getAllMiniUserDetails(this.organizationId);
const userExists = users.data.some(
(member) => member.email.toLowerCase() === value.toLowerCase(),
);
if (!currentUserEmail) {
return null;
}
if (value.toLowerCase() === currentUserEmail.toLowerCase()) {
return { currentUserEmail: true };
if (userExists) {
return {
isOrganizationMember: {
message: this.i18nService.t("organizationHasMemberMessage", value),
},
};
}
return null;

View File

@@ -5,19 +5,95 @@
</button>
</app-header>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab [label]="'sponsoredBitwardenFamilies' | i18n">
<app-organization-sponsored-families
[sponsoredFamilies]="sponsoredFamilies"
(removeSponsorshipEvent)="removeSponsorhip($event)"
></app-organization-sponsored-families>
</bit-tab>
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorshipFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesIncludeMessage" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<bit-tab [label]="'memberFamilies' | i18n">
<app-organization-member-families
[memberFamilies]="sponsoredFamilies"
></app-organization-member-families>
</bit-tab>
</bit-tab-group>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
<p class="tw-px-4" bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
@if (loading()) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading() && sponsoredFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "recipient" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "notes" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of sponsoredFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.friendlyName }}</td>
<td bitCell [class]="o.statusClass">{{ o.statusMessage }}</td>
<td bitCell>{{ o.notes }}</td>
<td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
(click)="resendEmail(o)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccountMessage' | i18n"
(click)="removeSponsorship(o)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else if (!loading()) {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noSponsoredFamiliesMessage" | i18n }}</h4>
<p bitTypography="body2">{{ "nosponsoredFamiliesDetails" | i18n }}</p>
</div>
}
@if (!loading() && sponsoredFamilies.length > 0) {
<p bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
}
</ng-container>
</bit-container>

View File

@@ -1,62 +1,259 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { formatDate } from "@angular/common";
import { Component, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.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 { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { FreeFamiliesPolicyService } from "../services/free-families-policy.service";
import {
AddSponsorshipDialogComponent,
AddSponsorshipDialogResult,
} from "./add-sponsorship-dialog.component";
import { SponsoredFamily } from "./types/sponsored-family";
import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component";
@Component({
selector: "app-free-bitwarden-families",
templateUrl: "free-bitwarden-families.component.html",
})
export class FreeBitwardenFamiliesComponent implements OnInit {
loading = signal<boolean>(true);
tabIndex = 0;
sponsoredFamilies: SponsoredFamily[] = [];
sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = [];
organizationId = "";
organizationKey$: Observable<OrgKey>;
private locale: string = "";
constructor(
private router: Router,
private route: ActivatedRoute,
private dialogService: DialogService,
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
) {}
private apiService: ApiService,
private encryptService: EncryptService,
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction,
private stateProvider: StateProvider,
) {
this.organizationId = this.route.snapshot.params.organizationId || "";
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
switchMap(
(userId) =>
this.keyService.orgKeys$(userId as UserId) as Observable<Record<OrganizationId, OrgKey>>,
),
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
takeUntilDestroyed(),
);
}
async ngOnInit() {
await this.preventAccessToFreeFamiliesPage();
this.locale = await firstValueFrom(this.i18nService.locale$);
await this.loadSponsorships();
this.loading.set(false);
}
async loadSponsorships() {
if (!this.organizationId) {
return;
}
const [response, orgKey] = await Promise.all([
this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId),
firstValueFrom(this.organizationKey$),
]);
if (!orgKey) {
this.logService.error("Organization key not found");
return;
}
const organizationFamilies = response.data;
this.sponsoredFamilies = await Promise.all(
organizationFamilies.map(async (family) => {
let decryptedNote = "";
try {
decryptedNote = await this.encryptService.decryptString(
new EncString(family.notes),
orgKey,
);
} catch (e) {
this.logService.error(e);
}
const { statusMessage, statusClass } = this.getStatus(
this.isSelfHosted,
family.toDelete,
family.validUntil,
family.lastSyncDate,
this.locale,
);
const newFamily = {
...family,
notes: decryptedNote,
statusMessage: statusMessage || "",
statusClass: statusClass || "tw-text-success",
status: statusMessage || "",
};
return new OrganizationSponsorshipInvitesResponse(newFamily);
}),
);
}
async addSponsorship() {
const addSponsorshipDialogRef: DialogRef<AddSponsorshipDialogResult> =
AddSponsorshipDialogComponent.open(this.dialogService);
const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open(
this.dialogService,
{
data: {
organizationId: this.organizationId,
organizationKey: await firstValueFrom(this.organizationKey$),
},
},
);
const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed);
await firstValueFrom(addSponsorshipDialogRef.closed);
if (dialogRef?.value) {
this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies];
await this.loadSponsorships();
}
async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
try {
await this.doRevokeSponsorship(sponsorship);
} catch (e) {
this.logService.error(e);
}
}
removeSponsorhip(sponsorship: any) {
const index = this.sponsoredFamilies.findIndex(
(e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail,
);
this.sponsoredFamilies.splice(index, 1);
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
private async preventAccessToFreeFamiliesPage() {
const showFreeFamiliesPage = await firstValueFrom(
this.freeFamiliesPolicyService.showFreeFamilies$,
);
async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) {
await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId);
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("emailSent"),
});
}
if (!showFreeFamiliesPage) {
await this.router.navigate(["/"]);
private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
const content = sponsorship.validUntil
? this.i18nService.t(
"updatedRevokeSponsorshipConfirmationForAcceptedSponsorship",
sponsorship.friendlyName,
formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale),
)
: this.i18nService.t(
"updatedRevokeSponsorshipConfirmationForSentSponsorship",
sponsorship.friendlyName,
);
const confirmed = await this.dialogService.openSimpleDialog({
title: `${this.i18nService.t("removeSponsorship")}?`,
content,
acceptButtonText: { key: "remove" },
type: "warning",
});
if (!confirmed) {
return;
}
await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId);
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("reclaimedFreePlan"),
});
await this.loadSponsorships();
}
private getStatus(
selfHosted: boolean,
toDelete?: boolean,
validUntil?: Date,
lastSyncDate?: Date,
locale: string = "",
): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } {
/*
* Possible Statuses:
* Requested (self-hosted only)
* Sent
* Active
* RequestRevoke
* RevokeWhenExpired
*/
if (toDelete && validUntil) {
// They want to delete but there is a valid until date which means there is an active sponsorship
return {
statusMessage: this.i18nService.t(
"revokeWhenExpired",
formatDate(validUntil, "MM/dd/yyyy", locale),
),
statusClass: "tw-text-danger",
};
}
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
return {
statusMessage: this.i18nService.t("requestRemoved"),
statusClass: "tw-text-danger",
};
}
if (validUntil) {
// They don't want to delete and they have a valid until date
// that means they are actively sponsoring someone
return {
statusMessage: this.i18nService.t("active"),
statusClass: "tw-text-success",
};
}
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
return {
statusMessage: this.i18nService.t("sent"),
statusClass: "tw-text-success",
};
}
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
return {
statusMessage: this.i18nService.t("sent"),
statusClass: "tw-text-success",
};
}
// We are on a self-hosted install and we have not synced yet
return {
statusMessage: this.i18nService.t("requested"),
statusClass: "tw-text-success",
};
}
}

View File

@@ -1,47 +0,0 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "membersWithSponsoredFamilies" | i18n }}
</p>
<h2 bitTypography="h2" class="">{{ "memberFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && memberFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of memberFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noMemberFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noMemberFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -1,34 +0,0 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { Subject } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SponsoredFamily } from "./types/sponsored-family";
@Component({
selector: "app-organization-member-families",
templateUrl: "organization-member-families.component.html",
})
export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy {
tabIndex = 0;
loading = false;
@Input() memberFamilies: SponsoredFamily[] = [];
private _destroy = new Subject<void>();
constructor(private platformUtilsService: PlatformUtilsService) {}
async ngOnInit() {
this.loading = false;
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
}

View File

@@ -1,87 +0,0 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
</ul>
</div>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && sponsoredFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "recipient" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "notes" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of sponsoredFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
<td bitCell>{{ o.sponsorshipNote }}</td>
<td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccount' | i18n"
(click)="remove(o)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noSponsoredFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noSponsoredFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -1,39 +0,0 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Subject } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SponsoredFamily } from "./types/sponsored-family";
@Component({
selector: "app-organization-sponsored-families",
templateUrl: "organization-sponsored-families.component.html",
})
export class OrganizationSponsoredFamiliesComponent implements OnInit, OnDestroy {
loading = false;
tabIndex = 0;
@Input() sponsoredFamilies: SponsoredFamily[] = [];
@Output() removeSponsorshipEvent = new EventEmitter<SponsoredFamily>();
private _destroy = new Subject<void>();
constructor(private platformUtilsService: PlatformUtilsService) {}
async ngOnInit() {
this.loading = false;
}
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
remove(sponsorship: SponsoredFamily) {
this.removeSponsorshipEvent.emit(sponsorship);
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
}

View File

@@ -10,10 +10,10 @@
{{ "sponsoredFamiliesEligible" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
{{ "sponsoredFamiliesIncludeMessage" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<form [formGroup]="sponsorshipForm" [bitSubmit]="submit" *ngIf="anyOrgsAvailable$ | async">

View File

@@ -32,7 +32,7 @@
type="button"
bitMenuItem
(click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
[attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<span class="tw-text-danger">{{ "remove" | i18n }}</span>
</button>

View File

@@ -81,7 +81,7 @@
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountWithStatementDescriptorWarning" | i18n }}
{{ bankAccountWarning }}
</bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin>

View File

@@ -8,6 +8,7 @@ import { takeUntil } from "rxjs/operators";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SharedModule } from "../../../shared";
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
@@ -37,6 +38,8 @@ export class PaymentComponent implements OnInit, OnDestroy {
/** If provided, will be invoked with the tokenized payment source during form submission. */
@Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise<void>;
@Input() private bankAccountWarningOverride?: string;
@Output() submitted = new EventEmitter<PaymentMethodType>();
private destroy$ = new Subject<void>();
@@ -56,6 +59,7 @@ export class PaymentComponent implements OnInit, OnDestroy {
constructor(
private billingApiService: BillingApiServiceAbstraction,
private braintreeService: BraintreeService,
private i18nService: I18nService,
private stripeService: StripeService,
) {}
@@ -200,4 +204,12 @@ export class PaymentComponent implements OnInit, OnDestroy {
private get usingStripe(): boolean {
return this.usingBankAccount || this.usingCard;
}
get bankAccountWarning(): string {
if (this.bankAccountWarningOverride) {
return this.bankAccountWarningOverride;
} else {
return this.i18nService.t("verifyBankAccountWithStatementDescriptorWarning");
}
}
}

View File

@@ -62,8 +62,6 @@ import { PipesModule } from "../vault/individual-vault/pipes/pipes.module";
import { PurgeVaultComponent } from "../vault/settings/purge-vault.component";
import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component";
import { OrganizationMemberFamiliesComponent } from "./../billing/members/organization-member-families.component";
import { OrganizationSponsoredFamiliesComponent } from "./../billing/members/organization-sponsored-families.component";
import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module";
import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component";
import { SharedModule } from "./shared.module";
@@ -128,8 +126,6 @@ import { SharedModule } from "./shared.module";
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
OrganizationSponsoredFamiliesComponent,
OrganizationMemberFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
UpdatePasswordComponent,
@@ -176,8 +172,6 @@ import { SharedModule } from "./shared.module";
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
OrganizationSponsoredFamiliesComponent,
OrganizationMemberFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
UpdateTempPasswordComponent,

View File

@@ -67,7 +67,7 @@ export class CipherReportComponent implements OnDestroy {
protected i18nService: I18nService,
private syncService: SyncService,
private cipherFormConfigService: CipherFormConfigService,
private adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
) {
this.organizations$ = this.accountService.activeAccount$.pipe(
getUserId,
@@ -207,7 +207,7 @@ export class CipherReportComponent implements OnDestroy {
// If the dialog was closed by deleting the cipher, refresh the report.
if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) {
await this.load();
await this.refresh(result, cipher);
}
}
@@ -215,6 +215,10 @@ export class CipherReportComponent implements OnDestroy {
this.allCiphers = [];
}
protected async refresh(result: VaultItemDialogResult, cipher: CipherView) {
await this.load();
}
protected async repromptCipher(c: CipherView) {
return (
c.reprompt === CipherRepromptType.None ||

View File

@@ -1,18 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant, DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@@ -40,7 +44,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
i18nService: I18nService,
syncService: SyncService,
cipherFormConfigService: CipherFormConfigService,
adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService,
) {
super(
cipherService,
@@ -66,62 +70,112 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.findWeakPasswords(allCiphers);
}
protected findWeakPasswords(ciphers: CipherView[]): void {
ciphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
) {
protected async refresh(result: VaultItemDialogResult, cipher: CipherView) {
if (result === VaultItemDialogResult.Deleted) {
// remove the cipher from the list
this.weakPasswordCiphers = this.weakPasswordCiphers.filter((c) => c.id !== cipher.id);
this.filterCiphersByOrg(this.weakPasswordCiphers);
return;
}
if (result == VaultItemDialogResult.Saved) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let updatedCipher = await this.cipherService.get(cipher.id, activeUserId);
if (this.isAdminConsoleActive) {
updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher(
cipher.id as CipherId,
this.organization,
);
}
const updatedCipherView = await updatedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId),
);
// update the cipher views
const updatedReportResult = this.determineWeakPasswordScore(updatedCipherView);
const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id);
if (updatedReportResult == null) {
// the password is no longer weak
// remove the cipher from the list
this.weakPasswordCiphers.splice(index, 1);
this.filterCiphersByOrg(this.weakPasswordCiphers);
return;
}
const hasUserName = this.isUserNameNotEmpty(ciph);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
login.username
.substr(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
if (index > -1) {
// update the existing cipher
this.weakPasswordCiphers[index] = updatedReportResult;
this.filterCiphersByOrg(this.weakPasswordCiphers);
}
const result = this.passwordStrengthService.getPasswordStrength(
login.password,
null,
userInput.length > 0 ? userInput : null,
);
}
}
if (result.score != null && result.score <= 2) {
const scoreValue = this.scoreKey(result.score);
const row = {
...ciph,
score: result.score,
reportValue: scoreValue,
scoreKey: scoreValue.sortOrder,
} as ReportResult;
protected findWeakPasswords(ciphers: CipherView[]): void {
ciphers.forEach((ciph) => {
const row = this.determineWeakPasswordScore(ciph);
if (row != null) {
this.weakPasswordCiphers.push(row);
}
});
this.filterCiphersByOrg(this.weakPasswordCiphers);
}
protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
) {
return;
}
const hasUserName = this.isUserNameNotEmpty(ciph);
let userInput: string[] = [];
if (hasUserName) {
const atPosition = login.username.indexOf("@");
if (atPosition > -1) {
userInput = userInput
.concat(
login.username
.substr(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/),
)
.filter((i) => i.length >= 3);
} else {
userInput = login.username
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter((i) => i.length >= 3);
}
}
const result = this.passwordStrengthService.getPasswordStrength(
login.password,
null,
userInput.length > 0 ? userInput : null,
);
if (result.score != null && result.score <= 2) {
const scoreValue = this.scoreKey(result.score);
return {
...ciph,
score: result.score,
reportValue: scoreValue,
scoreKey: scoreValue.sortOrder,
} as ReportResult;
}
return null;
}
protected canManageCipher(c: CipherView): boolean {
// this will only ever be false from the org view;
return true;

View File

@@ -79,7 +79,10 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { getNestedCollectionTree } from "../../admin-console/organizations/collections";
import {
getNestedCollectionTree,
getFlatCollectionTree,
} from "../../admin-console/organizations/collections";
import {
CollectionDialogAction,
CollectionDialogTabType,
@@ -372,31 +375,35 @@ export class VaultComponent implements OnInit, OnDestroy {
if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
return [];
}
let collectionsToReturn = [];
let searchableCollectionNodes: TreeNode<CollectionView>[] = [];
if (filter.organizationId !== undefined && filter.collectionId === All) {
collectionsToReturn = collections
.filter((c) => c.node.organizationId === filter.organizationId)
.map((c) => c.node);
searchableCollectionNodes = collections.filter(
(c) => c.node.organizationId === filter.organizationId,
);
} else if (filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
searchableCollectionNodes = collections;
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId,
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
searchableCollectionNodes = selectedCollection?.children ?? [];
}
if (await this.searchService.isSearchable(activeUserId, searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
// Flatten the tree for searching through all levels
const flatCollectionTree: CollectionView[] =
getFlatCollectionTree(searchableCollectionNodes);
return this.searchPipe.transform(
flatCollectionTree,
searchText,
(collection) => collection.name,
(collection) => collection.id,
);
}
return collectionsToReturn;
return searchableCollectionNodes.map((treeNode: TreeNode<CollectionView>) => treeNode.node);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);

View File

@@ -100,7 +100,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
};
}
private async getCipher(id: CipherId | null, organization: Organization): Promise<Cipher | null> {
async getCipher(id: CipherId | null, organization: Organization): Promise<Cipher | null> {
if (id == null) {
return null;
}

View File

@@ -6303,13 +6303,13 @@
"sponsoredBitwardenFamilies": {
"message": "Sponsored families"
},
"noSponsoredFamilies": {
"noSponsoredFamiliesMessage": {
"message": "No sponsored families"
},
"noSponsoredFamiliesDescription": {
"nosponsoredFamiliesDetails": {
"message": "Sponsored non-member families plans will display here"
},
"sponsorFreeBitwardenFamilies": {
"sponsorshipFreeBitwardenFamilies": {
"message": "Members of your organization are eligible for Free Bitwarden Families. You can sponsor Free Bitwarden Families for employees who are not a member of your Bitwarden organization. Sponsoring a non-member requires an available seat within your organization."
},
"sponsoredFamiliesRemoveActiveSponsorship": {
@@ -6321,14 +6321,14 @@
"sponsoredFamiliesEligibleCard": {
"message": "Redeem your Free Bitwarden for Families plan today to keep your data secure even when you are not at work."
},
"sponsoredFamiliesInclude": {
"message": "The Bitwarden for Families plan include"
"sponsoredFamiliesIncludeMessage": {
"message": "The Bitwarden for Families plan includes"
},
"sponsoredFamiliesPremiumAccess": {
"message": "Premium access for up to 6 users"
},
"sponsoredFamiliesSharedCollections": {
"message": "Shared collections for Family secrets"
"sponsoredFamiliesSharedCollectionsMessage": {
"message": "Shared collections for family members"
},
"memberFamilies": {
"message": "Member families"
@@ -6342,6 +6342,15 @@
"membersWithSponsoredFamilies": {
"message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization."
},
"organizationHasMemberMessage": {
"message": "A sponsorship cannot be sent to $EMAIL$ because they are a member of your organization.",
"placeholders": {
"email": {
"content": "$1",
"example": "mail@example.com"
}
}
},
"badToken": {
"message": "The link is no longer valid. Please have the sponsor resend the offer."
},
@@ -6393,7 +6402,7 @@
"redeemedAccount": {
"message": "Account redeemed"
},
"revokeAccount": {
"revokeAccountMessage": {
"message": "Revoke account $NAME$",
"placeholders": {
"name": {
@@ -10620,7 +10629,40 @@
"newBusinessUnit": {
"message": "New business unit"
},
"newLoginNudgeTitle": {
"message": "Save time with autofill"
},
"newLoginNudgeBody": {
"message": "Include a Website so this login appears as an autofill suggestion."
},
"newCardNudgeTitle": {
"message": "Seamless online checkout"
},
"newCardNudgeBody": {
"message": "With cards, easily autofill payment forms securely and accurately."
},
"newIdentityNudgeTitle": {
"message": "Simplify creating accounts"
},
"newIdentityNudgeBody": {
"message": "With identities, quickly autofill long registration or contact forms."
},
"newNoteNudgeTitle": {
"message": "Keep your sensitive data safe"
},
"newNoteNudgeBody": {
"message": "With notes, securely store sensitive data like banking or insurance details."
},
"newSshNudgeTitle": {
"message": "Developer-friendly SSH access"
},
"newSshNudgeBody": {
"message": "Store your keys and connect with the SSH agent for fast, encrypted authentication."
},
"restart": {
"message": "Restart"
},
"verifyProviderBankAccountWithStatementDescriptorWarning": {
"message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the provider's subscription page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended."
}
}

View File

@@ -59,7 +59,7 @@ describe("CriticalAppsService", () => {
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName"));
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
// act
@@ -67,7 +67,7 @@ describe("CriticalAppsService", () => {
// expectations
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
expect(encryptService.encrypt).toHaveBeenCalledTimes(2);
expect(encryptService.encryptString).toHaveBeenCalledTimes(2);
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
});
@@ -95,7 +95,7 @@ describe("CriticalAppsService", () => {
{ id: "id1", organizationId: "org1", uri: "test" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.encrypt.mockResolvedValue(new EncString("encryptedUrlName"));
encryptService.encryptString.mockResolvedValue(new EncString("encryptedUrlName"));
criticalAppsApiService.saveCriticalApps.mockReturnValue(of(response));
// act
@@ -103,7 +103,7 @@ describe("CriticalAppsService", () => {
// expectations
expect(keyService.getOrgKey).toHaveBeenCalledWith("org1");
expect(encryptService.encrypt).toHaveBeenCalledTimes(1);
expect(encryptService.encryptString).toHaveBeenCalledTimes(1);
expect(criticalAppsApiService.saveCriticalApps).toHaveBeenCalledWith(request);
});
@@ -114,7 +114,7 @@ describe("CriticalAppsService", () => {
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
encryptService.decryptToUtf8.mockResolvedValue("https://example.com");
encryptService.decryptString.mockResolvedValue("https://example.com");
criticalAppsApiService.getCriticalApps.mockReturnValue(of(response));
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
@@ -125,7 +125,7 @@ describe("CriticalAppsService", () => {
flush();
expect(keyService.getOrgKey).toHaveBeenCalledWith(orgId.toString());
expect(encryptService.decryptToUtf8).toHaveBeenCalledTimes(2);
expect(encryptService.decryptString).toHaveBeenCalledTimes(2);
expect(criticalAppsApiService.getCriticalApps).toHaveBeenCalledWith(orgId);
}));

View File

@@ -81,7 +81,7 @@ export class CriticalAppsService {
// add the new entries to the criticalAppsList
const updatedList = [...this.criticalAppsList.value];
for (const responseItem of dbResponse) {
const decryptedUrl = await this.encryptService.decryptToUtf8(
const decryptedUrl = await this.encryptService.decryptString(
new EncString(responseItem.uri),
key,
);
@@ -138,7 +138,7 @@ export class CriticalAppsService {
const results = response.map(async (r: PasswordHealthReportApplicationsResponse) => {
const encrypted = new EncString(r.uri);
const uri = await this.encryptService.decryptToUtf8(encrypted, key);
const uri = await this.encryptService.decryptString(encrypted, key);
return { id: r.id, organizationId: r.organizationId, uri: uri };
});
return forkJoin(results);
@@ -164,7 +164,7 @@ export class CriticalAppsService {
newEntries: string[],
): Promise<PasswordHealthReportApplicationsRequest[]> {
const criticalAppsPromises = newEntries.map(async (url) => {
const encryptedUrlName = await this.encryptService.encrypt(url, key);
const encryptedUrlName = await this.encryptService.encryptString(url, key);
return {
organizationId: orgId,
url: encryptedUrlName?.encryptedString?.toString() ?? "",

View File

@@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CardComponent, SearchModule } from "@bitwarden/components";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component";
import { VerifyBankAccountComponent } from "@bitwarden/web-vault/app/billing/shared/verify-bank-account/verify-bank-account.component";
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
@@ -53,6 +54,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
ScrollingModule,
VerifyBankAccountComponent,
CardComponent,
PaymentComponent,
],
declarations: [
AcceptProviderComponent,

View File

@@ -12,23 +12,50 @@
</div>
<p>{{ "setupProviderDesc" | i18n }}</p>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<h2 class="tw-mt-5">{{ "generalInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
@if (!(requireProviderPaymentMethodDuringSetup$ | async)) {
<h2 class="tw-mt-5">{{ "generalInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
</div>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
<app-manage-tax-information />
} @else {
<h2 class="tw-mt-5">{{ "billingInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
</div>
</div>
</div>
<app-manage-tax-information />
<h2 class="tw-mt-5">{{ "paymentMethod" | i18n }}</h2>
<app-payment
[showAccountCredit]="false"
[bankAccountWarningOverride]="
'verifyProviderBankAccountWithStatementDescriptorWarning' | i18n
"
/>
<app-manage-tax-information />
}
<button class="tw-mt-8" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>

View File

@@ -3,13 +3,14 @@
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, switchMap } from "rxjs";
import { firstValueFrom, Subject, switchMap } from "rxjs";
import { first, takeUntil } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -17,12 +18,14 @@ import { ProviderKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component";
@Component({
selector: "provider-setup",
templateUrl: "setup.component.html",
})
export class SetupComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxInformationComponent: ManageTaxInformationComponent;
loading = true;
@@ -36,6 +39,10 @@ export class SetupComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
requireProviderPaymentMethodDuringSetup$ = this.configService.getFeatureFlag$(
FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup,
);
constructor(
private router: Router,
private i18nService: I18nService,
@@ -134,6 +141,14 @@ export class SetupComponent implements OnInit, OnDestroy {
request.taxInfo.city = taxInformation.city;
request.taxInfo.state = taxInformation.state;
const requireProviderPaymentMethodDuringSetup = await firstValueFrom(
this.requireProviderPaymentMethodDuringSetup$,
);
if (requireProviderPaymentMethodDuringSetup) {
request.paymentSource = await this.paymentComponent.tokenize();
}
const provider = await this.providerApiService.postProviderSetup(this.providerId, request);
this.toastService.showToast({

View File

@@ -83,11 +83,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
return;
}
const email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
if (this.kdfConfig == null) {
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
}
// Create new master key

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { Directive } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
@@ -10,6 +11,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -96,8 +98,8 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
});
return false;
}
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
return true;
}

View File

@@ -110,10 +110,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
}
async setupSubmitActions(): Promise<boolean> {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
this.email = email;
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
return true;
}

View File

@@ -136,11 +136,13 @@ import {
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
@@ -1063,6 +1065,11 @@ const safeProviders: SafeProvider[] = [
// subscribes to sync notifications and will update itself based on that.
deps: [ApiServiceAbstraction, SyncService],
}),
safeProvider({
provide: OrganizationSponsorshipApiServiceAbstraction,
useClass: OrganizationSponsorshipApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: OrganizationBillingApiServiceAbstraction,
useClass: OrganizationBillingApiService,

View File

@@ -172,7 +172,7 @@ export class PinService implements PinServiceAbstraction {
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
const pinKey = await this.makePinKey(pin, email, kdfConfig);
return await this.encryptService.wrapSymmetricKey(userKey, pinKey);
@@ -293,7 +293,7 @@ export class PinService implements PinServiceAbstraction {
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
const userKey: UserKey = await this.decryptUserKey(
userId,

View File

@@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
isAdminInitiated?: boolean;
notes?: string;
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request";
import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request";
export class ProviderSetupRequest {
name: string;
@@ -9,4 +10,5 @@ export class ProviderSetupRequest {
token: string;
key: string;
taxInfo: ExpandedTaxInfoUpdateRequest;
paymentSource?: TokenizedPaymentSourceRequest;
}

View File

@@ -117,7 +117,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
masterKey = await this.keyService.makeMasterKey(
verification.secret,
email,
await this.kdfConfigService.getKdfConfig(),
await this.kdfConfigService.getKdfConfig(userId),
);
}
request.masterPasswordHash = alreadyHashed
@@ -186,7 +186,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
throw new Error("Email is required. Cannot verify user by master password.");
}
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
if (!kdfConfig) {
throw new Error("KDF config is required. Cannot verify user by master password.");
}

View File

@@ -0,0 +1,8 @@
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export abstract class OrganizationSponsorshipApiServiceAbstraction {
abstract getOrganizationSponsorship(
sponsoredOrgId: string,
): Promise<ListResponse<OrganizationSponsorshipInvitesResponse>>;
}

View File

@@ -0,0 +1,31 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PlanSponsorshipType } from "../../enums";
export class OrganizationSponsorshipInvitesResponse extends BaseResponse {
sponsoringOrganizationUserId: string;
friendlyName: string;
offeredToEmail: string;
planSponsorshipType: PlanSponsorshipType;
lastSyncDate?: Date;
validUntil?: Date;
toDelete = false;
isAdminInitiated: boolean;
notes: string;
statusMessage?: string;
statusClass?: string;
constructor(response: any) {
super(response);
this.sponsoringOrganizationUserId = this.getResponseProperty("SponsoringOrganizationUserId");
this.friendlyName = this.getResponseProperty("FriendlyName");
this.offeredToEmail = this.getResponseProperty("OfferedToEmail");
this.planSponsorshipType = this.getResponseProperty("PlanSponsorshipType");
this.lastSyncDate = this.getResponseProperty("LastSyncDate");
this.validUntil = this.getResponseProperty("ValidUntil");
this.toDelete = this.getResponseProperty("ToDelete") ?? false;
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
this.notes = this.getResponseProperty("Notes");
this.statusMessage = this.getResponseProperty("StatusMessage");
this.statusClass = this.getResponseProperty("StatusClass");
}
}

View File

@@ -0,0 +1,22 @@
import { ApiService } from "../../../abstractions/api.service";
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export class OrganizationSponsorshipApiService
implements OrganizationSponsorshipApiServiceAbstraction
{
constructor(private apiService: ApiService) {}
async getOrganizationSponsorship(
sponsoredOrgId: string,
): Promise<ListResponse<OrganizationSponsorshipInvitesResponse>> {
const r = await this.apiService.send(
"GET",
"/organization/sponsorship/" + sponsoredOrgId + "/sponsored",
null,
true,
true,
);
return new ListResponse(r, OrganizationSponsorshipInvitesResponse);
}
}

View File

@@ -34,6 +34,7 @@ export enum FeatureFlag {
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup",
/* Data Insights and Reporting */
CriticalApps = "pm-14466-risk-insights-critical-application",
@@ -121,6 +122,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -206,7 +206,7 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk", { web: "disk-local" });
export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
"vaultBrowserIntroCarousel",
"disk",

View File

@@ -28,6 +28,8 @@ import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state";
import { SyncOptions } from "./sync.service";
const LAST_SYNC_DATE = new UserKeyDefinition<Date>(SYNC_DISK, "lastSync", {
deserializer: (d) => (d != null ? new Date(d) : null),
clearOn: ["logout"],
@@ -55,6 +57,7 @@ export abstract class CoreSyncService implements SyncService {
protected readonly stateProvider: StateProvider,
) {}
abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise<boolean>;
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;
async getLastSync(): Promise<Date> {

View File

@@ -0,0 +1,199 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import {
LogoutReason,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
import { Matrix } from "../../../spec/matrix";
import { ApiService } from "../../abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "../../admin-console/abstractions/provider.service";
import { Account, AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AvatarService } from "../../auth/abstractions/avatar.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { StateProvider } from "../state";
import { DefaultSyncService } from "./default-sync.service";
import { SyncResponse } from "./sync.response";
describe("DefaultSyncService", () => {
let masterPasswordAbstraction: MockProxy<InternalMasterPasswordServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let domainSettingsService: MockProxy<DomainSettingsService>;
let folderService: MockProxy<InternalFolderService>;
let cipherService: MockProxy<CipherService>;
let keyService: MockProxy<KeyService>;
let collectionService: MockProxy<CollectionService>;
let messageSender: MockProxy<MessageSender>;
let policyService: MockProxy<InternalPolicyService>;
let sendService: MockProxy<InternalSendService>;
let logService: MockProxy<LogService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let stateService: MockProxy<StateService>;
let providerService: MockProxy<ProviderService>;
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let sendApiService: MockProxy<SendApiService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let avatarService: MockProxy<AvatarService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let tokenService: MockProxy<TokenService>;
let authService: MockProxy<AuthService>;
let stateProvider: MockProxy<StateProvider>;
let sut: DefaultSyncService;
beforeEach(() => {
masterPasswordAbstraction = mock();
accountService = mock();
apiService = mock();
domainSettingsService = mock();
folderService = mock();
cipherService = mock();
keyService = mock();
collectionService = mock();
messageSender = mock();
policyService = mock();
sendService = mock();
logService = mock();
keyConnectorService = mock();
stateService = mock();
providerService = mock();
folderApiService = mock();
organizationService = mock();
sendApiService = mock();
userDecryptionOptionsService = mock();
avatarService = mock();
logoutCallback = jest.fn();
billingAccountProfileStateService = mock();
tokenService = mock();
authService = mock();
stateProvider = mock();
sut = new DefaultSyncService(
masterPasswordAbstraction,
accountService,
apiService,
domainSettingsService,
folderService,
cipherService,
keyService,
collectionService,
messageSender,
policyService,
sendService,
logService,
keyConnectorService,
stateService,
providerService,
folderApiService,
organizationService,
sendApiService,
userDecryptionOptionsService,
avatarService,
logoutCallback,
billingAccountProfileStateService,
tokenService,
authService,
stateProvider,
);
});
const user1 = "user1" as UserId;
describe("fullSync", () => {
beforeEach(() => {
accountService.activeAccount$ = of({ id: user1 } as Account);
Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked));
apiService.getSync.mockResolvedValue(
new SyncResponse({
profile: {
id: user1,
},
folders: [],
collections: [],
ciphers: [],
sends: [],
domains: [],
policies: [],
}),
);
Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () =>
of({ hasMasterPassword: true } satisfies UserDecryptionOptions),
);
stateProvider.getUser.mockReturnValue(mock());
});
it("does a token refresh when option missing from options", async () => {
await sut.fullSync(true, { allowThrowOnError: false });
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does a token refresh when boolean passed in", async () => {
await sut.fullSync(true, false);
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does a token refresh when skipTokenRefresh option passed in with false and allowThrowOnError also passed in", async () => {
await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: false });
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does a token refresh when skipTokenRefresh option passed in with false by itself", async () => {
await sut.fullSync(true, { skipTokenRefresh: false });
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does not do a token refresh when skipTokenRefresh passed in as true", async () => {
await sut.fullSync(true, { skipTokenRefresh: true });
expect(apiService.refreshIdentityToken).not.toHaveBeenCalled();
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does not do a token refresh when skipTokenRefresh passed in as true and allowThrowOnError also passed in", async () => {
await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: true });
expect(apiService.refreshIdentityToken).not.toHaveBeenCalled();
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does a token refresh when nothing passed in", async () => {
await sut.fullSync(true);
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -54,6 +54,7 @@ import { MessageSender } from "../messaging";
import { StateProvider } from "../state";
import { CoreSyncService } from "./core-sync.service";
import { SyncOptions } from "./sync.service";
export class DefaultSyncService extends CoreSyncService {
syncInProgress = false;
@@ -102,7 +103,15 @@ export class DefaultSyncService extends CoreSyncService {
);
}
override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
override async fullSync(
forceSync: boolean,
allowThrowOnErrorOrOptions?: boolean | SyncOptions,
): Promise<boolean> {
const { allowThrowOnError = false, skipTokenRefresh = false } =
typeof allowThrowOnErrorOrOptions === "boolean"
? { allowThrowOnError: allowThrowOnErrorOrOptions }
: (allowThrowOnErrorOrOptions ?? {});
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
this.syncStarted();
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
@@ -127,7 +136,9 @@ export class DefaultSyncService extends CoreSyncService {
}
try {
await this.apiService.refreshIdentityToken();
if (!skipTokenRefresh) {
await this.apiService.refreshIdentityToken();
}
const response = await this.apiService.getSync();
await this.syncProfile(response.profile);

View File

@@ -7,6 +7,26 @@ import {
} from "../../models/response/notification.response";
import { UserId } from "../../types/guid";
/**
* A set of options for configuring how a {@link SyncService.fullSync} call should behave.
*/
export type SyncOptions = {
/**
* A boolean dictating whether or not caught errors should be rethrown.
* `true` if they can be rethrown, `false` if they should not be rethrown.
* @default false
*/
allowThrowOnError?: boolean;
/**
* A boolean dictating whether or not to do a token refresh before doing the sync.
* `true` if the refresh can be skipped, likely because one was done soon before the call to
* `fullSync`. `false` if the token refresh should be done before getting data.
*
* @default false
*/
skipTokenRefresh?: boolean;
};
/**
* A class encapsulating sync operations and data.
*/
@@ -47,9 +67,12 @@ export abstract class SyncService {
* as long as the current user is authenticated. If `false` it will only sync if either a sync
* has not happened before or the last sync date for the active user is before their account
* revision date. Try to always use `false` if possible.
*
* @param allowThrowOnError A boolean dictating whether or not caught errors should be rethrown.
* `true` if they can be rethrown, `false` if they should not be rethrown.
* @param syncOptions Options for customizing how the sync call should behave.
*/
abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise<boolean>;
/**
* @deprecated Use the overload taking {@link SyncOptions} instead.
*/
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;

View File

@@ -22,8 +22,10 @@ describe("OrgKeyEncryptor", () => {
// on this property--that the facade treats its data like a opaque objects--to trace
// the data through several function calls. Should the encryptor interact with the
// objects themselves, these mocks will break.
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c as unknown as string));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});
@@ -95,7 +97,7 @@ describe("OrgKeyEncryptor", () => {
// these are data flow expectations; the operations all all pass-through mocks
expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey);
expect(encryptService.encryptString).toHaveBeenCalledWith(value, orgKey);
expect(result).toBe(value);
});
});
@@ -117,7 +119,7 @@ describe("OrgKeyEncryptor", () => {
const result = await encryptor.decrypt(secret);
// these are data flow expectations; the operations all all pass-through mocks
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey);
expect(encryptService.decryptString).toHaveBeenCalledWith(secret, orgKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret);
});

View File

@@ -37,7 +37,7 @@ export class OrganizationKeyEncryptor extends OrganizationEncryptor {
this.assertHasValue("secret", secret);
let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
const encrypted = await this.encryptService.encryptString(packed, this.key);
packed = null;
return encrypted;
@@ -46,7 +46,7 @@ export class OrganizationKeyEncryptor extends OrganizationEncryptor {
async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret);
let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
let decrypted = await this.encryptService.decryptString(secret, this.key);
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;

View File

@@ -22,8 +22,10 @@ describe("UserKeyEncryptor", () => {
// on this property--that the facade treats its data like a opaque objects--to trace
// the data through several function calls. Should the encryptor interact with the
// objects themselves, these mocks will break.
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c as unknown as string));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});
@@ -95,7 +97,7 @@ describe("UserKeyEncryptor", () => {
// these are data flow expectations; the operations all all pass-through mocks
expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey);
expect(encryptService.encryptString).toHaveBeenCalledWith(value, userKey);
expect(result).toBe(value);
});
});
@@ -117,7 +119,7 @@ describe("UserKeyEncryptor", () => {
const result = await encryptor.decrypt(secret);
// these are data flow expectations; the operations all all pass-through mocks
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
expect(encryptService.decryptString).toHaveBeenCalledWith(secret, userKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret);
});

View File

@@ -37,7 +37,7 @@ export class UserKeyEncryptor extends UserEncryptor {
this.assertHasValue("secret", secret);
let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
const encrypted = await this.encryptService.encryptString(packed, this.key);
packed = null;
return encrypted;
@@ -46,7 +46,7 @@ export class UserKeyEncryptor extends UserEncryptor {
async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret);
let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
let decrypted = await this.encryptService.decryptString(secret, this.key);
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;

View File

@@ -112,7 +112,7 @@ describe("Send", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<KeyService>();
encryptService.decryptToBytes
encryptService.decryptBytes
.calledWith(send.key, userKey)
.mockResolvedValue(makeStaticByteArray(32));
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);

View File

@@ -79,7 +79,8 @@ export class Send extends Domain {
try {
const sendKeyEncryptionKey = await keyService.getUserKey();
model.key = await encryptService.decryptToBytes(this.key, sendKeyEncryptionKey);
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -477,7 +477,9 @@ describe("SendService", () => {
let encryptedKey: EncString;
beforeEach(() => {
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)),
);
encryptedKey = new EncString("Re-encrypted Send Key");
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
});

View File

@@ -86,12 +86,12 @@ export class SendService implements InternalSendServiceAbstraction {
userKey = await this.keyService.getUserKey();
}
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
send.key = await this.encryptService.encrypt(model.key, userKey);
send.name = await this.encryptService.encrypt(model.name, model.cryptoKey);
send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey);
send.key = await this.encryptService.encryptBytes(model.key, userKey);
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
if (send.type === SendType.Text) {
send.text = new SendText();
send.text.text = await this.encryptService.encrypt(model.text.text, model.cryptoKey);
send.text.text = await this.encryptService.encryptString(model.text.text, model.cryptoKey);
send.text.hidden = model.text.hidden;
} else if (send.type === SendType.File) {
send.file = new SendFile();
@@ -292,9 +292,7 @@ export class SendService implements InternalSendServiceAbstraction {
) {
const requests = await Promise.all(
sends.map(async (send) => {
const sendKey = new SymmetricCryptoKey(
await this.encryptService.decryptToBytes(send.key, originalUserKey),
);
const sendKey = await this.encryptService.unwrapSymmetricKey(send.key, originalUserKey);
send.key = await this.encryptService.wrapSymmetricKey(sendKey, rotateUserKey);
return new SendWithIdRequest(send);
}),
@@ -333,8 +331,8 @@ export class SendService implements InternalSendServiceAbstraction {
if (key == null) {
key = await this.keyService.getUserKey();
}
const encFileName = await this.encryptService.encrypt(fileName, key);
const encFileData = await this.encryptService.encryptToBytes(new Uint8Array(data), key);
const encFileName = await this.encryptService.encryptString(fileName, key);
const encFileData = await this.encryptService.encryptFileData(new Uint8Array(data), key);
return [encFileName, encFileData];
}

View File

@@ -1 +1,2 @@
export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component";

View File

@@ -72,7 +72,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
keyForDecryption = await this.keyService.getUserKeyWithLegacySupport();
}
const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptString(
encKeyValidation,
keyForDecryption,
);

View File

@@ -92,7 +92,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
});
it("succeeds with default jdoc", async () => {
encryptService.decryptToUtf8.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
encryptService.decryptString.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
expect((await importer.parse(JSON.stringify(jDoc))).success).toEqual(true);
});

View File

@@ -69,7 +69,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
}
const encData = new EncString(parsedData.data);
const clearTextData = await this.encryptService.decryptToUtf8(encData, this.key);
const clearTextData = await this.encryptService.decryptString(encData, this.key);
return await super.parse(clearTextData);
}
@@ -90,7 +90,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
const encKeyValidation = new EncString(jdoc.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptString(
encKeyValidation,
this.key,
);

View File

@@ -6,6 +6,6 @@ import { KdfConfig } from "../models/kdf-config";
export abstract class KdfConfigService {
abstract setKdfConfig(userId: UserId, KdfConfig: KdfConfig): Promise<void>;
abstract getKdfConfig(): Promise<KdfConfig>;
abstract getKdfConfig(userId: UserId): Promise<KdfConfig>;
abstract getKdfConfig$(userId: UserId): Observable<KdfConfig | null>;
}

View File

@@ -26,90 +26,94 @@ describe("KdfConfigService", () => {
sutKdfConfigService = new DefaultKdfConfigService(fakeStateProvider);
});
it("setKdfConfig(): should set the PBKDF2KdfConfig config", async () => {
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig);
expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith(
KDF_CONFIG,
kdfConfig,
mockUserId,
);
describe("setKdfConfig", () => {
it("sets the PBKDF2KdfConfig config", async () => {
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig);
expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith(
KDF_CONFIG,
kdfConfig,
mockUserId,
);
});
it("sets the Argon2KdfConfig config", async () => {
const kdfConfig: KdfConfig = new Argon2KdfConfig(2, 63, 3);
await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig);
expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith(
KDF_CONFIG,
kdfConfig,
mockUserId,
);
});
it("throws error KDF cannot be null", async () => {
try {
await sutKdfConfigService.setKdfConfig(mockUserId, null as unknown as KdfConfig);
} catch (e) {
expect(e).toEqual(new Error("kdfConfig cannot be null"));
}
});
it("throws error userId cannot be null", async () => {
const kdfConfig: KdfConfig = new Argon2KdfConfig(3, 64, 4);
try {
await sutKdfConfigService.setKdfConfig(null as unknown as UserId, kdfConfig);
} catch (e) {
expect(e).toEqual(new Error("userId cannot be null"));
}
});
});
it("setKdfConfig(): should set the Argon2KdfConfig config", async () => {
const kdfConfig: KdfConfig = new Argon2KdfConfig(2, 63, 3);
await sutKdfConfigService.setKdfConfig(mockUserId, kdfConfig);
expect(fakeStateProvider.mock.setUserState).toHaveBeenCalledWith(
KDF_CONFIG,
kdfConfig,
mockUserId,
);
describe("getKdfConfig", () => {
it("throws error if userId is null", async () => {
await expect(sutKdfConfigService.getKdfConfig(null as unknown as UserId)).rejects.toThrow(
"userId cannot be null",
);
});
it("throws if target user doesn't have a KkfConfig", async () => {
const errorMessage = "KdfConfig for user " + mockUserId + " is null";
await expect(sutKdfConfigService.getKdfConfig(mockUserId)).rejects.toThrow(errorMessage);
});
it("returns KdfConfig of target user", async () => {
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId);
await expect(sutKdfConfigService.getKdfConfig(mockUserId)).resolves.toEqual(kdfConfig);
});
});
it("setKdfConfig(): should throw error KDF cannot be null", async () => {
try {
await sutKdfConfigService.setKdfConfig(mockUserId, null as unknown as KdfConfig);
} catch (e) {
expect(e).toEqual(new Error("kdfConfig cannot be null"));
}
});
describe("getKdfConfig$", () => {
it("gets KdfConfig of provided user", async () => {
await expect(
firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId)),
).resolves.toBeNull();
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId);
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual(
kdfConfig,
);
});
it("setKdfConfig(): should throw error userId cannot be null", async () => {
const kdfConfig: KdfConfig = new Argon2KdfConfig(3, 64, 4);
try {
await sutKdfConfigService.setKdfConfig(null as unknown as UserId, kdfConfig);
} catch (e) {
expect(e).toEqual(new Error("userId cannot be null"));
}
});
it("gets KdfConfig of provided user after changed", async () => {
await expect(
firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId)),
).resolves.toBeNull();
await fakeStateProvider.setUserState(KDF_CONFIG, new PBKDF2KdfConfig(500_000), mockUserId);
const kdfConfigChanged: KdfConfig = new PBKDF2KdfConfig(500_001);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfigChanged, mockUserId);
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual(
kdfConfigChanged,
);
});
it("getKdfConfig(): should get KdfConfig of active user", async () => {
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId);
await expect(sutKdfConfigService.getKdfConfig()).resolves.toEqual(kdfConfig);
});
it("getKdfConfig(): should throw error KdfConfig can only be retrieved when there is active user", async () => {
fakeAccountService.activeAccountSubject.next(null);
try {
await sutKdfConfigService.getKdfConfig();
} catch (e) {
expect(e).toEqual(new Error("KdfConfig can only be retrieved when there is active user"));
}
});
it("getKdfConfig(): should throw error KdfConfig for active user account state is null", async () => {
try {
await sutKdfConfigService.getKdfConfig();
} catch (e) {
expect(e).toEqual(new Error("KdfConfig for active user account state is null"));
}
});
it("getKdfConfig$(UserId): should get KdfConfig of provided user", async () => {
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toBeNull();
const kdfConfig: KdfConfig = new PBKDF2KdfConfig(500_000);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfig, mockUserId);
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual(
kdfConfig,
);
});
it("getKdfConfig$(UserId): should get KdfConfig of provided user after changed", async () => {
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toBeNull();
await fakeStateProvider.setUserState(KDF_CONFIG, new PBKDF2KdfConfig(500_000), mockUserId);
const kdfConfigChanged: KdfConfig = new PBKDF2KdfConfig(500_001);
await fakeStateProvider.setUserState(KDF_CONFIG, kdfConfigChanged, mockUserId);
await expect(firstValueFrom(sutKdfConfigService.getKdfConfig$(mockUserId))).resolves.toEqual(
kdfConfigChanged,
);
});
it("getKdfConfig$(UserId): should throw error userId cannot be null", async () => {
try {
sutKdfConfigService.getKdfConfig$(null as unknown as UserId);
} catch (e) {
expect(e).toEqual(new Error("userId cannot be null"));
}
it("throws error userId cannot be null", async () => {
try {
sutKdfConfigService.getKdfConfig$(null as unknown as UserId);
} catch (e) {
expect(e).toEqual(new Error("userId cannot be null"));
}
});
});
});

View File

@@ -37,14 +37,14 @@ export class DefaultKdfConfigService implements KdfConfigService {
await this.stateProvider.setUserState(KDF_CONFIG, kdfConfig, userId);
}
async getKdfConfig(): Promise<KdfConfig> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
async getKdfConfig(userId: UserId): Promise<KdfConfig> {
if (userId == null) {
throw new Error("KdfConfig can only be retrieved when there is active user");
throw new Error("userId cannot be null");
}
const state = await firstValueFrom(this.stateProvider.getUser(userId, KDF_CONFIG).state$);
if (state == null) {
throw new Error("KdfConfig for active user account state is null");
throw new Error("KdfConfig for user " + userId + " is null");
}
return state;
}

View File

@@ -4,6 +4,7 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management";
@@ -17,14 +18,18 @@ export class BaseVaultExportService {
private kdfConfigService: KdfConfigService,
) {}
protected async buildPasswordExport(clearText: string, password: string): Promise<string> {
const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig();
protected async buildPasswordExport(
userId: UserId,
clearText: string,
password: string,
): Promise<string> {
const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig(userId);
const salt = Utils.fromBufferToB64(await this.cryptoFunctionService.randomBytes(16));
const key = await this.pinService.makePinKey(password, salt, kdfConfig);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), key);
const encText = await this.encryptService.encrypt(clearText, key);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), key);
const encText = await this.encryptService.encryptString(clearText, key);
const jsonDoc: BitwardenPasswordProtectedFileFormat = {
encrypted: true,

View File

@@ -209,7 +209,7 @@ describe("VaultExportService", () => {
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
folderService.folders$.mockReturnValue(of(UserFolders));
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encryptString.mockResolvedValue(new EncString("encrypted"));
apiService.getAttachmentData.mockResolvedValue(attachmentResponse);
exportService = new IndividualVaultExportService(
@@ -313,7 +313,7 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
@@ -338,7 +338,7 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
@@ -362,7 +362,7 @@ describe("VaultExportService", () => {
cipherView.attachments = [attachmentView];
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
status: 200,
@@ -427,7 +427,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
encryptService.encryptString.mockResolvedValue(mac);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);
@@ -436,7 +436,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
encryptService.encryptString.mockResolvedValue(data);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);

View File

@@ -13,6 +13,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -59,19 +60,21 @@ export class IndividualVaultExportService
* @param format The format of the export
*/
async getExport(format: ExportFormat = "csv"): Promise<ExportedVault> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (format === "encrypted_json") {
return this.getEncryptedExport();
return this.getEncryptedExport(userId);
} else if (format === "zip") {
return this.getDecryptedExportZip();
return this.getDecryptedExportZip(userId);
}
return this.getDecryptedExport(format);
return this.getDecryptedExport(userId, format);
}
/** Creates a password protected export of an individiual vault (My Vault) as a JSON file
/** Creates a password protected export of an individual vault (My Vault) as a JSON file
* @param password The password to encrypt the export with
* @returns A password-protected encrypted individual vault export
*/
async getPasswordProtectedExport(password: string): Promise<ExportedVaultAsString> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const exportVault = await this.getExport("json");
if (exportVault.type !== "text/plain") {
@@ -80,19 +83,20 @@ export class IndividualVaultExportService
return {
type: "text/plain",
data: await this.buildPasswordExport(exportVault.data, password),
data: await this.buildPasswordExport(userId, exportVault.data, password),
fileName: ExportHelper.getFileName("", "encrypted_json"),
} as ExportedVaultAsString;
}
/** Creates a unencrypted export of an individual vault including attachments
* @param activeUserId The user ID of the user requesting the export
* @returns A unencrypted export including attachments
*/
async getDecryptedExportZip(): Promise<ExportedVaultAsBlob> {
async getDecryptedExportZip(activeUserId: UserId): Promise<ExportedVaultAsBlob> {
const zip = new JSZip();
// ciphers
const exportedVault = await this.getDecryptedExport("json");
const exportedVault = await this.getDecryptedExport(activeUserId, "json");
zip.file("data.json", exportedVault.data);
const attachmentsFolder = zip.folder("attachments");
@@ -100,8 +104,6 @@ export class IndividualVaultExportService
throw new Error("Error creating attachments folder");
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// attachments
for (const cipher of await this.cipherService.getAllDecrypted(activeUserId)) {
if (
@@ -155,17 +157,19 @@ export class IndividualVaultExportService
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(cipher.organizationId);
return await this.encryptService.decryptToBytes(encBuf, key);
return await this.encryptService.decryptFileData(encBuf, key);
} catch {
throw new Error("Error decrypting attachment");
}
}
private async getDecryptedExport(format: "json" | "csv"): Promise<ExportedVaultAsString> {
private async getDecryptedExport(
activeUserId: UserId,
format: "json" | "csv",
): Promise<ExportedVaultAsString> {
let decFolders: FolderView[] = [];
let decCiphers: CipherView[] = [];
const promises = [];
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
promises.push(
firstValueFrom(this.folderService.folderViews$(activeUserId)).then((folders) => {
@@ -196,11 +200,10 @@ export class IndividualVaultExportService
} as ExportedVaultAsString;
}
private async getEncryptedExport(): Promise<ExportedVaultAsString> {
private async getEncryptedExport(activeUserId: UserId): Promise<ExportedVaultAsString> {
let folders: Folder[] = [];
let ciphers: Cipher[] = [];
const promises = [];
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
promises.push(
firstValueFrom(this.folderService.folders$(activeUserId)).then((f) => {
@@ -216,10 +219,8 @@ export class IndividualVaultExportService
await Promise.all(promises);
const userKey = await this.keyService.getUserKeyWithLegacySupport(
await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)),
);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), userKey);
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), userKey);
const jsonDoc: BitwardenEncryptedIndividualJsonExport = {
encrypted: true,

View File

@@ -18,7 +18,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
@@ -67,6 +67,7 @@ export class OrganizationVaultExportService
password: string,
onlyManagedCollections: boolean,
): Promise<ExportedVaultAsString> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const exportVault = await this.getOrganizationExport(
organizationId,
"json",
@@ -75,7 +76,7 @@ export class OrganizationVaultExportService
return {
type: "text/plain",
data: await this.buildPasswordExport(exportVault.data, password),
data: await this.buildPasswordExport(userId, exportVault.data, password),
fileName: ExportHelper.getFileName("org", "encrypted_json"),
} as ExportedVaultAsString;
}
@@ -102,12 +103,13 @@ export class OrganizationVaultExportService
if (format === "zip") {
throw new Error("Zip export not supported for organization");
}
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (format === "encrypted_json") {
return {
type: "text/plain",
data: onlyManagedCollections
? await this.getEncryptedManagedExport(organizationId)
? await this.getEncryptedManagedExport(userId, organizationId)
: await this.getOrganizationEncryptedExport(organizationId),
fileName: ExportHelper.getFileName("org", "encrypted_json"),
} as ExportedVaultAsString;
@@ -116,20 +118,20 @@ export class OrganizationVaultExportService
return {
type: "text/plain",
data: onlyManagedCollections
? await this.getDecryptedManagedExport(organizationId, format)
: await this.getOrganizationDecryptedExport(organizationId, format),
? await this.getDecryptedManagedExport(userId, organizationId, format)
: await this.getOrganizationDecryptedExport(userId, organizationId, format),
fileName: ExportHelper.getFileName("org", format),
} as ExportedVaultAsString;
}
private async getOrganizationDecryptedExport(
activeUserId: UserId,
organizationId: string,
format: "json" | "csv",
): Promise<string> {
const decCollections: CollectionView[] = [];
const decCiphers: CipherView[] = [];
const promises = [];
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
promises.push(
this.apiService.getOrganizationExport(organizationId).then((exportData) => {
@@ -210,6 +212,7 @@ export class OrganizationVaultExportService
}
private async getDecryptedManagedExport(
activeUserId: UserId,
organizationId: string,
format: "json" | "csv",
): Promise<string> {
@@ -217,7 +220,6 @@ export class OrganizationVaultExportService
let allDecCiphers: CipherView[] = [];
let decCollections: CollectionView[] = [];
const promises = [];
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
promises.push(
this.collectionService.getAllDecrypted().then(async (collections) => {
@@ -245,12 +247,14 @@ export class OrganizationVaultExportService
return this.buildJsonExport(decCollections, decCiphers);
}
private async getEncryptedManagedExport(organizationId: string): Promise<string> {
private async getEncryptedManagedExport(
activeUserId: UserId,
organizationId: string,
): Promise<string> {
let encCiphers: Cipher[] = [];
let allCiphers: Cipher[] = [];
let encCollections: Collection[] = [];
const promises = [];
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
promises.push(
this.collectionService.getAll().then((collections) => {
@@ -282,7 +286,7 @@ export class OrganizationVaultExportService
ciphers: Cipher[],
): Promise<string> {
const orgKey = await this.keyService.getOrgKey(organizationId);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), orgKey);
const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), orgKey);
const jsonDoc: BitwardenEncryptedOrgJsonExport = {
encrypted: true,

View File

@@ -175,7 +175,7 @@ describe("VaultExportService", () => {
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
folderService.folders$.mockReturnValue(of(UserFolders));
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encryptString.mockResolvedValue(new EncString("encrypted"));
keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
const userId = "" as UserId;
const accountInfo: AccountInfo = {
@@ -282,7 +282,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
encryptService.encryptString.mockResolvedValue(mac);
exportedVault = await exportService.getPasswordProtectedExport(password);
@@ -293,7 +293,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
encryptService.encryptString.mockResolvedValue(data);
exportedVault = await exportService.getPasswordProtectedExport(password);

View File

@@ -19,7 +19,7 @@ export class LegacyPasswordHistoryDecryptor {
const promises = (history ?? []).map(async (item) => {
const encrypted = new EncString(item.password);
const decrypted = await this.encryptService.decryptToUtf8(encrypted, key);
const decrypted = await this.encryptService.decryptString(encrypted, key);
return new GeneratedPasswordHistory(decrypted, item.date);
});

View File

@@ -23,9 +23,11 @@ describe("LocalGeneratorHistoryService", () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
beforeEach(() => {
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
// tests always provide a value for c.encryptedString
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString!));
encryptService.encryptString.mockImplementation((p) =>
Promise.resolve(p as unknown as EncString),
);
// in the test environment `c.encryptedString` always has a value
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString!));
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey));
});

View File

@@ -1,4 +1,4 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus-f" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>

View File

@@ -34,7 +34,9 @@ import { AsyncActionsModule, ButtonModule, ItemModule, ToastService } from "@bit
import {
CipherFormConfig,
CipherFormGenerationService,
NudgeStatus,
PasswordRepromptService,
VaultNudgesService,
} from "@bitwarden/vault";
// FIXME: remove `/apps` import from `/libs`
// FIXME: remove `src` and fix import
@@ -47,6 +49,7 @@ import { CipherFormService } from "./abstractions/cipher-form.service";
import { TotpCaptureService } from "./abstractions/totp-capture.service";
import { CipherFormModule } from "./cipher-form.module";
import { CipherFormComponent } from "./components/cipher-form.component";
import { NewItemNudgeComponent } from "./components/new-item-nudge/new-item-nudge.component";
import { CipherFormCacheService } from "./services/default-cipher-form-cache.service";
const defaultConfig: CipherFormConfig = {
@@ -132,8 +135,23 @@ export default {
component: CipherFormComponent,
decorators: [
moduleMetadata({
imports: [CipherFormModule, AsyncActionsModule, ButtonModule, ItemModule],
imports: [
CipherFormModule,
AsyncActionsModule,
ButtonModule,
ItemModule,
NewItemNudgeComponent,
],
providers: [
{
provide: VaultNudgesService,
useValue: {
showNudge$: new BehaviorSubject({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
} as NudgeStatus),
},
},
{
provide: CipherFormService,
useClass: TestAddEditFormService,

View File

@@ -1,3 +1,4 @@
<vault-new-item-nudge *ngIf="!loading" [configType]="config.cipherType"> </vault-new-item-nudge>
<form [id]="formId" [formGroup]="cipherForm" [bitSubmit]="submit">
<!-- TODO: Should we show a loading spinner here? Or emit a ready event for the container to handle loading state -->
<ng-container *ngIf="!loading">

View File

@@ -45,6 +45,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details
import { IdentitySectionComponent } from "./identity/identity.component";
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component";
import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component";
@Component({
@@ -76,6 +77,7 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen
NgIf,
AdditionalOptionsSectionComponent,
LoginDetailsSectionComponent,
NewItemNudgeComponent,
],
})
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {

View File

@@ -0,0 +1,8 @@
<ng-container *ngIf="showNewItemSpotlight">
<bit-spotlight
[title]="nudgeTitle"
[subtitle]="nudgeBody"
(onDismiss)="dismissNewItemSpotlight()"
>
</bit-spotlight>
</ng-container>

View File

@@ -0,0 +1,101 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/sdk-internal";
import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service";
import { NewItemNudgeComponent } from "./new-item-nudge.component";
describe("NewItemNudgeComponent", () => {
let component: NewItemNudgeComponent;
let fixture: ComponentFixture<NewItemNudgeComponent>;
let i18nService: MockProxy<I18nService>;
let accountService: MockProxy<AccountService>;
let vaultNudgesService: MockProxy<VaultNudgesService>;
beforeEach(async () => {
i18nService = mock<I18nService>({ t: (key: string) => key });
accountService = mock<AccountService>();
vaultNudgesService = mock<VaultNudgesService>();
await TestBed.configureTestingModule({
imports: [NewItemNudgeComponent, CommonModule],
providers: [
{ provide: I18nService, useValue: i18nService },
{ provide: AccountService, useValue: accountService },
{ provide: VaultNudgesService, useValue: vaultNudgesService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NewItemNudgeComponent);
component = fixture.componentInstance;
component.configType = null; // Set to null for initial state
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should set nudge title and body for CipherType.Login type", async () => {
component.configType = CipherType.Login;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
expect(component.nudgeTitle).toBe("newLoginNudgeTitle");
expect(component.nudgeBody).toBe("newLoginNudgeBody");
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newLoginItemStatus);
});
it("should set nudge title and body for CipherType.Card type", async () => {
component.configType = CipherType.Card;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
expect(component.nudgeTitle).toBe("newCardNudgeTitle");
expect(component.nudgeBody).toBe("newCardNudgeBody");
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newCardItemStatus);
});
it("should not show anything if spotlight has been dismissed", async () => {
component.configType = CipherType.Identity;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(false);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(false);
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newIdentityItemStatus);
});
it("should set showNewItemSpotlight to false when user dismisses spotlight", async () => {
component.showNewItemSpotlight = true;
component.dismissalNudgeType = VaultNudgeType.newLoginItemStatus;
component.activeUserId = "test-user-id" as UserId;
const dismissSpy = jest.spyOn(vaultNudgesService, "dismissNudge").mockResolvedValue();
await component.dismissNewItemSpotlight();
expect(component.showNewItemSpotlight).toBe(false);
expect(dismissSpy).toHaveBeenCalledWith(
VaultNudgeType.newLoginItemStatus,
component.activeUserId,
);
});
});

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