1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-08 11:33:28 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
Alec Rippberger
2024-09-24 21:58:22 -05:00
543 changed files with 31609 additions and 7636 deletions

View File

@@ -40,7 +40,7 @@
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutMemberRoles' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/"
slot="end"
>

View File

@@ -39,7 +39,7 @@
<p bitTypography="body1">
{{ "apiKeyDesc" | i18n }}
<a bitLink href="https://docs.bitwarden.com" target="_blank" rel="noreferrer">
{{ "learnMore" | i18n }}
{{ "learnMoreAboutApi" | i18n }}
</a>
</p>
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">

View File

@@ -188,7 +188,7 @@ export class AppComponent implements OnDestroy, OnInit {
if (premiumConfirmed) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["settings/subscription/premium"]);
await this.router.navigate(["settings/subscription/premium"]);
}
break;
}

View File

@@ -43,7 +43,7 @@
</div>
<p bitTypography="body1" class="tw-mb-0">
{{ "troubleLoggingIn" | i18n }}<br />
<a routerLink="/login">{{ "useADifferentLogInMethod" | i18n }}</a>
<a bitLink routerLink="/login">{{ "useADifferentLogInMethod" | i18n }}</a>
</p>
</div>
</div>

View File

@@ -48,14 +48,15 @@
<p class="tw-m-0 tw-text-sm">
{{ "newAroundHere" | i18n }}
<!-- Two notes:
(1) We check the value and validity of email so we don't send an invalid email to autofill
<!-- Two notes:
(1) We check the value and validity of email so we don't send an invalid email to autofill
on load of register for both enter and mouse based navigation
(2) We use mousedown to trigger navigation so that the onBlur form validation does not fire
and move the create account link down the page on click which causes the user to miss actually
clicking on the link. Mousedown fires before onBlur.
(2) We use mousedown to trigger navigation so that the onBlur form validation does not fire
and move the create account link down the page on click which causes the user to miss actually
clicking on the link. Mousedown fires before onBlur.
-->
<a
bitLink
[routerLink]="registerRoute$ | async"
[queryParams]="emailFormControl.valid ? { email: emailFormControl.value } : {}"
(mousedown)="goToRegister()"
@@ -72,6 +73,7 @@
<button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
</bit-form-field>
<a
bitLink
class="tw-mt-2"
routerLink="/hint"
(mousedown)="goToHint()"
@@ -120,7 +122,7 @@
<div class="tw-m-0 tw-text-sm">
<p class="tw-mb-1">{{ "loggingInAs" | i18n }} {{ loggedEmail }}</p>
<a [routerLink]="[]" (click)="toggleValidateEmail(false)">{{ "notYou" | i18n }}</a>
<a bitLink [routerLink]="[]" (click)="toggleValidateEmail(false)">{{ "notYou" | i18n }}</a>
</div>
</div>
</form>

View File

