mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 13:40:06 +00:00
Merge branch 'main' into PM-19741
This commit is contained in:
@@ -5228,6 +5228,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"
|
||||
},
|
||||
@@ -5254,5 +5260,20 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
>
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "autofillSuggestionsTip" | i18n }}</ng-container>
|
||||
<ng-container slot="description">
|
||||
<p bitTypography="body2" class="tw-mx-6">{{ "emptyVaultDescription" | i18n }}</p>
|
||||
</ng-container>
|
||||
<app-new-item-dropdown
|
||||
slot="button"
|
||||
[initialValues]="newItemItemValues$ | async"
|
||||
@@ -28,11 +30,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
4
apps/desktop/desktop_native/Cargo.lock
generated
4
apps/desktop/desktop_native/Cargo.lock
generated
@@ -1491,9 +1491,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.169"
|
||||
version = "0.2.172"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
|
||||
@@ -28,7 +28,7 @@ hex = "=0.4.3"
|
||||
homedir = "=0.3.4"
|
||||
interprocess = "=2.2.1"
|
||||
keytar = "=0.1.6"
|
||||
libc = "=0.2.169"
|
||||
libc = "=0.2.172"
|
||||
log = "=0.4.25"
|
||||
napi = "=2.16.15"
|
||||
napi-build = "=2.1.4"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -20,8 +20,9 @@ $theme-colors: (
|
||||
$body-bg: $white;
|
||||
$body-color: #333333;
|
||||
|
||||
$font-family-sans-serif: "DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
$font-family-sans-serif:
|
||||
"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$h1-font-size: 1.7rem;
|
||||
$h2-font-size: 1.3rem;
|
||||
|
||||
@@ -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() ?? "",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="tw-flex">
|
||||
<div class="tw-flex tw-gap-3">
|
||||
<ng-container *ngIf="!addButtonMode; else buttonMode">
|
||||
<bit-form-field class="tw-grow">
|
||||
<bit-label>{{ label }}</bit-label>
|
||||
@@ -97,7 +97,7 @@
|
||||
<bit-hint>{{ hint }}</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-ml-3 tw-mt-7 tw-shrink-0">
|
||||
<div class="tw-shrink-0 tw-mt-[0.6rem]">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ $theme-colors: (
|
||||
$body-bg: $white;
|
||||
$body-color: #333333;
|
||||
|
||||
$font-family-sans-serif: "DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
$font-family-sans-serif:
|
||||
"DM Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$h1-font-size: 1.7rem;
|
||||
$h2-font-size: 1.3rem;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ 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));
|
||||
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
|
||||
encryptService.encryptString.mockImplementation((p) =>
|
||||
Promise.resolve(p as unknown as EncString),
|
||||
);
|
||||
encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString));
|
||||
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
|
||||
keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey));
|
||||
});
|
||||
|
||||
@@ -32,3 +32,5 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service";
|
||||
|
||||
export * from "./abstractions/change-login-password.service";
|
||||
export * from "./services/default-change-login-password.service";
|
||||
|
||||
export { SpotlightComponent } from "./components/spotlight/spotlight.component";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service Checking Nudge Status For Empty Vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
cipherService = inject(CipherService);
|
||||
organizationService = inject(OrganizationService);
|
||||
collectionService = inject(CollectionService);
|
||||
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.organizationService.organizations$(userId),
|
||||
this.collectionService.decryptedCollections$,
|
||||
]).pipe(
|
||||
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
|
||||
const emptyVault = ciphers == null || ciphers.length === 0;
|
||||
if (orgs == null || orgs.length === 0) {
|
||||
return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed
|
||||
? of(nudgeStatus)
|
||||
: of({
|
||||
hasSpotlightDismissed: emptyVault,
|
||||
hasBadgeDismissed: emptyVault,
|
||||
});
|
||||
}
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
);
|
||||
// Do not show nudge when
|
||||
// user has previously dismissed nudge
|
||||
// OR
|
||||
// user belongs to an organization and cannot create collections || manage collections
|
||||
if (
|
||||
nudgeStatus.hasBadgeDismissed ||
|
||||
nudgeStatus.hasSpotlightDismissed ||
|
||||
hasManageCollections ||
|
||||
canCreateCollections
|
||||
) {
|
||||
return of(nudgeStatus);
|
||||
}
|
||||
return of({
|
||||
hasSpotlightDismissed: emptyVault,
|
||||
hasBadgeDismissed: emptyVault,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,49 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { map, Observable, of, switchMap } from "rxjs";
|
||||
import { combineLatest, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { VaultNudgeType } from "../vault-nudges.service";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service to use for the Onboarding Nudges in the Vault
|
||||
* Custom Nudge Service Checking Nudge Status For Welcome Nudge With Populated Vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class HasItemsNudgeService extends DefaultSingleNudgeService {
|
||||
cipherService = inject(CipherService);
|
||||
vaultProfileService = inject(VaultProfileService);
|
||||
logService = inject(LogService);
|
||||
|
||||
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.isDismissed$(nudgeType, userId).pipe(
|
||||
switchMap((dismissed) =>
|
||||
dismissed
|
||||
? of(false)
|
||||
: this.cipherService
|
||||
.cipherViews$(userId)
|
||||
.pipe(map((ciphers) => ciphers == null || ciphers.length === 0)),
|
||||
),
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.cipherService.cipherViews$(userId),
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
]).pipe(
|
||||
switchMap(async ([ciphers, nudgeStatus]) => {
|
||||
try {
|
||||
const creationDate = await this.vaultProfileService.getProfileCreationDate(userId);
|
||||
const thirtyDays = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const isRecentAcct = creationDate >= thirtyDays;
|
||||
|
||||
if (!isRecentAcct || nudgeStatus.hasSpotlightDismissed) {
|
||||
return nudgeStatus;
|
||||
} else {
|
||||
return {
|
||||
hasBadgeDismissed: ciphers == null || ciphers.length === 0,
|
||||
hasSpotlightDismissed: ciphers == null || ciphers.length === 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to fetch profile creation date: ", error);
|
||||
return nudgeStatus;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { VaultNudgeType } from "../vault-nudges.service";
|
||||
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Custom Nudge Service used for showing if the user has any existing nudge in the Vault.
|
||||
@@ -17,6 +17,7 @@ export class HasNudgeService extends DefaultSingleNudgeService {
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
private nudgeTypes: VaultNudgeType[] = [
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
VaultNudgeType.HasVaultItems,
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
// add additional nudge types here as needed
|
||||
@@ -25,20 +26,25 @@ export class HasNudgeService extends DefaultSingleNudgeService {
|
||||
/**
|
||||
* Returns an observable that emits true if any of the provided nudge types are present
|
||||
*/
|
||||
shouldShowNudge$(): Observable<boolean> {
|
||||
nudgeStatus$(): Observable<NudgeStatus> {
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
switchMap((activeAccount) => {
|
||||
const userId: UserId | undefined = activeAccount?.id;
|
||||
if (!userId) {
|
||||
return of(false);
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
|
||||
}
|
||||
|
||||
const nudgeObservables: Observable<boolean>[] = this.nudgeTypes.map((nudge) =>
|
||||
super.shouldShowNudge$(nudge, userId),
|
||||
const nudgeObservables: Observable<NudgeStatus>[] = this.nudgeTypes.map((nudge) =>
|
||||
super.nudgeStatus$(nudge, userId),
|
||||
);
|
||||
|
||||
return combineLatest(nudgeObservables).pipe(
|
||||
map((nudgeStates) => nudgeStates.some((state) => state)),
|
||||
map((nudgeStates) => {
|
||||
return {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: nudgeStates.some((state) => state.hasSpotlightDismissed),
|
||||
};
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./has-items-nudge.service";
|
||||
export * from "./empty-vault-nudge.service";
|
||||
export * from "./has-nudge.service";
|
||||
|
||||
@@ -4,15 +4,19 @@ import { map, Observable } from "rxjs";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { VAULT_NUDGE_DISMISSED_DISK_KEY, VaultNudgeType } from "./vault-nudges.service";
|
||||
import {
|
||||
NudgeStatus,
|
||||
VAULT_NUDGE_DISMISSED_DISK_KEY,
|
||||
VaultNudgeType,
|
||||
} from "./vault-nudges.service";
|
||||
|
||||
/**
|
||||
* Base interface for handling a nudge's status
|
||||
*/
|
||||
export interface SingleNudgeService {
|
||||
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean>;
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus>;
|
||||
|
||||
setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise<void>;
|
||||
setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,28 +28,29 @@ export interface SingleNudgeService {
|
||||
export class DefaultSingleNudgeService implements SingleNudgeService {
|
||||
stateProvider = inject(StateProvider);
|
||||
|
||||
protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||
protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return this.stateProvider
|
||||
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
|
||||
.state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false));
|
||||
.state$.pipe(
|
||||
map(
|
||||
(nudges) =>
|
||||
nudges?.[nudgeType] ?? { hasBadgeDismissed: false, hasSpotlightDismissed: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
|
||||
return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed));
|
||||
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return this.getNudgeStatus$(nudgeType, userId);
|
||||
}
|
||||
|
||||
async setNudgeStatus(
|
||||
nudgeType: VaultNudgeType,
|
||||
dismissed: boolean,
|
||||
status: NudgeStatus,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
|
||||
nudges ??= [];
|
||||
if (dismissed) {
|
||||
nudges.push(nudgeType);
|
||||
} else {
|
||||
nudges = nudges.filter((n) => n !== nudgeType);
|
||||
}
|
||||
nudges ??= {};
|
||||
nudges[nudgeType] = status;
|
||||
return nudges;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
|
||||
|
||||
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
|
||||
import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
|
||||
|
||||
@@ -15,6 +16,10 @@ describe("Vault Nudges Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
let testBed: TestBed;
|
||||
const mockConfigService = {
|
||||
getFeatureFlag$: jest.fn().mockReturnValue(of(true)),
|
||||
getFeatureFlag: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
@@ -32,50 +37,55 @@ describe("Vault Nudges Service", () => {
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{
|
||||
provide: HasItemsNudgeService,
|
||||
useValue: mock<HasItemsNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: EmptyVaultNudgeService,
|
||||
useValue: mock<EmptyVaultNudgeService>(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultSingleNudgeService", () => {
|
||||
it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => {
|
||||
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is true", async () => {
|
||||
const service = testBed.inject(DefaultSingleNudgeService);
|
||||
|
||||
await service.setNudgeStatus(
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
true,
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
{ hasBadgeDismissed: true, hasSpotlightDismissed: true },
|
||||
"user-id" as UserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
|
||||
});
|
||||
|
||||
it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => {
|
||||
it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is false", async () => {
|
||||
const service = testBed.inject(DefaultSingleNudgeService);
|
||||
|
||||
await service.setNudgeStatus(
|
||||
VaultNudgeType.IntroCarouselDismissal,
|
||||
false,
|
||||
VaultNudgeType.EmptyVaultNudge,
|
||||
{ hasBadgeDismissed: false, hasSpotlightDismissed: false },
|
||||
"user-id" as UserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
|
||||
service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaultNudgesService", () => {
|
||||
it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => {
|
||||
it("should return true, the proper value from the custom nudge service nudgeStatus$", async () => {
|
||||
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||
useValue: { shouldShowNudge$: () => of(true) },
|
||||
useValue: { nudgeStatus$: () => of(true) },
|
||||
});
|
||||
const service = testBed.inject(VaultNudgesService);
|
||||
|
||||
@@ -86,9 +96,9 @@ describe("Vault Nudges Service", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => {
|
||||
it("should return false, the proper value for the custom nudge service nudgeStatus$", async () => {
|
||||
TestBed.overrideProvider(HasItemsNudgeService, {
|
||||
useValue: { shouldShowNudge$: () => of(false) },
|
||||
useValue: { nudgeStatus$: () => of(false) },
|
||||
});
|
||||
const service = testBed.inject(VaultNudgesService);
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { of, switchMap } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
|
||||
import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
|
||||
|
||||
export type NudgeStatus = {
|
||||
hasBadgeDismissed: boolean;
|
||||
hasSpotlightDismissed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
|
||||
*/
|
||||
@@ -13,18 +21,17 @@ export enum VaultNudgeType {
|
||||
/** Nudge to show when user has no items in their vault
|
||||
* Add future nudges here
|
||||
*/
|
||||
EmptyVaultNudge = "empty-vault-nudge",
|
||||
HasVaultItems = "has-vault-items",
|
||||
IntroCarouselDismissal = "intro-carousel-dismissal",
|
||||
}
|
||||
|
||||
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<VaultNudgeType[]>(
|
||||
VAULT_NUDGES_DISK,
|
||||
"vaultNudgeDismissed",
|
||||
{
|
||||
deserializer: (nudgeDismissed) => nudgeDismissed,
|
||||
clearOn: [], // Do not clear dismissals
|
||||
},
|
||||
);
|
||||
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
|
||||
Partial<Record<VaultNudgeType, NudgeStatus>>
|
||||
>(VAULT_NUDGES_DISK, "vaultNudgeDismissed", {
|
||||
deserializer: (nudge) => nudge,
|
||||
clearOn: [], // Do not clear dismissals
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@@ -37,6 +44,7 @@ export class VaultNudgesService {
|
||||
*/
|
||||
private customNudgeServices: any = {
|
||||
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
|
||||
[VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -45,6 +53,7 @@ export class VaultNudgesService {
|
||||
* @private
|
||||
*/
|
||||
private defaultNudgeService = inject(DefaultSingleNudgeService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
|
||||
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
|
||||
@@ -56,7 +65,14 @@ export class VaultNudgesService {
|
||||
* @param userId
|
||||
*/
|
||||
showNudge$(nudge: VaultNudgeType, userId: UserId) {
|
||||
return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId);
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe(
|
||||
switchMap((hasVaultNudgeFlag) => {
|
||||
if (!hasVaultNudgeFlag) {
|
||||
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus);
|
||||
}
|
||||
return this.getNudgeService(nudge).nudgeStatus$(nudge, userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +80,10 @@ export class VaultNudgesService {
|
||||
* @param nudge
|
||||
* @param userId
|
||||
*/
|
||||
dismissNudge(nudge: VaultNudgeType, userId: UserId) {
|
||||
return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId);
|
||||
async dismissNudge(nudge: VaultNudgeType, userId: UserId, onlyBadge: boolean = false) {
|
||||
const dismissedStatus = onlyBadge
|
||||
? { hasBadgeDismissed: true, hasSpotlightDismissed: false }
|
||||
: { hasBadgeDismissed: true, hasSpotlightDismissed: true };
|
||||
await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId);
|
||||
}
|
||||
}
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -159,7 +159,7 @@
|
||||
"nx": "20.8.0",
|
||||
"postcss": "8.5.1",
|
||||
"postcss-loader": "8.1.1",
|
||||
"prettier": "3.4.2",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"process": "0.11.10",
|
||||
"remark-gfm": "4.0.0",
|
||||
@@ -178,7 +178,7 @@
|
||||
"typescript-strict-plugin": "2.4.4",
|
||||
"url": "0.11.4",
|
||||
"util": "0.12.5",
|
||||
"wait-on": "8.0.2",
|
||||
"wait-on": "8.0.3",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.0",
|
||||
@@ -30918,9 +30918,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
|
||||
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -36915,17 +36915,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.2.tgz",
|
||||
"integrity": "sha512-qHlU6AawrgAIHlueGQHQ+ETcPLAauXbnoTKl3RKq20W0T8x0DKVAo5xWIYjHSyvHxQlcYbFdR0jp4T9bDVITFA==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.3.tgz",
|
||||
"integrity": "sha512-nQFqAFzZDeRxsu7S3C7LbuxslHhk+gnJZHyethuGKAn2IVleIbTB9I3vJSQiSR+DifUqmdzfPMoMPJfLqMF2vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.8.2",
|
||||
"joi": "^17.13.3",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
@@ -36934,6 +36934,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on/node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walk-up-path": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz",
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"nx": "20.8.0",
|
||||
"postcss": "8.5.1",
|
||||
"postcss-loader": "8.1.1",
|
||||
"prettier": "3.4.2",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"process": "0.11.10",
|
||||
"remark-gfm": "4.0.0",
|
||||
@@ -140,7 +140,7 @@
|
||||
"typescript-strict-plugin": "2.4.4",
|
||||
"url": "0.11.4",
|
||||
"util": "0.12.5",
|
||||
"wait-on": "8.0.2",
|
||||
"wait-on": "8.0.3",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.0",
|
||||
|
||||
Reference in New Issue
Block a user