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:
4
.github/renovate.json5
vendored
4
.github/renovate.json5
vendored
@@ -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",
|
||||
|
||||
2
.github/workflows/build-web.yml
vendored
2
.github/workflows/build-web.yml
vendored
@@ -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
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master)
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main)
|
||||
[](https://crowdin.com/project/bitwarden-browser)
|
||||
[](https://gitter.im/bitwarden/Lobby)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
The Bitwarden browser extension is written using the Web Extension API and Angular.
|
||||
|
||||

|
||||

|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master)
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main)
|
||||
[](https://gitter.im/bitwarden/Lobby)
|
||||
|
||||
# Bitwarden Command-line Interface
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master)
|
||||
[](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main)
|
||||
[](https://crowdin.com/project/bitwarden-desktop)
|
||||
[](https://gitter.im/bitwarden/Lobby)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
|
||||
@@ -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() ?? "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest {
|
||||
sponsoredEmail: string;
|
||||
planSponsorshipType: PlanSponsorshipType;
|
||||
friendlyName: string;
|
||||
isAdminInitiated?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
199
libs/common/src/platform/sync/default-sync.service.spec.ts
Normal file
199
libs/common/src/platform/sync/default-sync.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./icon-button.module";
|
||||
export { BitIconButtonComponent } from "./icon-button.component";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<ng-container *ngIf="showNewItemSpotlight">
|
||||
<bit-spotlight
|
||||
[title]="nudgeTitle"
|
||||
[subtitle]="nudgeBody"
|
||||
(onDismiss)="dismissNewItemSpotlight()"
|
||||
>
|
||||
</bit-spotlight>
|
||||
</ng-container>
|
||||
@@ -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
Reference in New Issue
Block a user