@@ -115,11 +115,11 @@
<bit-label for="register-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}<br />
<a href="https://bitwarden.com/terms/" target="_blank" rel="noreferrer">{{
<a bitLink href="https://bitwarden.com/terms/" target="_blank" rel="noreferrer">{{
"termsOfService" | i18n
}}</a
>,
<a href="https://bitwarden.com/privacy/" target="_blank" rel="noreferrer">{{
<a bitLink href="https://bitwarden.com/privacy/" target="_blank" rel="noreferrer">{{
"privacyPolicy" | i18n
}}</a>
</bit-label>
@@ -151,7 +151,7 @@
</div>
<p class="tw-m-0 tw-mt-5 tw-text-sm">
{{ "alreadyHaveAccount" | i18n }}
<a routerLink="/login">{{ "logIn" | i18n }}</a>
<a bitLink routerLink="/login">{{ "logIn" | i18n }}</a>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</div>

View File

@@ -104,7 +104,7 @@
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'impactOfRotatingYourEncryptionKey' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>

View File

@@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -26,6 +27,7 @@ export class EmergencyAccessAttachmentsComponent extends BaseAttachmentsComponen
cipherService: CipherService,
i18nService: I18nService,
cryptoService: CryptoService,
encryptService: EncryptService,
stateService: StateService,
platformUtilsService: PlatformUtilsService,
apiService: ApiService,
@@ -40,6 +42,7 @@ export class EmergencyAccessAttachmentsComponent extends BaseAttachmentsComponen
cipherService,
i18nService,
cryptoService,
encryptService,
platformUtilsService,
apiService,
window,

View File

@@ -21,7 +21,7 @@
rel="noreferrer"
bitLink
linkType="primary"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutUserAccess' | i18n }}"
href="https://bitwarden.com/help/emergency-access/#user-access"
slot="end"
>

View File

@@ -9,7 +9,7 @@
target="_blank"
rel="noreferrer"
>
{{ "learnMore" | i18n }}.
{{ "learnMoreAboutEmergencyAccess" | i18n }}
</a>
</p>
<bit-callout *ngIf="isOrganizationOwner" type="warning" title="{{ 'warning' | i18n }}">{{

View File

@@ -21,7 +21,7 @@
href="https://bitwarden.com/help/kdf-algorithms"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
@@ -57,7 +57,7 @@
href="https://bitwarden.com/help/what-encryption-is-used/#changing-kdf-iterations"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutKDFIterations' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>

View File

@@ -11,7 +11,6 @@ import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/mod
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -127,13 +126,6 @@ export class TwoFactorAuthenticatorComponent
}
protected override async disableMethod() {
const twoFactorAuthenticatorTokenFeatureFlag = await this.configService.getFeatureFlag(
FeatureFlag.AuthenticatorTwoFactorToken,
);
if (twoFactorAuthenticatorTokenFeatureFlag === false) {
return super.disableMethod();
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "disable" },
content: { key: "twoStepDisableDesc" },

View File

@@ -11,7 +11,7 @@
<p>
<ng-container *ngIf="isEnterpriseOrg; else teamsDescription">
{{ "twoStepLoginEnterpriseDescStart" | i18n }}
<a routerLink="../policies">{{ "twoStepLoginPolicy" | i18n }}.</a>
<a bitLink routerLink="../policies">{{ "twoStepLoginPolicy" | i18n }}.</a>
<br />
{{ "twoStepLoginOrganizationDuoDesc" | i18n }}
<br />

View File

@@ -42,7 +42,7 @@
aria-hidden="true"
></i>
-
<a href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
<a bitLink href="#" appStopClick (click)="remove(k)">{{ "remove" | i18n }}</a>
</ng-container>
</li>
</ul>

View File

@@ -110,7 +110,7 @@ export class SsoComponent extends BaseSsoComponent implements OnInit {
const response: OrganizationDomainSsoDetailsResponse =
await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email);
if (response?.ssoAvailable) {
if (response?.ssoAvailable && response?.verifiedDate) {
this.identifierFormControl.setValue(response.organizationIdentifier);
await this.submit();
return;

View File

@@ -1,6 +1,6 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/billing/enums";
import { freeTrialTextResolver } from "./free-trial-text.resolver";
@@ -11,48 +11,25 @@ const route = {
const routerStateSnapshot = {} as RouterStateSnapshot;
describe("freeTrialTextResolver", () => {
[
{
param: ProductType.PasswordManager,
keyBase: "startYour7DayFreeTrialOfBitwardenPasswordManager",
},
{
param: ProductType.SecretsManager,
keyBase: "startYour7DayFreeTrialOfBitwardenSecretsManager",
},
{
param: `${ProductType.PasswordManager},${ProductType.SecretsManager}`,
keyBase: "startYour7DayFreeTrialOfBitwarden",
},
].forEach(({ param, keyBase }) => {
describe(`when product is ${param}`, () => {
beforeEach(() => {
route.queryParams.product = `${param}`;
});
it("shows password manager text", () => {
route.queryParams.product = `${ProductType.PasswordManager}`;
it("returns teams trial text", () => {
route.queryParams.productTier = ProductTierType.Teams;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(
"continueSettingUpFreeTrialPasswordManager",
);
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForTeams`);
});
it("shows secret manager text", () => {
route.queryParams.product = `${ProductType.SecretsManager}`;
it("returns enterprise trial text", () => {
route.queryParams.productTier = ProductTierType.Enterprise;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(
"continueSettingUpFreeTrialSecretsManager",
);
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForEnterprise`);
});
it("shows default text", () => {
route.queryParams.product = `${ProductType.PasswordManager},${ProductType.SecretsManager}`;
it("returns families trial text", () => {
route.queryParams.productTier = ProductTierType.Families;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForFamilies`);
});
it("returns default trial text", () => {
route.queryParams.productTier = "";
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(keyBase);
});
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe("continueSettingUpFreeTrial");
});
});

View File

@@ -1,43 +1,22 @@
import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router";
import { ProductType, ProductTierType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/billing/enums";
export const freeTrialTextResolver: ResolveFn<string | null> = (
route: ActivatedRouteSnapshot,
): string | null => {
const { product, productTier } = route.queryParams;
const { product } = route.queryParams;
const products: ProductType[] = (product ?? "").split(",").map((p: string) => parseInt(p));
const onlyPasswordManager = products.length === 1 && products[0] === ProductType.PasswordManager;
const onlySecretsManager = products.length === 1 && products[0] === ProductType.SecretsManager;
const forTeams = parseInt(productTier) === ProductTierType.Teams;
const forEnterprise = parseInt(productTier) === ProductTierType.Enterprise;
const forFamilies = parseInt(productTier) === ProductTierType.Families;
switch (true) {
case onlyPasswordManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForTeams";
case onlyPasswordManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForEnterprise";
case onlyPasswordManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForFamilies";
case onlyPasswordManager:
return "startYour7DayFreeTrialOfBitwardenPasswordManager";
case onlySecretsManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForTeams";
case onlySecretsManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForEnterprise";
case onlySecretsManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForFamilies";
return "continueSettingUpFreeTrialPasswordManager";
case onlySecretsManager:
return "startYour7DayFreeTrialOfBitwardenSecretsManager";
case forTeams:
return "startYour7DayFreeTrialOfBitwardenForTeams";
case forEnterprise:
return "startYour7DayFreeTrialOfBitwardenForEnterprise";
case forFamilies:
return "startYour7DayFreeTrialOfBitwardenForFamilies";
return "continueSettingUpFreeTrialSecretsManager";
default:
return "startYour7DayFreeTrialOfBitwarden";
return "continueSettingUpFreeTrial";
}
};

View File

@@ -21,8 +21,8 @@
>{{
"upgradeDiscount"
| i18n
: (selectedInterval === planIntervals.Annually
? discountPercentageFromSub + this.discountPercentage
: (selectedInterval === planIntervals.Annually && discountPercentageFromSub == 0
? this.discountPercentage
: this.discountPercentageFromSub)
}}</span
>
@@ -53,10 +53,19 @@
[class]="'tw-grid-cols-' + selectableProducts.length"
>
<div
*ngFor="let selectableProduct of selectableProducts; let i = index"
*ngFor="
let selectableProduct of selectableProducts;
trackBy: manageSelectableProduct;
let i = index
"
[ngClass]="getPlanCardContainerClasses(selectableProduct, i)"
(click)="selectPlan(selectableProduct)"
tabindex="0"
[attr.tabindex]="focusedIndex !== i || isCardDisabled(i) ? '-1' : '0'"
class="product-card"
(keyup)="onKeydown($event, i)"
(focus)="onFocus(i)"
[attr.aria-disabled]="isCardDisabled(i)"
[id]="i + 'a_plan_card'"
>
<div class="tw-relative">
<div
@@ -77,9 +86,9 @@
}"
>
<h3
class="tw-text-[1.5rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize">{{
<span class="tw-capitalize tw-whitespace-nowrap">{{
selectableProduct.nameLocalizationKey | i18n
}}</span>
<span
@@ -309,7 +318,7 @@
type="info"
title="SECRETS MANAGER SUBSCRIPTION"
>
{{ "secretsManagerSubInfo" | i18n }}
{{ "secretsManagerSubscriptionInfo" | i18n }}
</bit-callout>
<bit-callout
*ngIf="organization.useSecretsManager && isSecretsManagerTrial()"
@@ -323,12 +332,16 @@
<!-- Payment info -->
<ng-container *ngIf="formGroup.value.productTier !== productTypes.Free">
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<p *ngIf="!showPayment && billing.paymentSource">
<p *ngIf="!showPayment && (paymentSource || billing?.paymentSource)">
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
{{ billing.paymentSource.description }}
<span class="ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">{{
"changePaymentMethod" | i18n
}}</span>
{{
deprecateStripeSourcesAPI
? paymentSource?.description
: billing?.paymentSource?.description
}}
<span class="ml-2 tw-text-primary-600 tw-cursor-pointer" (click)="toggleShowPayment()">
{{ "changePaymentMethod" | i18n }}
</span>
<a></a>
</p>
<app-payment
@@ -347,7 +360,8 @@
<div id="price" class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
>{{ "total" | i18n }}: {{ total | currency: "USD" : "$" }} USD</span
>{{ "total" | i18n }}:
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD</span
>
<span class="tw-text-xs tw-font-light"> / {{ selectedPlanInterval | i18n }}</span>
<button
@@ -433,13 +447,11 @@
bitTypography="body2"
*ngIf="organization.useSecretsManager && !isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<ng-container
*ngIf="selectedInterval == planIntervals.Annually && discountPercentageFromSub > 0"
>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
@@ -505,7 +517,7 @@
{{ "serviceAccounts" | i18n | lowercase }}
&times;
{{ selectedPlan?.SecretsManager?.additionalPricePerServiceAccount | currency: "$" }}
/{{ "month" | i18n }}
/{{ "year" | i18n }}
</span>
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
@@ -515,13 +527,11 @@
bitTypography="body2"
*ngIf="organization.useSecretsManager && !isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<ng-container
*ngIf="selectedInterval == planIntervals.Annually && discountPercentageFromSub > 0"
>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
@@ -751,28 +761,6 @@
</span>
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!--Discount SM Annual-->
<p
class="tw-mb-0 tw-flex tw-justify-between"
bitTypography="body2"
*ngIf="organization.useSecretsManager && isSecretsManagerTrial()"
>
<ng-container *ngIf="selectedInterval == planIntervals.Annually">
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(
additionalServiceAccountTotal(selectedPlan) +
secretsManagerSeatTotal(selectedPlan, sub.smSeats)
) | currency: "$"
}}</span>
</ng-container>
</p>
<!-- password manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
@@ -937,37 +925,19 @@
class="row"
>
<bit-hint class="col-6">
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<ng-container
*ngIf="
selectedInterval == planIntervals.Annually;
else MonthlyOrAnnuallyWithDiscount
"
>
<p
class="tw-mb-0 tw-flex tw-justify-between"
bitTypography="body2"
*ngIf="discountPercentageFromSub > 0"
>
<ng-container>
<span class="tw-text-xs">
{{
"providerDiscount"
| i18n: this.discountPercentageFromSub + this.discountPercentage
| lowercase
}}
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span class="tw-line-through tw-text-xs">{{
calculateTotalAppliedDiscount(total) | currency: "$"
}}</span>
</ng-container>
<ng-template #MonthlyOrAnnuallyWithDiscount>
<span
class="tw-text-xs"
[style.display]="discountPercentageFromSub > 0 ? 'block' : 'none'"
>
{{ "providerDiscount" | i18n: this.discountPercentageFromSub | lowercase }}
</span>
<span
[style.display]="discountPercentageFromSub > 0 ? 'block' : 'none'"
class="tw-line-through tw-text-xs"
>{{ calculateTotalAppliedDiscount(total) | currency: "$" }}</span
>
</ng-template>
</p>
</bit-hint>
</div>
@@ -980,7 +950,7 @@
{{ "total" | i18n }}
</span>
<span>
{{ total | currency: "USD" : "$" }}
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold">
/ {{ selectedPlanInterval | i18n }}</span
>

View File

@@ -32,6 +32,7 @@ import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -160,6 +161,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
showPayment: boolean = false;
totalOpened: boolean = false;
currentPlan: PlanResponse;
currentFocusIndex = 0;
isCardStateDisabled = false;
focusedIndex: number | null = null;
accountCredit: number;
paymentSource?: PaymentSourceResponse;
deprecateStripeSourcesAPI: boolean;
@@ -197,7 +203,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.currentPlan = this.sub?.plan;
this.selectedPlan = this.sub?.plan;
this.organization = await this.organizationService.get(this.organizationId);
this.billing = await this.organizationApiService.getBilling(this.organizationId);
if (this.deprecateStripeSourcesAPI) {
const { accountCredit, paymentSource } =
await this.billingApiService.getOrganizationPaymentMethod(this.organizationId);
this.accountCredit = accountCredit;
this.paymentSource = paymentSource;
} else {
this.billing = await this.organizationApiService.getBilling(this.organizationId);
}
}
if (!this.selfHosted) {
@@ -255,6 +268,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
setInitialPlanSelection() {
this.focusedIndex = this.selectableProducts.length - 1;
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
}
@@ -307,15 +321,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
if (plan == this.currentPlan) {
cardState = PlanCardState.Disabled;
this.isCardStateDisabled = true;
this.focusedIndex = index;
} else if (plan == this.selectedPlan) {
cardState = PlanCardState.Selected;
this.isCardStateDisabled = false;
this.focusedIndex = index;
} else if (
this.selectedInterval === PlanInterval.Monthly &&
plan.productTier == ProductTierType.Families
) {
cardState = PlanCardState.Disabled;
this.isCardStateDisabled = true;
this.focusedIndex = this.selectableProducts.length - 1;
} else {
cardState = PlanCardState.NotSelected;
this.isCardStateDisabled = false;
}
switch (cardState) {
@@ -382,11 +403,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
get upgradeRequiresPaymentMethod() {
return (
this.organization?.productTierType === ProductTierType.Free &&
!this.showFree &&
!this.billing?.paymentSource
);
const isFreeTier = this.organization?.productTierType === ProductTierType.Free;
const shouldHideFree = !this.showFree;
const hasNoPaymentSource = this.deprecateStripeSourcesAPI
? !this.paymentSource
: !this.billing?.paymentSource;
return isFreeTier && shouldHideFree && hasNoPaymentSource;
}
get selectedSecretsManagerPlan() {
@@ -466,7 +489,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
get storageGb() {
return this.sub?.maxStorageGb - 1;
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
}
passwordManagerSeatTotal(plan: PlanResponse): number {
@@ -492,15 +515,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
return (
plan.PasswordManager.additionalStoragePricePerGb * Math.abs(this.sub?.maxStorageGb - 1 || 0)
plan.PasswordManager.additionalStoragePricePerGb *
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
);
}
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
if (!selectedPlan.isAnnual) {
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
}
return selectedPlan.PasswordManager.additionalStoragePricePerGb / 12;
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
}
additionalServiceAccountTotal(plan: PlanResponse): number {
@@ -660,7 +681,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
if (!this.acceptingSponsorship && !this.isInTrialFlow) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/organizations/" + orgId + "/members"]);
this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]);
}
if (this.isInTrialFlow) {
@@ -822,30 +843,43 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
calculateTotalAppliedDiscount(total: number) {
const discountPercent =
this.selectedInterval == PlanInterval.Annually
? this.discountPercentage + this.discountPercentageFromSub
: this.discountPercentageFromSub;
const discountedTotal = total / (1 - discountPercent / 100);
const discountedTotal = total * (this.discountPercentageFromSub / 100);
return discountedTotal;
}
get paymentSourceClasses() {
if (this.billing.paymentSource == null) {
return [];
}
switch (this.billing.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
if (this.deprecateStripeSourcesAPI) {
if (this.paymentSource == null) {
return [];
}
switch (this.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
} else {
if (this.billing.paymentSource == null) {
return [];
}
switch (this.billing.paymentSource.type) {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:
return [];
}
}
}
@@ -859,6 +893,48 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return this.i18nService.t("planNameFamilies");
case ProductTierType.Teams:
return this.i18nService.t("planNameTeams");
case ProductTierType.TeamsStarter:
return this.i18nService.t("planNameTeamsStarter");
}
}
onKeydown(event: KeyboardEvent, index: number) {
const cardElements = Array.from(document.querySelectorAll(".product-card")) as HTMLElement[];
let newIndex = index;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
do {
newIndex = (newIndex + direction + cardElements.length) % cardElements.length;
} while (this.isCardDisabled(newIndex) && newIndex !== index);
event.preventDefault();
setTimeout(() => {
const card = cardElements[newIndex];
if (
!(
card.classList.contains("tw-bg-secondary-100") &&
card.classList.contains("tw-text-muted")
)
) {
card?.focus();
}
}, 0);
}
}
onFocus(index: number) {
this.focusedIndex = index;
this.selectPlan(this.selectableProducts[index]);
}
isCardDisabled(index: number): boolean {
const card = this.selectableProducts[index];
return card === (this.currentPlan || this.isCardStateDisabled);
}
manageSelectableProduct(index: number) {
return index;
}
}

View File

@@ -11,7 +11,7 @@
<form [formGroup]="selfHostedForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>

View File

@@ -23,16 +23,19 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-organization-create.request";
import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -147,19 +150,20 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private router: Router,
private syncService: SyncService,
private policyService: PolicyService,
private organizationService: OrganizationService,
private logService: LogService,
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private organizationApiService: OrganizationApiServiceAbstraction,
private providerApiService: ProviderApiServiceAbstraction,
private toastService: ToastService,
private configService: ConfigService,
private billingApiService: BillingApiServiceAbstraction,
) {
this.selfHosted = platformUtilsService.isSelfHost();
this.selfHosted = this.platformUtilsService.isSelfHost();
}
async ngOnInit() {
@@ -590,7 +594,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.createOrganization) {
const orgKey = await this.cryptoService.makeOrgKey<OrgKey>();
const key = orgKey[0].encryptedString;
const collection = await this.cryptoService.encrypt(
const collection = await this.encryptService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey[1],
);
@@ -658,21 +662,26 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod) {
let type: PaymentMethodType;
let token: string;
if (this.deprecateStripeSourcesAPI) {
({ type, token } = await this.paymentV2Component.tokenize());
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = await this.paymentV2Component.tokenize();
const expandedTaxInfoUpdateRequest = new ExpandedTaxInfoUpdateRequest();
expandedTaxInfoUpdateRequest.country = this.taxComponent.country;
expandedTaxInfoUpdateRequest.postalCode = this.taxComponent.postalCode;
updatePaymentMethodRequest.taxInformation = expandedTaxInfoUpdateRequest;
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
} else {
[token, type] = await this.paymentComponent.createPaymentToken();
const [paymentToken, paymentMethodType] = await this.paymentComponent.createPaymentToken();
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = paymentToken;
paymentRequest.paymentMethodType = paymentMethodType;
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
}
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = token;
paymentRequest.paymentMethodType = type;
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
}
// Backfill pub/priv key if necessary
@@ -744,7 +753,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
);
const providerKey = await this.cryptoService.getProviderKey(this.providerId);
providerRequest.organizationCreateRequest.key = (
await this.cryptoService.encrypt(orgKey.key, providerKey)
await this.encryptService.encrypt(orgKey.key, providerKey)
).encryptedString;
const orgId = (
await this.apiService.postProviderCreateOrganization(this.providerId, providerRequest)

View File

@@ -422,9 +422,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Submitted) {
await this.load();
if (result === ChangePlanDialogResultType.Closed) {
return;
}
await this.load();
} else {
this.showChangePlan = !this.showChangePlan;
}

View File

@@ -9,7 +9,7 @@
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [bitAction]="submit">
{{ "submit" }}
{{ "submit" | i18n }}
</button>
<button
type="button"

View File

@@ -15,7 +15,13 @@
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
></a>
<a href="{{ i.url }}" target="_blank" rel="noreferrer" title="{{ 'viewInvoice' | i18n }}">
<a
bitLink
href="{{ i.url }}"
target="_blank"
rel="noreferrer"
title="{{ 'viewInvoice' | i18n }}"
>
{{ "invoiceNumber" | i18n: i.number }}</a
>
</td>

View File

@@ -26,7 +26,7 @@
</div>
<ng-container *ngIf="showMethods && method === paymentMethodType.Card">
<div class="tw-grid tw-grid-cols-12 tw-gap-4 tw-mb-4">
<div [ngClass]="trialFlow ? 'tw-col-span-12' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-12' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-number-element">{{
"number" | i18n
}}</app-payment-label-v2>
@@ -40,13 +40,13 @@
height="32"
/>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-expiry-element">{{
"expiration" | i18n
}}</app-payment-label-v2>
<div id="stripe-card-expiry-element" class="form-control stripe-form-control"></div>
</div>
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-4'">
<div [ngClass]="trialFlow ? 'tw-col-span-6' : 'tw-col-span-6'">
<app-payment-label-v2 for="stripe-card-cvc-element">
{{ "securityCodeSlashCVV" | i18n }}
<a
@@ -55,7 +55,7 @@
target="_blank"
rel="noreferrer"
class="hover:tw-no-underline"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'whatIsACvvNumber' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>

View File

@@ -35,6 +35,7 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -182,10 +183,10 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: ThemeStateService,
useFactory: (globalStateProvider: GlobalStateProvider) =>
useFactory: (globalStateProvider: GlobalStateProvider, configService: ConfigService) =>
// Web chooses to have Light as the default theme
new DefaultThemeStateService(globalStateProvider, ThemeType.Light),
deps: [GlobalStateProvider],
new DefaultThemeStateService(globalStateProvider, configService, ThemeType.Light),
deps: [GlobalStateProvider, ConfigService],
}),
safeProvider({
provide: CLIENT_TYPE,

View File

@@ -56,7 +56,7 @@
href="https://bitwarden.com/help/localization/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutLocalization' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
@@ -76,7 +76,7 @@
href="https://bitwarden.com/help/website-icons/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutWebsiteIcons' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>

View File

@@ -6,7 +6,7 @@
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutYourAccountFingerprintPhrase' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i
></a>

View File

@@ -27,12 +27,14 @@
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<ng-container header *ngIf="!isAdminConsoleActive">
<ng-container header>
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
{{ "owner" | i18n }}
</th>
<th bitCell class="tw-text-right" bitSortable="exposedXTimes"></th>
</tr>
</ng-container>
<ng-template body let-rows$>
@@ -43,10 +45,11 @@
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItem' | i18n }}"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
@@ -74,7 +77,7 @@
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<td bitCell *ngIf="!isAdminConsoleActive">
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
@@ -86,7 +89,7 @@
</td>
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "exposedXTimes" | i18n: (exposedPasswordMap.get(r.id) | number) }}
{{ "exposedXTimes" | i18n: (r.exposedXTimes | number) }}
</span>
</td>
</tr>

View File

@@ -11,12 +11,14 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CipherReportComponent } from "./cipher-report.component";
type ReportResult = CipherView & { exposedXTimes: number };
@Component({
selector: "app-exposed-passwords-report",
templateUrl: "exposed-passwords-report.component.html",
})
export class ExposedPasswordsReportComponent extends CipherReportComponent implements OnInit {
exposedPasswordMap = new Map<string, number>();
disabled = true;
constructor(
@@ -44,12 +46,12 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
async setCiphers() {
const allCiphers = await this.getAllCiphers();
const exposedPasswordCiphers: CipherView[] = [];
const exposedPasswordCiphers: ReportResult[] = [];
const promises: Promise<void>[] = [];
this.filterStatus = [0];
allCiphers.forEach((ciph: any) => {
const { type, login, isDeleted, edit, viewPassword, id } = ciph;
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
@@ -63,8 +65,8 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => {
if (exposedCount > 0) {
exposedPasswordCiphers.push(ciph);
this.exposedPasswordMap.set(id, exposedCount);
const row = { ...ciph, exposedXTimes: exposedCount } as ReportResult;
exposedPasswordCiphers.push(row);
}
});
promises.push(promise);
@@ -72,6 +74,7 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
await Promise.all(promises);
this.filterCiphersByOrg(exposedPasswordCiphers);
this.dataSource.sort = { column: "exposedXTimes", direction: "desc" };
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -48,10 +48,11 @@
</td>
<td bitCell>
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItem' | i18n }}"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
<ng-container *ngIf="!organization && r.organizationId">

View File

@@ -50,10 +50,11 @@
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItem' | i18n }}"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>

View File

@@ -47,9 +47,15 @@
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<a href="#" appStopClick (click)="selectCipher(r)" title="{{ 'editItem' | i18n }}">{{
r.name
}}</a>
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>
{{ r.name }}
</a>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection"

View File

@@ -48,10 +48,11 @@
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItem' | i18n }}"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>

View File

@@ -1,6 +1,6 @@
import { Component, Input } from "@angular/core";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -26,7 +26,7 @@ export class SendAccessFileComponent {
constructor(
private i18nService: I18nService,
private toastService: ToastService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private fileDownloadService: FileDownloadService,
private sendApiService: SendApiService,
) {}
@@ -62,7 +62,7 @@ export class SendAccessFileComponent {
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, this.decKey);
const decBuf = await this.encryptService.decryptToBytes(encBuf, this.decKey);
this.fileDownloadService.download({
fileName: this.send.file.fileName,
blobData: decBuf,

View File

@@ -21,6 +21,7 @@
[routerLink]="[]"
[queryParams]="{ itemId: cipher.id, action: extensionRefreshEnabled ? 'view' : null }"
queryParamsHandling="merge"
[replaceUrl]="extensionRefreshEnabled"
title="{{ 'editItemWithName' | i18n: cipher.name }}"
type="button"
appStopProp
@@ -157,7 +158,7 @@
</button>
<button
bitMenuItem
*ngIf="canManageCollection || !vaultBulkManagementActionEnabled"
*ngIf="canEditCipher || !vaultBulkManagementActionEnabled"
(click)="deleteCipher()"
type="button"
>

View File

@@ -36,7 +36,6 @@ export class VaultCipherRowComponent implements OnInit {
@Input() viewingOrgVault: boolean;
@Input() canEditCipher: boolean;
@Input() vaultBulkManagementActionEnabled: boolean;
@Input() canManageCollection: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();

View File

@@ -132,9 +132,6 @@
[collections]="allCollections"
[checked]="selection.isSelected(item)"
[canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled"
[canManageCollection]="
canManageCollection(item.cipher) && vaultBulkManagementActionEnabled
"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"

View File

@@ -47,7 +47,6 @@ export class VaultItemsComponent {
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
@Input() vaultBulkManagementActionEnabled = false;
@Input() activeCollection: CollectionView | undefined;
private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] {
@@ -215,33 +214,6 @@ export class VaultItemsComponent {
return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit;
}
protected canManageCollection(cipher: CipherView) {
if (cipher.organizationId == null) {
return true;
}
// Check for admin access in AC vault
if (this.showAdminActions) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
if (organization?.permissions.editAnyCollection) {
return true;
}
if (organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin) {
return true;
}
}
if (this.activeCollection) {
return this.activeCollection.manage;
}
return this.allCollections
.filter((c) => cipher.collectionIds.includes(c.id))
.some((collection) => collection.manage);
}
private refreshItems() {
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
@@ -317,16 +289,19 @@ export class VaultItemsComponent {
const hasPersonalItems = this.hasPersonalItems();
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
const organizations = Array.from(uniqueCipherOrgIds, (orgId) =>
this.allOrganizations.find((o) => o.id === orgId),
);
const canManageCollectionCiphers = this.selection.selected
.filter((item) => item.cipher)
.every(({ cipher }) => this.canManageCollection(cipher));
const canEditOrManageAllCiphers =
organizations.length > 0 && organizations.every((org) => org?.canEditAllCiphers);
const canDeleteCollections = this.selection.selected
.filter((item) => item.collection)
.every((item) => item.collection && this.canDeleteCollection(item.collection));
const userCanDeleteAccess = canManageCollectionCiphers && canDeleteCollections;
const userCanDeleteAccess =
(canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && canDeleteCollections;
if (
userCanDeleteAccess ||

View File

@@ -3,6 +3,7 @@ import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
@@ -23,6 +24,7 @@ export class CollectionAdminService {
constructor(
private apiService: ApiService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private collectionService: CollectionService,
) {}
@@ -116,7 +118,7 @@ export class CollectionAdminService {
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.cryptoService.decryptToUtf8(new EncString(c.name), orgKey);
view.name = await this.encryptService.decryptToUtf8(new EncString(c.name), orgKey);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
@@ -146,7 +148,7 @@ export class CollectionAdminService {
}
const collection = new CollectionRequest();
collection.externalId = model.externalId;
collection.name = (await this.cryptoService.encrypt(model.name, key)).encryptedString;
collection.name = (await this.encryptService.encrypt(model.name, key)).encryptedString;
collection.groups = model.groups.map(
(group) =>
new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords, group.manage),

View File

@@ -151,6 +151,7 @@
</div>
<!-- Add new custom field -->
<a
bitLink
href="#"
appStopClick
(click)="addField()"

View File

@@ -0,0 +1,34 @@
<bit-dialog dialogSize="large">
<span bitDialogTitle>
{{ headerText }}
</span>
<ng-container bitDialogContent>
<vault-cipher-form
*ngIf="!loading"
formId="cipherForm"
[config]="config"
[submitBtn]="submitBtn"
(cipherSaved)="onCipherSaved($event)"
>
<bit-item slot="attachment-button">
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
<p class="tw-m-0">
{{ "attachments" | i18n }}
<span *ngIf="!canAccessAttachments" bitBadge variant="success" class="tw-ml-2">
{{ "premium" | i18n }}
</span>
</p>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,124 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { CipherFormConfig, DefaultCipherFormConfigService } from "@bitwarden/vault";
import { AddEditComponentV2 } from "./add-edit-v2.component";
describe("AddEditComponentV2", () => {
let component: AddEditComponentV2;
let fixture: ComponentFixture<AddEditComponentV2>;
let organizationService: MockProxy<OrganizationService>;
let policyService: MockProxy<PolicyService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let activatedRoute: MockProxy<ActivatedRoute>;
let dialogRef: MockProxy<DialogRef<any>>;
let dialogService: MockProxy<DialogService>;
let cipherService: MockProxy<CipherService>;
let messagingService: MockProxy<MessagingService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
const mockParams = {
cloneMode: false,
cipherFormConfig: mock<CipherFormConfig>(),
};
beforeEach(async () => {
const mockOrganization: Organization = {
id: "org-id",
name: "Test Organization",
} as Organization;
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([mockOrganization]);
policyService = mock<PolicyService>();
policyService.policyAppliesToActiveUser$.mockImplementation((policyType: PolicyType) =>
of(true),
);
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true);
activatedRoute = mock<ActivatedRoute>();
activatedRoute.queryParams = of({});
dialogRef = mock<DialogRef<any>>();
dialogService = mock<DialogService>();
messagingService = mock<MessagingService>();
folderService = mock<FolderService>();
folderService.folderViews$ = of([]);
collectionService = mock<CollectionService>();
collectionService.decryptedCollections$ = of([]);
const mockDefaultCipherFormConfigService = {
buildConfig: jest.fn().mockResolvedValue({
allowPersonal: true,
allowOrganization: true,
}),
};
await TestBed.configureTestingModule({
imports: [AddEditComponentV2],
providers: [
{ provide: DIALOG_DATA, useValue: mockParams },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } },
{ provide: DialogService, useValue: dialogService },
{ provide: CipherService, useValue: cipherService },
{ provide: MessagingService, useValue: messagingService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: Router, useValue: mock<Router>() },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: CollectionService, useValue: collectionService },
{ provide: FolderService, useValue: folderService },
{ provide: CryptoService, useValue: mock<CryptoService>() },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
{ provide: PolicyService, useValue: policyService },
{ provide: DefaultCipherFormConfigService, useValue: mockDefaultCipherFormConfigService },
{
provide: PasswordGenerationServiceAbstraction,
useValue: mock<PasswordGenerationServiceAbstraction>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(AddEditComponentV2);
component = fixture.componentInstance;
});
describe("ngOnInit", () => {
it("initializes the component with cipher", async () => {
await component.ngOnInit();
expect(component).toBeTruthy();
});
});
describe("cancel", () => {
it("handles cancel action", async () => {
const spyClose = jest.spyOn(dialogRef, "close");
await component.cancel();
expect(spyClose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,177 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, DialogModule, DialogService, ItemModule } from "@bitwarden/components";
import {
CipherAttachmentsComponent,
CipherFormConfig,
CipherFormGenerationService,
CipherFormMode,
CipherFormModule,
} from "@bitwarden/vault";
import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service";
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { AttachmentsV2Component } from "./attachments-v2.component";
/**
* The result of the AddEditCipherDialogV2 component.
*/
export enum AddEditCipherDialogResult {
Edited = "edited",
Added = "added",
Canceled = "canceled",
}
/**
* The close result of the AddEditCipherDialogV2 component.
*/
export interface AddEditCipherDialogCloseResult {
/**
* The action that was taken.
*/
action: AddEditCipherDialogResult;
/**
* The ID of the cipher that was edited or added.
*/
id?: CipherId;
}
/**
* Component for viewing a cipher, presented in a dialog.
*/
@Component({
selector: "app-vault-add-edit-v2",
templateUrl: "add-edit-v2.component.html",
standalone: true,
imports: [
CipherViewComponent,
CommonModule,
AsyncActionsModule,
DialogModule,
SharedModule,
CipherFormModule,
CipherAttachmentsComponent,
ItemModule,
],
providers: [{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }],
})
export class AddEditComponentV2 implements OnInit {
config: CipherFormConfig;
headerText: string;
canAccessAttachments: boolean = false;
/**
* Constructor for the AddEditComponentV2 component.
* @param params The parameters for the component.
* @param dialogRef The reference to the dialog.
* @param i18nService The internationalization service.
* @param dialogService The dialog service.
* @param billingAccountProfileStateService The billing account profile state service.
*/
constructor(
@Inject(DIALOG_DATA) public params: CipherFormConfig,
private dialogRef: DialogRef<AddEditCipherDialogCloseResult>,
private i18nService: I18nService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.billingAccountProfileStateService.hasPremiumFromAnySource$
.pipe(takeUntilDestroyed())
.subscribe((canAccessPremium) => {
this.canAccessAttachments = canAccessPremium;
});
}
/**
* Lifecycle hook for component initialization.
*/
async ngOnInit() {
this.config = this.params;
this.headerText = this.setHeader(this.config?.mode, this.config.cipherType);
}
/**
* Getter to check if the component is loading.
*/
get loading() {
return this.config == null;
}
/**
* Method to handle cancel action. Called when a user clicks the cancel button.
*/
async cancel() {
this.dialogRef.close({ action: AddEditCipherDialogResult.Canceled });
}
/**
* Sets the header text based on the mode and type of the cipher.
* @param mode The form mode.
* @param type The cipher type.
* @returns The header text.
*/
setHeader(mode: CipherFormMode, type: CipherType) {
const partOne = mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader";
switch (type) {
case CipherType.Login:
return this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
case CipherType.Card:
return this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
case CipherType.Identity:
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
}
}
/**
* Opens the attachments dialog.
*/
async openAttachmentsDialog() {
this.dialogService.open<AttachmentsV2Component, { cipherId: CipherId }>(
AttachmentsV2Component,
{
data: {
cipherId: this.config.originalCipher?.id as CipherId,
},
},
);
}
/**
* Handles the event when a cipher is saved.
* @param cipherView The cipher view that was saved.
*/
async onCipherSaved(cipherView: CipherView) {
this.dialogRef.close({
action:
this.config.mode === "add"
? AddEditCipherDialogResult.Added
: AddEditCipherDialogResult.Edited,
id: cipherView.id as CipherId,
});
}
}
/**
* Strongly typed helper to open a cipher add/edit dialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
* @returns A reference to the opened dialog
*/
export function openAddEditCipherDialog(
dialogService: DialogService,
config: DialogConfig<CipherFormConfig>,
): DialogRef<AddEditCipherDialogCloseResult> {
return dialogService.open(AddEditComponentV2, config);
}

View File

@@ -385,7 +385,7 @@
href="https://bitwarden.com/help/uri-match-detection/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutMatchDetection' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
@@ -418,6 +418,7 @@
</ng-container>
<a
href="#"
bitLink
appStopClick
(click)="addUri()"
class="d-inline-block mb-3"
@@ -923,7 +924,7 @@
</div>
<div *ngIf="hasPasswordHistory">
<b class="font-weight-semibold">{{ "passwordHistory" | i18n }}:</b>
<a href="#" appStopClick (click)="viewHistory()" title="{{ 'view' | i18n }}">
<a href="#" bitLink appStopClick (click)="viewHistory()" title="{{ 'view' | i18n }}">
{{ cipher.passwordHistory.length }}
</a>
</div>
@@ -953,7 +954,7 @@
<a
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutMasterPasswordReprompt' | i18n }}"
href="https://bitwarden.com/help/managing-items/#protect-individual-items"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>

View File

@@ -8,6 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { isCardExpired } from "@bitwarden/common/autofill/utils";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { EventType } from "@bitwarden/common/enums";
@@ -24,7 +25,6 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
import { isCardExpired } from "@bitwarden/common/vault/utils";
import { DialogService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { PasswordRepromptService } from "@bitwarden/vault";

View File

@@ -0,0 +1,19 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle>
{{ "attachments" | i18n }}
</span>
<ng-container bitDialogContent>
<app-cipher-attachments
*ngIf="cipherId"
[cipherId]="cipherId"
[submitBtn]="submitBtn"
(onUploadSuccess)="uploadSuccessful()"
(onRemoveSuccess)="removalSuccessful()"
></app-cipher-attachments>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn>
{{ "upload" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,65 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
AttachmentsV2Component,
AttachmentDialogResult,
AttachmentsDialogParams,
} from "./attachments-v2.component";
describe("AttachmentsV2Component", () => {
let component: AttachmentsV2Component;
let fixture: ComponentFixture<AttachmentsV2Component>;
const mockCipherId: CipherId = "cipher-id" as CipherId;
const mockParams: AttachmentsDialogParams = {
cipherId: mockCipherId,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AttachmentsV2Component, NoopAnimationsModule],
providers: [
{ provide: DIALOG_DATA, useValue: mockParams },
{ provide: DialogRef, useValue: mock<DialogRef>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: AccountService, useValue: mock<AccountService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(AttachmentsV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("initializes without errors and with the correct cipherId", () => {
expect(component).toBeTruthy();
expect(component.cipherId).toBe(mockParams.cipherId);
});
it("closes the dialog with 'uploaded' result on uploadSuccessful", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.uploadSuccessful();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Uploaded });
});
it("closes the dialog with 'removed' result on removalSuccessful", () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
component.removalSuccessful();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed });
});
});

View File

@@ -0,0 +1,87 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { CipherId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { CipherAttachmentsComponent } from "@bitwarden/vault";
import { SharedModule } from "../../shared";
export interface AttachmentsDialogParams {
cipherId: CipherId;
}
/**
* Enum representing the possible results of the attachment dialog.
*/
export enum AttachmentDialogResult {
Uploaded = "uploaded",
Removed = "removed",
Closed = "closed",
}
export interface AttachmentDialogCloseResult {
action: AttachmentDialogResult;
}
/**
* Component for the attachments dialog.
*/
@Component({
selector: "app-vault-attachments-v2",
templateUrl: "attachments-v2.component.html",
standalone: true,
imports: [CommonModule, SharedModule, CipherAttachmentsComponent],
})
export class AttachmentsV2Component {
cipherId: CipherId;
attachmentFormId = CipherAttachmentsComponent.attachmentFormID;
/**
* Constructor for AttachmentsV2Component.
* @param dialogRef - Reference to the dialog.
* @param params - Parameters passed to the dialog.
*/
constructor(
private dialogRef: DialogRef<AttachmentDialogCloseResult>,
@Inject(DIALOG_DATA) public params: AttachmentsDialogParams,
) {
this.cipherId = params.cipherId;
}
/**
* Opens the attachments dialog.
* @param dialogService - The dialog service.
* @param params - The parameters for the dialog.
* @returns The dialog reference.
*/
static open(
dialogService: DialogService,
params: AttachmentsDialogParams,
): DialogRef<AttachmentDialogCloseResult> {
return dialogService.open(AttachmentsV2Component, {
data: params,
});
}
/**
* Called when an attachment is successfully uploaded.
* Closes the dialog with an 'uploaded' result.
*/
uploadSuccessful() {
this.dialogRef.close({
action: AttachmentDialogResult.Uploaded,
});
}
/**
* Called when an attachment is successfully removed.
* Closes the dialog with a 'removed' result.
*/
removalSuccessful() {
this.dialogRef.close({
action: AttachmentDialogResult.Removed,
});
}
}

View File

@@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -25,6 +26,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent {
cipherService: CipherService,
i18nService: I18nService,
cryptoService: CryptoService,
encryptService: EncryptService,
stateService: StateService,
platformUtilsService: PlatformUtilsService,
apiService: ApiService,
@@ -39,6 +41,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent {
cipherService,
i18nService,
cryptoService,
encryptService,
platformUtilsService,
apiService,
window,

View File

@@ -1,8 +1,11 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { FolderAddEditComponent as BaseFolderAddEditComponent } from "@bitwarden/angular/vault/components/folder-add-edit.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -19,6 +22,8 @@ export class FolderAddEditComponent extends BaseFolderAddEditComponent {
constructor(
folderService: FolderService,
folderApiService: FolderApiServiceAbstraction,
protected accountSerivce: AccountService,
protected cryptoService: CryptoService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -31,6 +36,8 @@ export class FolderAddEditComponent extends BaseFolderAddEditComponent {
super(
folderService,
folderApiService,
accountSerivce,
cryptoService,
i18nService,
platformUtilsService,
logService,
@@ -73,7 +80,9 @@ export class FolderAddEditComponent extends BaseFolderAddEditComponent {
}
try {
const folder = await this.folderService.encrypt(this.folder);
const activeAccountId = (await firstValueFrom(this.accountSerivce.activeAccount$)).id;
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeAccountId);
const folder = await this.folderService.encrypt(this.folder, userKey);
this.formPromise = this.folderApiService.save(folder);
await this.formPromise;
this.platformUtilsService.showToast(

View File

@@ -10,7 +10,7 @@
href="https://bitwarden.com/help/searching-vault/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
appA11yTitle="{{ 'learnMoreAboutSearchingYourVault' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>

View File

@@ -49,7 +49,7 @@ export class VaultHeaderComponent implements OnInit {
protected All = All;
protected CollectionDialogTabType = CollectionDialogTabType;
protected CipherType = CipherType;
protected extensionRefreshEnabled = false;
protected extensionRefreshEnabled: boolean;
/**
* Boolean to determine the loading state of the header.

View File

@@ -57,7 +57,6 @@
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
(onEvent)="onVaultItemsEvent($event)"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
<div

View File

@@ -47,20 +47,25 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { CollectionAssignmentResult, PasswordRepromptService } from "@bitwarden/vault";
import {
CollectionAssignmentResult,
DefaultCipherFormConfigService,
PasswordRepromptService,
} from "@bitwarden/vault";
import { SharedModule } from "../../shared/shared.module";
import { AssignCollectionsWebComponent } from "../components/assign-collections";
@@ -74,7 +79,17 @@ import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { getNestedCollectionTree } from "../utils/collection-utils";
import {
AddEditCipherDialogCloseResult,
AddEditCipherDialogResult,
openAddEditCipherDialog,
} from "./add-edit-v2.component";
import { AddEditComponent } from "./add-edit.component";
import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
} from "./attachments-v2.component";
import { AttachmentsComponent } from "./attachments.component";
import {
BulkDeleteDialogResult,
@@ -131,7 +146,11 @@ const SearchTextDebounceInterval = 200;
VaultItemsModule,
SharedModule,
],
providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService],
providers: [
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService,
],
})
export class VaultComponent implements OnInit, OnDestroy {
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
@@ -170,6 +189,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private extensionRefreshEnabled: boolean;
constructor(
private syncService: SyncService,
@@ -200,6 +220,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private accountService: AccountService,
private cipherFormConfigService: DefaultCipherFormConfigService,
) {}
async ngOnInit() {
@@ -416,6 +437,11 @@ export class VaultComponent implements OnInit, OnDestroy {
this.refreshing = false;
},
);
// Check if the extension refresh feature flag is enabled
this.extensionRefreshEnabled = await this.configService.getFeatureFlag(
FeatureFlag.ExtensionRefresh,
);
}
ngOnDestroy() {
@@ -511,6 +537,15 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchText$.next(searchText);
}
/**
* Handles opening the attachments dialog for a cipher.
* Runs several checks to ensure that the user has the correct permissions
* and then opens the attachments dialog.
* Uses the new AttachmentsV2Component if the extensionRefresh feature flag is enabled.
*
* @param cipher
* @returns
*/
async editCipherAttachments(cipher: CipherView) {
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
@@ -536,6 +571,24 @@ export class VaultComponent implements OnInit, OnDestroy {
);
let madeAttachmentChanges = false;
if (this.extensionRefreshEnabled) {
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: cipher.id as CipherId,
});
const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed);
if (
result.action === AttachmentDialogResult.Uploaded ||
result.action === AttachmentDialogResult.Removed
) {
this.refresh();
}
return;
}
const [modal] = await this.modalService.openViewRef(
AttachmentsComponent,
this.attachmentsModalRef,
@@ -598,7 +651,11 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async addCipher(cipherType?: CipherType) {
const component = await this.editCipher(null);
if (this.extensionRefreshEnabled) {
return this.addCipherV2(cipherType);
}
const component = (await this.editCipher(null)) as AddEditComponent;
component.type = cipherType || this.activeFilter.cipherType;
if (
this.activeFilter.organizationId !== "MyVault" &&
@@ -622,18 +679,56 @@ export class VaultComponent implements OnInit, OnDestroy {
component.folderId = this.activeFilter.folderId;
}
async navigateToCipher(cipher: CipherView) {
this.go({ itemId: cipher?.id });
/**
* Opens the add cipher dialog.
* @param cipherType The type of cipher to add.
* @returns The dialog reference.
*/
async addCipherV2(cipherType?: CipherType) {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
"add",
null,
cipherType,
);
cipherFormConfig.initialValues = {
organizationId:
this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null
? (this.activeFilter.organizationId as OrganizationId)
: null,
collectionIds:
this.activeFilter.collectionId !== "AllCollections" &&
this.activeFilter.collectionId != null
? [this.activeFilter.collectionId as CollectionId]
: [],
folderId: this.activeFilter.folderId,
};
// Open the dialog.
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
// Wait for the dialog to close.
const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// Refresh the vault to show the new cipher.
if (result?.action === AddEditCipherDialogResult.Added) {
this.refresh();
this.go({ itemId: result.id, action: "view" });
return;
}
// If the dialog was closed by any other action navigate back to the vault.
this.go({ cipherId: null, itemId: null, action: null });
}
async editCipher(cipher: CipherView) {
return this.editCipherId(cipher?.id);
async editCipher(cipher: CipherView, cloneMode?: boolean) {
return this.editCipherId(cipher?.id, cloneMode);
}
async editCipherId(id: string) {
async editCipherId(id: string, cloneMode?: boolean) {
const cipher = await this.cipherService.get(id);
// if cipher exists (cipher is null when new) and MP reprompt
// is on for this cipher, then show password reprompt
if (
cipher &&
cipher.reprompt !== 0 &&
@@ -644,6 +739,11 @@ export class VaultComponent implements OnInit, OnDestroy {
return;
}
if (this.extensionRefreshEnabled) {
await this.editCipherIdV2(cipher, cloneMode);
return;
}
const [modal, childComponent] = await this.modalService.openViewRef(
AddEditComponent,
this.cipherAddEditModalRef,
@@ -673,6 +773,46 @@ export class VaultComponent implements OnInit, OnDestroy {
return childComponent;
}
/**
* Edit a cipher using the new AddEditCipherDialogV2 component.
*
* @param cipher
* @param cloneMode
*/
private async editCipherIdV2(cipher: Cipher, cloneMode?: boolean) {
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
cloneMode ? "clone" : "edit",
cipher.id as CipherId,
cipher.type,
);
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed);
/**
* Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.refresh();
}
/**
* View the cipher if the dialog was closed by editing the cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.go({ itemId: cipher.id, action: "view" });
return;
}
/**
* Navigate to the vault if the dialog was closed by any other action.
*/
this.go({ cipherId: null, itemId: null, action: null });
}
/**
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
* @param cipher - CipherView
@@ -717,15 +857,19 @@ export class VaultComponent implements OnInit, OnDestroy {
// Wait for the dialog to close.
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// If the dialog was closed by clicking the edit button, navigate to open the edit dialog.
if (result?.action === ViewCipherDialogResult.Edited) {
this.go({ itemId: cipherView.id, action: "edit" });
return;
}
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result?.action === ViewCipherDialogResult.deleted) {
if (result?.action === ViewCipherDialogResult.Deleted) {
this.refresh();
}
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
if (!result?.action) {
this.go({ cipherId: null, itemId: null, action: null });
}
// Clear the query params when the view dialog closes
this.go({ cipherId: null, itemId: null, action: null });
}
async addCollection() {
@@ -873,7 +1017,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
const component = await this.editCipher(cipher);
const component = await this.editCipher(cipher, true);
component.cloneMode = true;
}

View File

@@ -3,13 +3,19 @@
{{ cipherTypeString }}
</span>
<ng-container bitDialogContent>
<app-cipher-view [cipher]="cipher"></app-cipher-view>
<app-cipher-view [cipher]="cipher" [collections]="collections"></app-cipher-view>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton (click)="edit()" buttonType="primary" type="button" [disabled]="!cipher.edit">
<button
bitButton
(click)="edit()"
buttonType="primary"
type="button"
[disabled]="params.disableEdit"
>
{{ "edit" | i18n }}
</button>
<div class="ml-auto">
<div class="tw-ml-auto">
<button
bitButton
type="button"

View File

@@ -1,6 +1,5 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -17,12 +16,11 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { ViewComponent, ViewCipherDialogParams, ViewCipherDialogResult } from "./view.component";
import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component";
describe("ViewComponent", () => {
let component: ViewComponent;
let fixture: ComponentFixture<ViewComponent>;
let router: Router;
const mockCipher: CipherView = {
id: "cipher-id",
@@ -56,7 +54,6 @@ describe("ViewComponent", () => {
provide: OrganizationService,
useValue: { get: jest.fn().mockResolvedValue(mockOrganization) },
},
{ provide: Router, useValue: mock<Router>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: FolderService, useValue: mock<FolderService>() },
{ provide: CryptoService, useValue: mock<CryptoService>() },
@@ -70,7 +67,6 @@ describe("ViewComponent", () => {
fixture = TestBed.createComponent(ViewComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
component.params = mockParams;
component.cipher = mockCipher;
});
@@ -85,20 +81,12 @@ describe("ViewComponent", () => {
});
describe("edit", () => {
it("navigates to the edit route and closes the dialog with the proper arguments", async () => {
jest.spyOn(router, "navigate").mockResolvedValue(true);
it("closes the dialog with the proper arguments", async () => {
const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close");
await component.edit();
expect(router.navigate).toHaveBeenCalledWith([], {
queryParams: {
itemId: mockCipher.id,
action: "edit",
organizationId: mockCipher.organizationId,
},
});
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.edited });
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited });
});
});
@@ -111,7 +99,7 @@ describe("ViewComponent", () => {
await component.delete();
expect(deleteSpy).toHaveBeenCalled();
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.deleted });
expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted });
});
});
});

View File

@@ -1,8 +1,6 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
import { Component, Inject, OnInit, EventEmitter } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -12,6 +10,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import {
AsyncActionsModule,
DialogModule,
@@ -19,16 +18,30 @@ import {
ToastService,
} from "@bitwarden/components";
import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
export interface ViewCipherDialogParams {
cipher: CipherView;
/**
* Optional list of collections the cipher is assigned to. If none are provided, they will be loaded using the
* `CipherService` and the `collectionIds` property of the cipher.
*/
collections?: CollectionView[];
/**
* If true, the edit button will be disabled in the dialog.
*/
disableEdit?: boolean;
}
export enum ViewCipherDialogResult {
edited = "edited",
deleted = "deleted",
Edited = "edited",
Deleted = "deleted",
PremiumUpgrade = "premiumUpgrade",
}
export interface ViewCipherDialogCloseResult {
@@ -43,15 +56,17 @@ export interface ViewCipherDialogCloseResult {
templateUrl: "view.component.html",
standalone: true,
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
],
})
export class ViewComponent implements OnInit, OnDestroy {
export class ViewComponent implements OnInit {
cipher: CipherView;
collections?: CollectionView[];
onDeletedCipher = new EventEmitter<CipherView>();
cipherTypeString: string;
organization: Organization;
protected destroy$ = new Subject<void>();
constructor(
@Inject(DIALOG_DATA) public params: ViewCipherDialogParams,
private dialogRef: DialogRef<ViewCipherDialogCloseResult>,
@@ -62,7 +77,6 @@ export class ViewComponent implements OnInit, OnDestroy {
private cipherService: CipherService,
private toastService: ToastService,
private organizationService: OrganizationService,
private router: Router,
) {}
/**
@@ -70,20 +84,13 @@ export class ViewComponent implements OnInit, OnDestroy {
*/
async ngOnInit() {
this.cipher = this.params.cipher;
this.collections = this.params.collections;
this.cipherTypeString = this.getCipherViewTypeString();
if (this.cipher.organizationId) {
this.organization = await this.organizationService.get(this.cipher.organizationId);
}
}
/**
* Lifecycle hook for component destruction.
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Method to handle cipher deletion. Called when a user clicks the delete button.
*/
@@ -117,7 +124,7 @@ export class ViewComponent implements OnInit, OnDestroy {
this.logService.error(e);
}
this.dialogRef.close({ action: ViewCipherDialogResult.deleted });
this.dialogRef.close({ action: ViewCipherDialogResult.Deleted });
};
/**
@@ -136,14 +143,7 @@ export class ViewComponent implements OnInit, OnDestroy {
* Method to handle cipher editing. Called when a user clicks the edit button.
*/
async edit(): Promise<void> {
this.dialogRef.close({ action: ViewCipherDialogResult.edited });
await this.router.navigate([], {
queryParams: {
itemId: this.cipher.id,
action: "edit",
organizationId: this.cipher.organizationId,
},
});
this.dialogRef.close({ action: ViewCipherDialogResult.Edited });
}
/**

View File

@@ -5,6 +5,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -31,6 +32,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
cipherService: CipherService,
i18nService: I18nService,
cryptoService: CryptoService,
encryptService: EncryptService,
stateService: StateService,
platformUtilsService: PlatformUtilsService,
apiService: ApiService,
@@ -45,6 +47,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
cipherService,
i18nService,
cryptoService,
encryptService,
stateService,
platformUtilsService,
apiService,

View File

@@ -122,7 +122,9 @@ export class VaultHeaderComponent implements OnInit {
return this.i18nService.t("unassigned");
}
return `${this.organization?.name} ${headerType}`;
return this.organization?.name
? `${this.organization?.name} ${headerType}`
: this.i18nService.t("collections");
}
get icon() {

View File

@@ -81,7 +81,11 @@
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned"
*ngIf="
filter.type !== 'trash' &&
filter.collectionId !== Unassigned &&
selectedCollection?.node?.canEditItems(organization)
"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>

View File

@@ -11,7 +11,6 @@ import { ActivatedRoute, Params, Router } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
defer,
firstValueFrom,
lastValueFrom,
Observable,
@@ -283,27 +282,10 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
this.allCollectionsWithoutUnassigned$ = combineLatest([
organizationId$.pipe(switchMap((orgId) => this.collectionAdminService.getAll(orgId))),
defer(() => this.collectionService.getAllDecrypted()),
]).pipe(
map(([adminCollections, syncCollections]) => {
const syncCollectionDict = Object.fromEntries(syncCollections.map((c) => [c.id, c]));
return adminCollections.map((collection) => {
const currentId: any = collection.id;
const match = syncCollectionDict[currentId];
if (match) {
collection.manage = match.manage;
collection.readOnly = match.readOnly;
collection.hidePasswords = match.hidePasswords;
}
return collection;
});
}),
shareReplay({ refCount: true, bufferSize: 1 }),
this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe(
switchMap(() => organizationId$),
switchMap((orgId) => this.collectionAdminService.getAll(orgId)),
shareReplay({ refCount: false, bufferSize: 1 }),
);
this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe(
@@ -336,8 +318,8 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }),
);
const allCiphers$ = organization$.pipe(
concatMap(async (organization) => {
const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe(
switchMap(async ([organization]) => {
// If user swaps organization reset the addAccessToggle
if (!this.showAddAccessToggle || organization) {
this.addAccessToggle(0);
@@ -361,13 +343,13 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.searchService.indexCiphers(ciphers, organization.id);
return ciphers;
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
const allCipherMap$ = allCiphers$.pipe(
map((ciphers) => {
return Object.fromEntries(ciphers.map((c) => [c.id, c]));
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
const nestedCollections$ = allCollections$.pipe(
@@ -494,20 +476,23 @@ export class VaultComponent implements OnInit, OnDestroy {
firstSetup$
.pipe(
switchMap(() => combineLatest([this.route.queryParams, organization$])),
switchMap(async ([qParams, organization]) => {
switchMap(() =>
combineLatest([this.route.queryParams, allCipherMap$, allCollections$, organization$]),
),
switchMap(async ([qParams, allCiphersMap, allCollections]) => {
const cipherId = getCipherIdFromParams(qParams);
if (!cipherId) {
return;
}
const canEditCipher =
organization.canEditAllCiphers ||
(await firstValueFrom(allCipherMap$))[cipherId] != undefined;
const cipher = allCiphersMap[cipherId];
const cipherCollections = allCollections.filter((c) =>
cipher.collectionIds.includes(c.id),
);
if (canEditCipher) {
if (cipher) {
if (qParams.action === "view") {
await this.viewCipherById(cipherId);
await this.viewCipher(cipher, cipherCollections);
} else {
await this.editCipherId(cipherId);
}
@@ -775,10 +760,6 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
async navigateToCipher(cipher: CipherView) {
this.go({ itemId: cipher?.id });
}
async editCipher(
cipher: CipherView,
additionalComponentParameters?: (comp: AddEditComponent) => void,
@@ -842,58 +823,46 @@ export class VaultComponent implements OnInit, OnDestroy {
}
/**
* Takes a CipherView and opens a dialog where it can be viewed (wraps viewCipherById).
* @param cipher - CipherView
* @returns Promise<void>
* Takes a cipher and its assigned collections to opens dialog where it can be viewed.
* @param cipher - the cipher to view
* @param collections - the collections the cipher is assigned to
*/
viewCipher(cipher: CipherView) {
return this.viewCipherById(cipher.id);
}
/**
* Takes a cipher id and opens a dialog where it can be viewed.
* @param id - string
* @returns Promise<void>
*/
async viewCipherById(id: string) {
const cipher = await this.cipherService.get(id);
// if cipher exists (cipher is null when new) and MP reprompt
// is on for this cipher, then show password reprompt.
if (
cipher &&
cipher.reprompt !== 0 &&
!(await this.passwordRepromptService.showPasswordPrompt())
) {
// didn't pass password prompt, so don't open add / edit modal.
async viewCipher(cipher: CipherView, collections: CollectionView[] = []) {
if (!cipher) {
this.go({ cipherId: null, itemId: null });
return;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
// Decrypt the cipher.
const cipherView = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
if (cipher.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
// didn't pass password prompt, so don't open the dialog
this.go({ cipherId: null, itemId: null });
return;
}
// Open the dialog.
const dialogRef = openViewCipherDialog(this.dialogService, {
data: { cipher: cipherView },
data: {
cipher: cipher,
collections: collections,
disableEdit: !cipher.edit && !this.organization.canEditAllCiphers,
},
});
// Wait for the dialog to close.
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// If the dialog was closed by clicking the edit button, navigate to open the edit dialog.
if (result?.action === ViewCipherDialogResult.Edited) {
this.go({ itemId: cipher.id, action: "edit" });
return;
}
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result.action === ViewCipherDialogResult.deleted) {
if (result?.action === ViewCipherDialogResult.Deleted) {
this.refresh();
}
// If the dialog was closed by any other action (close button, escape key, etc), navigate back to the vault.
if (!result.action) {
this.go({ cipherId: null, itemId: null, action: null });
}
// Clear the query params when the view dialog closes
this.go({ cipherId: null, itemId: null, action: null });
}
async cloneCipher(cipher: CipherView) {

View File

@@ -0,0 +1,95 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of, lastValueFrom } from "rxjs";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service";
describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
beforeEach(() => {
dialogServiceMock = {
openSimpleDialog: jest.fn(),
} as unknown as jest.Mocked<DialogService>;
routerMock = {
navigate: jest.fn(),
} as unknown as jest.Mocked<Router>;
dialogRefMock = {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
TestBed.configureTestingModule({
providers: [
WebVaultPremiumUpgradePromptService,
{ provide: DialogService, useValue: dialogServiceMock },
{ provide: Router, useValue: routerMock },
{ provide: DialogRef, useValue: dialogRefMock },
],
});
service = TestBed.inject(WebVaultPremiumUpgradePromptService);
});
it("prompts for premium upgrade and navigates to organization billing if organizationId is provided", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
const organizationId = "test-org-id" as OrganizationId;
await service.promptForPremium(organizationId);
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
expect(routerMock.navigate).toHaveBeenCalledWith([
"organizations",
organizationId,
"billing",
"subscription",
]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
});
it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(true)));
await service.promptForPremium();
expect(dialogServiceMock.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
});
it("does not navigate or close dialog if upgrade is no action is taken", async () => {
dialogServiceMock.openSimpleDialog.mockReturnValue(lastValueFrom(of(false)));
await service.promptForPremium("test-org-id" as OrganizationId);
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(dialogRefMock.close).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
/**
* This service is used to prompt the user to upgrade to premium.
*/
@Injectable()
export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePromptService {
constructor(
private dialogService: DialogService,
private router: Router,
private dialog: DialogRef<ViewCipherDialogCloseResult>,
) {}
/**
* Prompts the user to upgrade to premium.
* @param organizationId The ID of the organization to upgrade.
*/
async promptForPremium(organizationId?: OrganizationId) {
let upgradeConfirmed;
if (organizationId) {
upgradeConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "upgradeOrganization" },
content: { key: "upgradeOrganizationDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
});
if (upgradeConfirmed) {
await this.router.navigate(["organizations", organizationId, "billing", "subscription"]);
}
} else {
upgradeConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "upgrade" },
type: "success",
});
if (upgradeConfirmed) {
await this.router.navigate(["settings/subscription/premium"]);
}
}
if (upgradeConfirmed) {
this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade });
}
}
}