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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ "learnMore" | i18n }}.
|
||||
{{ "learnMoreAboutEmergencyAccess" | i18n }}
|
||||
</a>
|
||||
</p>
|
||||
<bit-callout *ngIf="isOrganizationOwner" type="warning" title="{{ 'warning' | i18n }}">{{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }}
|
||||
×
|
||||
{{ 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
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
</div>
|
||||
<!-- Add new custom field -->
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="addField()"
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts
Normal file
177
apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
|
||||
(onEvent)="onVaultItemsEvent($event)"
|
||||
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
|
||||
[activeCollection]="selectedCollection?.node"
|
||||
>
|
||||
</app-vault-items>
|
||||
<div
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user