diff --git a/.github/renovate.json b/.github/renovate.json index a1200912dc8..562622807c2 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -81,7 +81,6 @@ "cross-env", "del", "gulp", - "gulp-filter", "gulp-if", "gulp-json-editor", "gulp-replace", diff --git a/apps/browser/gulpfile.js b/apps/browser/gulpfile.js index 53a0e886267..573f86efc18 100644 --- a/apps/browser/gulpfile.js +++ b/apps/browser/gulpfile.js @@ -14,23 +14,9 @@ const betaBuild = process.env.BETA_BUILD === "1"; const paths = { build: "./build/", dist: "./dist/", - node_modules: "./node_modules/", - popupDir: "./src/popup/", - cssDir: "./src/popup/css/", safari: "./src/safari/", }; -const filters = { - fonts: [ - "!build/popup/fonts/*", - "build/popup/fonts/dm-sans.woff2", - "build/popup/fonts/bwi-font.woff2", - "build/popup/fonts/bwi-font.woff", - "build/popup/fonts/bwi-font.ttf", - ], - safari: ["!build/safari/**/*"], -}; - /** * Converts a number to a tuple containing two Uint16's * @param num {number} This number is expected to be a integer style number with no decimals @@ -64,11 +50,9 @@ function distFileName(browserName, ext) { async function dist(browserName, manifest) { const { default: zip } = await import("gulp-zip"); - const { default: filter } = await import("gulp-filter"); return gulp .src(paths.build + "**/*") - .pipe(filter(["**"].concat(filters.fonts).concat(filters.safari))) .pipe(gulpif("popup/index.html", replace("__BROWSER__", "browser_" + browserName))) .pipe(gulpif("manifest.json", jeditor(manifest))) .pipe(zip(distFileName(browserName, "zip"))) @@ -192,8 +176,6 @@ function distSafariApp(cb, subBuildPath) { return new Promise((resolve) => proc.on("close", resolve)); }) .then(async () => { - const { default: filter } = await import("gulp-filter"); - const libs = fs .readdirSync(builtAppexFrameworkPath) .filter((p) => p.endsWith(".dylib")) @@ -237,13 +219,10 @@ function safariCopyAssets(source, dest) { } async function safariCopyBuild(source, dest) { - const { default: filter } = await import("gulp-filter"); - return new Promise((resolve, reject) => { gulp .src(source) .on("error", reject) - .pipe(filter(["**"].concat(filters.fonts))) .pipe(gulpif("popup/index.html", replace("__BROWSER__", "browser_safari"))) .pipe( gulpif( diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 9d7644878d0..db85b28fa64 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -131,31 +131,35 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { return; } - if (data.pageTitle) { - this.pageTitle = this.handleStringOrTranslation(data.pageTitle); + // Null emissions are used to reset the page data as all fields are optional. + + if (data.pageTitle !== undefined) { + this.pageTitle = + data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null; } - if (data.pageSubtitle) { - this.pageSubtitle = this.handleStringOrTranslation(data.pageSubtitle); + if (data.pageSubtitle !== undefined) { + this.pageSubtitle = + data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null; } - if (data.pageIcon) { - this.pageIcon = data.pageIcon; + if (data.pageIcon !== undefined) { + this.pageIcon = data.pageIcon !== null ? data.pageIcon : null; } - if (data.showReadonlyHostname != null) { + if (data.showReadonlyHostname !== undefined) { this.showReadonlyHostname = data.showReadonlyHostname; } - if (data.showAcctSwitcher != null) { + if (data.showAcctSwitcher !== undefined) { this.showAcctSwitcher = data.showAcctSwitcher; } - if (data.showBackButton != null) { + if (data.showBackButton !== undefined) { this.showBackButton = data.showBackButton; } - if (data.showLogo != null) { + if (data.showLogo !== undefined) { this.showLogo = data.showLogo; } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a3dd1c473ae..e5a4087510c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -177,6 +177,10 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -369,6 +373,7 @@ export default class MainBackground { themeStateService: DefaultThemeStateService; autoSubmitLoginBackground: AutoSubmitLoginBackground; sdkService: SdkService; + cipherAuthorizationService: CipherAuthorizationService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -1265,6 +1270,11 @@ export default class MainBackground { } this.userAutoUnlockKeyService = new UserAutoUnlockKeyService(this.cryptoService); + + this.cipherAuthorizationService = new DefaultCipherAuthorizationService( + this.collectionService, + this.organizationService, + ); } async bootstrap() { diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 407a4d414a5..585f6067e3d 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -114,8 +114,8 @@ export class SendAddEditComponent { /** * Handles the event when the send is updated. */ - onSendUpdated(send: SendView) { - this.location.back(); + async onSendUpdated(_: SendView) { + await this.router.navigate(["/tabs/send"]); } deleteSend = async () => { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 7c65cbeb17d..af3abbf5427 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -4,7 +4,7 @@ slot="header" [pageTitle]="'createdSend' | i18n" showBackButton - [backAction]="close.bind(this)" + [backAction]="goToEditSend.bind(this)" > @@ -15,7 +15,9 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" > -

{{ "createdSendSuccessfully" | i18n }}

+

+ {{ "createdSendSuccessfully" | i18n }} +

{{ formatExpirationDate() }}

@@ -27,7 +29,7 @@ - diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 24186ad4275..fdf147b360f 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,6 +11,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; @@ -50,6 +51,7 @@ describe("SendCreatedComponent", () => { sendView = { id: sendId, deletionDate: new Date(), + type: SendType.Text, accessId: "abc", urlB64Key: "123", } as SendView; @@ -129,9 +131,11 @@ describe("SendCreatedComponent", () => { expect(component["hoursAvailable"]).toBe(0); }); - it("should navigate back to send list on close", async () => { - await component.close(); - expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]); + it("should navigate back to the edit send form on close", async () => { + await component.goToEditSend(); + expect(router.navigate).toHaveBeenCalledWith(["/edit-send"], { + queryParams: { sendId: "test-send-id", type: SendType.Text }, + }); }); describe("getHoursAvailable", () => { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 88475d7dad9..98b09d380e4 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -77,7 +77,13 @@ export class SendCreatedComponent { return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60))); } - async close() { + async goToEditSend() { + await this.router.navigate([`/edit-send`], { + queryParams: { sendId: this.send.id, type: this.send.type }, + }); + } + + async goBack() { await this.router.navigate(["/tabs/send"]); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html index 7652b8ab0bf..72aaeea493d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html @@ -1,4 +1,4 @@ - + { provide: AccountService, useValue: accountService, }, + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn().mockReturnValue(true), + }, + }, ], }).compileComponents(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 542c4f82438..cc2c419c04c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -19,6 +19,7 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, ButtonModule, @@ -68,6 +69,7 @@ export class ViewV2Component { cipher: CipherView; organization$: Observable; folder$: Observable; + canDeleteCipher$: Observable; collections$: Observable; loadAction: typeof AUTOFILL_ID | typeof SHOW_AUTOFILL_BUTTON; @@ -83,6 +85,7 @@ export class ViewV2Component { private accountService: AccountService, private eventCollectionService: EventCollectionService, private popupRouterCacheService: PopupRouterCacheService, + protected cipherAuthorizationService: CipherAuthorizationService, ) { this.subscribeToParams(); } @@ -101,6 +104,8 @@ export class ViewV2Component { await this.vaultPopupAutofillService.doAutofill(this.cipher); } + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(cipher); + await this.eventCollectionService.collect( EventType.Cipher_ClientViewed, cipher.id, diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html index 9ddae878770..32d7c283b28 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html @@ -779,7 +779,7 @@ -
+
-
+
- -
+

{{ "additionalOptions" | i18n }}

@@ -302,13 +262,50 @@ - -

- - {{ - "manageBillingFromProviderPortalMessage" | i18n - }} -
- + + + +

{{ "manageSubscription" | i18n }}

+

+ {{ "manageSubscriptionFromThe" | i18n }} + {{ + "providerPortal" | i18n + }}. +

+ +

+ {{ "billingManagedByProvider" | i18n: userOrg.providerName }}. + {{ "billingContactProviderForAssistance" | i18n }}. +

+
+ +
+ +
+ +

{{ "billingManagedByProvider" | i18n: userOrg.providerName }}

+

{{ "billingContactProviderForAssistance" | i18n }}

+
+
+
+
+ + + +

+ {{ "selfHostingTitleProper" | i18n }} +

+

+ {{ "toHostBitwardenOnYourOwnServer" | i18n }} +

+
+ + +
+
+
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 7a66faa0a43..e604140e569 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -5,9 +5,9 @@ import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUnti import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { OrganizationApiKeyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; @@ -15,7 +15,6 @@ 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"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { @@ -34,7 +33,7 @@ import { import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan-dialog.component"; import { DownloadLicenceDialogComponent } from "./download-license.component"; -import { ManageBilling } from "./icons/manage-billing.icon"; +import { SubscriptionHiddenIcon } from "./icons/subscription-hidden.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @Component({ @@ -50,19 +49,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy hasBillingSyncToken: boolean; showAdjustSecretsManager = false; showSecretsManagerSubscribe = false; - firstLoaded = false; - loading: boolean; + loading = true; locale: string; showUpdatedSubscriptionStatusSection$: Observable; - manageBillingFromProviderPortal = ManageBilling; - isManagedByConsolidatedBillingMSP = false; enableTimeThreshold: boolean; preSelectedProductTier: ProductTierType = ProductTierType.Free; + showSubscription = true; + showSelfHost = false; + protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon; protected readonly teamsStarter = ProductTierType.TeamsStarter; - private destroy$ = new Subject(); - protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( FeatureFlag.EnableConsolidatedBilling, ); @@ -71,7 +68,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.EnableTimeThreshold, ); - protected EnableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( + protected enableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( FeatureFlag.EnableUpgradePasswordManagerSub, ); @@ -79,9 +76,10 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.AC2476_DeprecateStripeSourcesAPI, ); + private destroy$ = new Subject(); + constructor( private apiService: ApiService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private logService: LogService, private organizationService: OrganizationService, @@ -89,15 +87,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private route: ActivatedRoute, private dialogService: DialogService, private configService: ConfigService, - private providerService: ProviderService, private toastService: ToastService, + private billingApiService: BillingApiServiceAbstraction, ) {} async ngOnInit() { if (this.route.snapshot.queryParamMap.get("upgrade")) { - // 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.changePlan(); + await this.changePlan(); const productTierTypeStr = this.route.snapshot.queryParamMap.get("productTierType"); if (productTierTypeStr != null) { const productTier = Number(productTierTypeStr); @@ -112,7 +108,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy concatMap(async (params) => { this.organizationId = params.organizationId; await this.load(); - this.firstLoaded = true; }), takeUntil(this.destroy$), ) @@ -130,21 +125,34 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } async load() { - if (this.loading) { - return; - } this.loading = true; this.locale = await firstValueFrom(this.i18nService.locale$); this.userOrg = await this.organizationService.get(this.organizationId); - if (this.userOrg.canViewSubscription) { - const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); - const provider = await this.providerService.get(this.userOrg.providerId); - this.isManagedByConsolidatedBillingMSP = - enableConsolidatedBilling && - this.userOrg.hasProvider && - provider?.providerStatus == ProviderStatusType.Billable; + /* + +--------------------+--------------+----------------------+--------------+ + | User Type | Has Provider | Consolidated Billing | Subscription | + +--------------------+--------------+----------------------+--------------+ + | Organization Owner | False | N/A | Shown | + | Organization Owner | True | N/A | Hidden | + | Provider User | True | False | Shown | + | Provider User | True | True | Hidden | + +--------------------+--------------+----------------------+--------------+ + */ + const consolidatedBillingEnabled = await firstValueFrom(this.enableConsolidatedBilling$); + + this.showSubscription = + (!this.userOrg.hasProvider && this.userOrg.isOwner) || + (this.userOrg.hasProvider && this.userOrg.isProviderUser && !consolidatedBillingEnabled); + + const metadata = await this.billingApiService.getOrganizationBillingMetadata( + this.organizationId, + ); + + this.showSelfHost = metadata.isEligibleForSelfHost; + + if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); this.lineItems = this.sub?.subscription?.items; @@ -277,26 +285,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.sub.subscription?.items.some((i) => i.sponsoredSubscriptionItem); } - get canDownloadLicense() { - return ( - (this.sub.planType !== PlanType.Free && this.subscription == null) || - (this.subscription != null && !this.subscription.cancelled) - ); - } - - get canManageBillingSync() { - return ( - this.sub.planType === PlanType.EnterpriseAnnually || - this.sub.planType === PlanType.EnterpriseMonthly || - this.sub.planType === PlanType.EnterpriseAnnually2023 || - this.sub.planType === PlanType.EnterpriseMonthly2023 || - this.sub.planType === PlanType.EnterpriseAnnually2020 || - this.sub.planType === PlanType.EnterpriseMonthly2020 || - this.sub.planType === PlanType.EnterpriseAnnually2019 || - this.sub.planType === PlanType.EnterpriseMonthly2019 - ); - } - get subscriptionDesc() { if (this.sub.planType === PlanType.Free) { return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); @@ -353,13 +341,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy ); } - shownSelfHost(): boolean { - return ( - this.sub?.plan.productTier !== ProductTierType.Teams && - this.sub?.plan.productTier !== ProductTierType.Free - ); - } - cancelSubscription = async () => { const reference = openOffboardingSurvey(this.dialogService, { data: { @@ -399,9 +380,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy title: null, message: this.i18nService.t("reinstated"), }); - // 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.load(); + await this.load(); } catch (e) { this.logService.error(e); } @@ -409,7 +388,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy async changePlan() { const EnableUpgradePasswordManagerSub = await firstValueFrom( - this.EnableUpgradePasswordManagerSub$, + this.enableUpgradePasswordManagerSub$, ); if (EnableUpgradePasswordManagerSub) { const reference = openChangePlanDialog(this.dialogService, { @@ -458,24 +437,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy }); await firstValueFrom(dialogRef.closed); - // 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.load(); + await this.load(); } - closeDownloadLicense() { - this.showDownloadLicense = false; - } - - subscriptionAdjusted() { - // 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.load(); + async subscriptionAdjusted() { + await this.load(); } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total / (1 - this.customerDiscount?.percentOff / 100); - return discountedTotal; + return total / (1 - this.customerDiscount?.percentOff / 100); } adjustStorage = (add: boolean) => { diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 740264713ca..ccf853837c3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -72,7 +72,7 @@ buttonType="danger" [appA11yTitle]="'delete' | i18n" [bitAction]="delete" - [disabled]="!canDelete" + [disabled]="!(canDeleteCipher$ | async)" data-testid="delete-cipher-btn" >
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 38416c2c39c..ae2cf88fd1f 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -2,7 +2,7 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, Subject } from "rxjs"; +import { firstValueFrom, Observable, Subject } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -12,12 +12,13 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs 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 { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, ButtonModule, @@ -63,6 +64,16 @@ export interface VaultItemDialogParams { * If true, the "edit" button will be disabled in the dialog. */ disableForm?: boolean; + + /** + * The ID of the active collection. This is know the collection filter selected by the user. + */ + activeCollectionId?: CollectionId; + + /** + * If true, the dialog is being opened from the admin console. + */ + isAdminConsoleAction?: boolean; } export enum VaultItemDialogResult { @@ -204,6 +215,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected formConfig: CipherFormConfig = this.params.formConfig; + protected canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) protected params: VaultItemDialogParams, private dialogRef: DialogRef, @@ -217,6 +230,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private router: Router, private billingAccountProfileStateService: BillingAccountProfileStateService, private premiumUpgradeService: PremiumUpgradePromptService, + private cipherAuthorizationService: CipherAuthorizationService, ) { this.updateTitle(); } @@ -231,6 +245,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.organization = this.formConfig.organizations.find( (o) => o.id === this.cipher.organizationId, ); + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.params.activeCollectionId], + this.params.isAdminConsoleAction, + ); } this.performingInitialLoad = false; diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 2f38d7c70db..5c4de576ead 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -69,7 +69,11 @@ > - + +

+ {{ permissionText }} +

+ - - diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index 0dd58b846d7..d1bfd221175 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -14,6 +14,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component"; @@ -62,6 +63,12 @@ describe("ViewComponent", () => { useValue: mock(), }, { provide: ConfigService, useValue: mock() }, + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn().mockReturnValue(true), + }, + }, ], }).compileComponents(); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index 99829e8f086..d30c453a4bd 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,7 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -8,10 +9,12 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga 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 { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, DialogModule, @@ -34,6 +37,11 @@ export interface ViewCipherDialogParams { */ collections?: CollectionView[]; + /** + * Optional collection ID used to know the collection filter selected. + */ + activeCollectionId?: CollectionId; + /** * If true, the edit button will be disabled in the dialog. */ @@ -71,6 +79,8 @@ export class ViewComponent implements OnInit { cipherTypeString: string; organization: Organization; + canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) public params: ViewCipherDialogParams, private dialogRef: DialogRef, @@ -81,6 +91,7 @@ export class ViewComponent implements OnInit { private cipherService: CipherService, private toastService: ToastService, private organizationService: OrganizationService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} /** @@ -93,6 +104,10 @@ export class ViewComponent implements OnInit { if (this.cipher.organizationId) { this.organization = await this.organizationService.get(this.cipher.organizationId); } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.params.activeCollectionId, + ]); } /** diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 9cb5542a7b7..7a4697f5af6 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -21,6 +21,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -57,6 +58,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe: DatePipe, configService: ConfigService, billingAccountProfileStateService: BillingAccountProfileStateService, + cipherAuthorizationService: CipherAuthorizationService, ) { super( cipherService, @@ -79,6 +81,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe, configService, billingAccountProfileStateService, + cipherAuthorizationService, ); } @@ -90,6 +93,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected async loadCipher() { + this.isAdminConsoleAction = true; // Calling loadCipher first to assess if the cipher is unassigned. If null use apiService getCipherAdmin const firstCipherCheck = await super.loadCipher(); diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 220d6ef490f..0bcdc52eaeb 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -70,6 +70,7 @@ [viewingOrgVault]="true" [addAccessStatus]="addAccessStatus$ | async" [addAccessToggle]="showAddAccessToggle" + [activeCollection]="selectedCollection?.node" > diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 94bb6011dc7..87e40036d50 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -347,6 +347,7 @@ export class VaultComponent implements OnInit, OnDestroy { // If the user can edit all ciphers for the organization then fetch them ALL. if (organization.canEditAllCiphers) { ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + ciphers?.forEach((c) => (c.edit = true)); } else { // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); @@ -828,6 +829,7 @@ export class VaultComponent implements OnInit, OnDestroy { comp.organization = this.organization; comp.organizationId = this.organization.id; comp.cipherId = cipher?.id; + comp.collectionId = this.activeFilter.collectionId; comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); this.refresh(); @@ -897,7 +899,12 @@ export class VaultComponent implements OnInit, OnDestroy { cipher.type, ); - await this.openVaultItemDialog("view", cipherFormConfig, cipher); + await this.openVaultItemDialog( + "view", + cipherFormConfig, + cipher, + this.activeFilter.collectionId as CollectionId, + ); } /** @@ -907,6 +914,7 @@ export class VaultComponent implements OnInit, OnDestroy { mode: VaultItemDialogMode, formConfig: CipherFormConfig, cipher?: CipherView, + activeCollectionId?: CollectionId, ) { const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; // If the form is disabled, force the mode into `view` @@ -915,6 +923,8 @@ export class VaultComponent implements OnInit, OnDestroy { mode: dialogMode, formConfig, disableForm, + activeCollectionId, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d9be5d769bd..c50775efa6e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9457,5 +9457,15 @@ }, "permanentlyDeleteAttachmentConfirmation": { "message": "Are you sure you want to permanently delete this attachment?" + }, + "manageSubscriptionFromThe": { + "message": "Manage subscription from the", + "description": "This represents the beginning of a sentence. The full sentence will be 'Manage subscription from the Provider Portal', but 'Provider Portal' will be a link and thus cannot be included in the translation file." + }, + "toHostBitwardenOnYourOwnServer": { + "message": "To host Bitwarden on your own server, you will need to upload your license file. To support Free Families plans and advanced billing capabilities for your self-hosted organization, you will need to set up automatic sync in your self-hosted organization." + }, + "selfHostingTitleProper": { + "message": "Self-Hosting" } } diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts new file mode 100644 index 00000000000..fcf5ada4b2c --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class MemberCipherDetailsResponse extends BaseResponse { + userName: string; + email: string; + useKeyConnector: boolean; + cipherIds: string[] = []; + + constructor(response: any) { + super(response); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.useKeyConnector = this.getResponseProperty("UseKeyConnector"); + this.cipherIds = this.getResponseProperty("CipherIds"); + } +} diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts new file mode 100644 index 00000000000..b71abe075e6 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts @@ -0,0 +1,105 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; + +const mockMemberCipherDetails: any = { + data: [ + { + userName: "David Brent", + email: "david.brent@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Tim Canterbury", + email: "tim.canterbury@wernhamhogg.uk", + usesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Gareth Keenan", + email: "gareth.keenan@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + "cbea34a8-bde4-46ad-9d19-b05001227nm7", + ], + }, + { + userName: "Dawn Tinsley", + email: "dawn.tinsley@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + { + userName: "Keith Bishop", + email: "keith.bishop@wernhamhogg.uk", + usesKeyConnector: false, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab1", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + "cbea34a8-bde4-46ad-9d19-b05001227nm5", + ], + }, + { + userName: "Chris Finch", + email: "chris.finch@wernhamhogg.uk", + usesKeyConnector: true, + cipherIds: [ + "cbea34a8-bde4-46ad-9d19-b05001228ab2", + "cbea34a8-bde4-46ad-9d19-b05001228cd3", + "cbea34a8-bde4-46ad-9d19-b05001228xy4", + ], + }, + ], +}; + +describe("Member Cipher Details API Service", () => { + let memberCipherDetailsApiService: MemberCipherDetailsApiService; + + const apiService = mock(); + + beforeEach(() => { + memberCipherDetailsApiService = new MemberCipherDetailsApiService(apiService); + jest.resetAllMocks(); + }); + + it("instantiates", () => { + expect(memberCipherDetailsApiService).not.toBeFalsy(); + }); + + it("getMemberCipherDetails retrieves data", async () => { + apiService.send.mockResolvedValue(mockMemberCipherDetails); + + const orgId = "1234"; + const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); + expect(result).not.toBeNull(); + expect(result).toHaveLength(6); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/reports/member-cipher-details/" + orgId, + null, + true, + true, + ); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts new file mode 100644 index 00000000000..9351ac87776 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts @@ -0,0 +1,27 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; + +export class MemberCipherDetailsApiService { + constructor(private apiService: ApiService) {} + + /** + * Returns a list of organization members with their assigned + * cipherIds + * @param orgId OrganizationId to get member cipher details for + * @returns List of organization members and assigned cipherIds + */ + async getMemberCipherDetails(orgId: string): Promise { + const response = await this.apiService.send( + "GET", + "/reports/member-cipher-details/" + orgId, + null, + true, + true, + ); + + const listResponse = new ListResponse(response, MemberCipherDetailsResponse); + return listResponse.data.map((r) => new MemberCipherDetailsResponse(r)); + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts deleted file mode 100644 index 918673e63f5..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-confirm.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Component, Input } from "@angular/core"; - -import { ProviderUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ProviderUserBulkConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk-confirm.request"; -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { BulkConfirmComponent as OrganizationBulkConfirmComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-confirm.component"; -import { BulkUserDetails } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; - -/** - * @deprecated Please use the {@link BulkConfirmDialogComponent} instead. - */ -@Component({ - templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html", -}) -export class BulkConfirmComponent extends OrganizationBulkConfirmComponent { - @Input() providerId: string; - - protected override isAccepted(user: BulkUserDetails) { - return user.status === ProviderUserStatusType.Accepted; - } - - protected override async getPublicKeys() { - const request = new ProviderUserBulkRequest(this.filteredUsers.map((user) => user.id)); - return await this.apiService.postProviderUsersPublicKey(this.providerId, request); - } - - protected override getCryptoKey(): Promise { - return this.cryptoService.getProviderKey(this.providerId); - } - - protected override async postConfirmRequest(userIdsWithKeys: any[]) { - const request = new ProviderUserBulkConfirmRequest(userIdsWithKeys); - return await this.apiService.postProviderUserBulkConfirm(this.providerId, request); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts deleted file mode 100644 index ea3ea9b5967..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/bulk/bulk-remove.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, Input } from "@angular/core"; - -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { BulkRemoveComponent as OrganizationBulkRemoveComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-remove.component"; - -/** - * @deprecated Please use the {@link BulkRemoveDialogComponent} instead. - */ -@Component({ - templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html", -}) -export class BulkRemoveComponent extends OrganizationBulkRemoveComponent { - @Input() providerId: string; - - async deleteUsers() { - const request = new ProviderUserBulkRequest(this.users.map((user) => user.id)); - return await this.apiService.deleteManyProviderUsers(this.providerId, request); - } - - protected get removeUsersWarning() { - return this.i18nService.t("removeUsersWarning"); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 8a04cb6452d..61145efb783 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -27,7 +27,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html", + "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { providerId: string; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 16e64703700..b5d5274498c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -17,7 +17,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: - "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html", + "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { providerId: string; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html deleted file mode 100644 index 36bc6543696..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.html +++ /dev/null @@ -1,203 +0,0 @@ - - - - - -
- - - {{ "all" | i18n }} - {{ allCount }} - - - - {{ "invited" | i18n }} - {{ invitedCount }} - - - - {{ "needsConfirmation" | i18n }} - {{ acceptedCount }} - - - - -
- - - - {{ "loading" | i18n }} - - -

{{ "noUsersInList" | i18n }}

- - - {{ "providerUsersNeedConfirmed" | i18n }} - - - - - - - - - - - - -
- - - - - {{ u.email }} - {{ - "invited" | i18n - }} - {{ - "needsConfirmation" | i18n - }} - {{ u.name }} - - - - {{ "userUsingTwoStep" | i18n }} - - - {{ "providerAdmin" | i18n }} - {{ "serviceUser" | i18n }} - - -
-
-
- - - - - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts deleted file mode 100644 index 9293f8c6eb7..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { lastValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; - -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; -import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; -import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; -import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; -import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; -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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { BasePeopleComponent } from "@bitwarden/web-vault/app/admin-console/common/base.people.component"; -import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; -import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; - -import { BulkConfirmComponent } from "./bulk/bulk-confirm.component"; -import { BulkRemoveComponent } from "./bulk/bulk-remove.component"; -import { UserAddEditComponent } from "./user-add-edit.component"; - -/** - * @deprecated Please use the {@link MembersComponent} instead. - */ -@Component({ - selector: "provider-people", - templateUrl: "people.component.html", -}) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class PeopleComponent - extends BasePeopleComponent - implements OnInit -{ - @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; - @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) - groupsModalRef: ViewContainerRef; - @ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true }) - bulkStatusModalRef: ViewContainerRef; - @ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true }) - bulkConfirmModalRef: ViewContainerRef; - @ViewChild("bulkRemoveTemplate", { read: ViewContainerRef, static: true }) - bulkRemoveModalRef: ViewContainerRef; - - userType = ProviderUserType; - userStatusType = ProviderUserStatusType; - status: ProviderUserStatusType = null; - providerId: string; - accessEvents = false; - - constructor( - apiService: ApiService, - private route: ActivatedRoute, - i18nService: I18nService, - modalService: ModalService, - platformUtilsService: PlatformUtilsService, - cryptoService: CryptoService, - private encryptService: EncryptService, - private router: Router, - searchService: SearchService, - validationService: ValidationService, - logService: LogService, - searchPipe: SearchPipe, - userNamePipe: UserNamePipe, - private providerService: ProviderService, - dialogService: DialogService, - organizationManagementPreferencesService: OrganizationManagementPreferencesService, - private configService: ConfigService, - protected toastService: ToastService, - ) { - super( - apiService, - searchService, - i18nService, - platformUtilsService, - cryptoService, - validationService, - modalService, - logService, - searchPipe, - userNamePipe, - dialogService, - organizationManagementPreferencesService, - toastService, - ); - } - - ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; - const provider = await this.providerService.get(this.providerId); - - if (!provider.canManageUsers) { - // 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(["../"], { relativeTo: this.route }); - return; - } - - this.accessEvents = provider.useEvents; - - await this.load(); - - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchControl.setValue(qParams.search); - if (qParams.viewEvents != null) { - const user = this.users.filter((u) => u.id === qParams.viewEvents); - if (user.length > 0 && user[0].status === ProviderUserStatusType.Confirmed) { - // 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.events(user[0]); - } - } - }); - }); - } - - getUsers(): Promise> { - return this.apiService.getProviderUsers(this.providerId); - } - - deleteUser(id: string): Promise { - return this.apiService.deleteProviderUser(this.providerId, id); - } - - revokeUser(id: string): Promise { - // Not implemented. - return null; - } - - restoreUser(id: string): Promise { - // Not implemented. - return null; - } - - reinviteUser(id: string): Promise { - return this.apiService.postProviderUserReinvite(this.providerId, id); - } - - async confirmUser(user: ProviderUserUserDetailsResponse, publicKey: Uint8Array): Promise { - const providerKey = await this.cryptoService.getProviderKey(this.providerId); - const key = await this.encryptService.rsaEncrypt(providerKey.key, publicKey); - const request = new ProviderUserConfirmRequest(); - request.key = key.encryptedString; - await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); - } - - async edit(user: ProviderUserUserDetailsResponse) { - const [modal] = await this.modalService.openViewRef( - UserAddEditComponent, - this.addEditModalRef, - (comp) => { - comp.name = this.userNamePipe.transform(user); - comp.providerId = this.providerId; - comp.providerUserId = user != null ? user.id : null; - comp.savedUser.subscribe(() => { - modal.close(); - this.load(); - }); - comp.deletedUser.subscribe(() => { - modal.close(); - this.removeUser(user); - }); - }, - ); - } - - async events(user: ProviderUserUserDetailsResponse) { - await openEntityEventsDialog(this.dialogService, { - data: { - name: this.userNamePipe.transform(user), - providerId: this.providerId, - entityId: user.id, - showUser: false, - entity: "user", - }, - }); - } - - async bulkRemove() { - if (this.actionPromise != null) { - return; - } - - const [modal] = await this.modalService.openViewRef( - BulkRemoveComponent, - this.bulkRemoveModalRef, - (comp) => { - comp.providerId = this.providerId; - comp.users = this.getCheckedUsers(); - }, - ); - - await modal.onClosedPromise(); - await this.load(); - } - - async bulkReinvite() { - if (this.actionPromise != null) { - return; - } - - const users = this.getCheckedUsers(); - const filteredUsers = users.filter((u) => u.status === ProviderUserStatusType.Invited); - - if (filteredUsers.length <= 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noSelectedUsersApplicable"), - }); - return; - } - - try { - const request = new ProviderUserBulkRequest(filteredUsers.map((user) => user.id)); - const response = this.apiService.postManyProviderUserReinvite(this.providerId, request); - // 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 - - // Bulk Status component open - const dialogRef = BulkStatusComponent.open(this.dialogService, { - data: { - users: users, - filteredUsers: filteredUsers, - request: response, - successfulMessage: this.i18nService.t("bulkReinviteMessage"), - }, - }); - await lastValueFrom(dialogRef.closed); - } catch (e) { - this.validationService.showError(e); - } - this.actionPromise = null; - } - - async bulkConfirm() { - if (this.actionPromise != null) { - return; - } - - const [modal] = await this.modalService.openViewRef( - BulkConfirmComponent, - this.bulkConfirmModalRef, - (comp) => { - comp.providerId = this.providerId; - comp.users = this.getCheckedUsers(); - }, - ); - - await modal.onClosedPromise(); - await this.load(); - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index dd2b7210aa5..94c94b728ef 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -13,7 +13,7 @@ route="manage" *ngIf="showManageTab(provider)" > - + provider.canManageUsers), - ], - data: { - titleId: "people", - }, + { + path: "members", + component: MembersComponent, + canActivate: [ + providerPermissionsGuard((provider: Provider) => provider.canManageUsers), + ], + data: { + titleId: "members", }, - }), + }, { path: "events", component: EventsComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 8ed10a2d6e3..b6c7125c48c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -24,14 +24,11 @@ import { AddOrganizationComponent } from "./clients/add-organization.component"; import { ClientsComponent } from "./clients/clients.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; import { AcceptProviderComponent } from "./manage/accept-provider.component"; -import { BulkConfirmComponent } from "./manage/bulk/bulk-confirm.component"; -import { BulkRemoveComponent } from "./manage/bulk/bulk-remove.component"; import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component"; import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog.component"; import { BulkRemoveDialogComponent } from "./manage/dialogs/bulk-remove-dialog.component"; import { EventsComponent } from "./manage/events.component"; import { MembersComponent } from "./manage/members.component"; -import { PeopleComponent } from "./manage/people.component"; import { UserAddEditComponent } from "./manage/user-add-edit.component"; import { ProvidersLayoutComponent } from "./providers-layout.component"; import { ProvidersRoutingModule } from "./providers-routing.module"; @@ -58,14 +55,11 @@ import { SetupComponent } from "./setup/setup.component"; AcceptProviderComponent, AccountComponent, AddOrganizationComponent, - BulkConfirmComponent, BulkConfirmDialogComponent, - BulkRemoveComponent, BulkRemoveDialogComponent, ClientsComponent, CreateOrganizationComponent, EventsComponent, - PeopleComponent, MembersComponent, SetupComponent, SetupProviderComponent, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6af0fe2f660..e8d29bd69ba 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -242,6 +242,10 @@ import { } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -1340,6 +1344,11 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, ], }), + safeProvider({ + provide: CipherAuthorizationService, + useClass: DefaultCipherAuthorizationService, + deps: [CollectionService, OrganizationServiceAbstraction], + }), ]; @NgModule({ diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 49129a868be..44eaec03a68 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -23,7 +23,7 @@ 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 { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CollectionId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; @@ -36,6 +36,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -47,6 +48,7 @@ export class AddEditComponent implements OnInit, OnDestroy { @Input() type: CipherType; @Input() collectionIds: string[]; @Input() organizationId: string = null; + @Input() collectionId: string = null; @Output() onSavedCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); @@ -57,6 +59,8 @@ export class AddEditComponent implements OnInit, OnDestroy { @Output() onGeneratePassword = new EventEmitter(); @Output() onGenerateUsername = new EventEmitter(); + canDeleteCipher$: Observable; + editMode = false; cipher: CipherView; folders$: Observable; @@ -83,6 +87,10 @@ export class AddEditComponent implements OnInit, OnDestroy { reprompt = false; canUseReprompt = true; organization: Organization; + /** + * Flag to determine if the action is being performed from the admin console. + */ + isAdminConsoleAction: boolean = false; protected componentName = ""; protected destroy$ = new Subject(); @@ -118,6 +126,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected win: Window, protected datePipe: DatePipe, protected configService: ConfigService, + protected cipherAuthorizationService: CipherAuthorizationService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -314,6 +323,12 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.reprompt) { this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.collectionId as CollectionId], + this.isAdminConsoleAction, + ); } async submit(): Promise { diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index ac644acf9e4..4c96c10dac3 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -9,7 +9,7 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -28,6 +28,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -37,6 +38,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -45,12 +47,14 @@ const BroadcasterSubscriptionId = "ViewComponent"; @Directive() export class ViewComponent implements OnDestroy, OnInit { @Input() cipherId: string; + @Input() collectionId: string; @Output() onEditCipher = new EventEmitter(); @Output() onCloneCipher = new EventEmitter(); @Output() onShareCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); + canDeleteCipher$: Observable; cipher: CipherView; showPassword: boolean; showPasswordCount: boolean; @@ -105,6 +109,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected datePipe: DatePipe, protected accountService: AccountService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} ngOnInit() { @@ -144,6 +149,9 @@ export class ViewComponent implements OnDestroy, OnInit { ); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.collectionId as CollectionId, + ]); if (this.cipher.folderId) { this.folder = await ( diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 27446335740..f805da0700a 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -14,17 +14,17 @@ export interface AnonLayoutWrapperData { * If a string is provided, it will be presented as is (ex: Organization name) * If a Translation object (supports placeholders) is provided, it will be translated */ - pageTitle?: string | Translation; + pageTitle?: string | Translation | null; /** * The optional subtitle of the page. * If a string is provided, it will be presented as is (ex: user's email) * If a Translation object (supports placeholders) is provided, it will be translated */ - pageSubtitle?: string | Translation; + pageSubtitle?: string | Translation | null; /** * The optional icon to display on the page. */ - pageIcon?: Icon; + pageIcon?: Icon | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -114,19 +114,23 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { return; } - if (data.pageTitle) { - this.pageTitle = this.handleStringOrTranslation(data.pageTitle); + // Null emissions are used to reset the page data as all fields are optional. + + if (data.pageTitle !== undefined) { + this.pageTitle = + data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null; } - if (data.pageSubtitle) { - this.pageSubtitle = this.handleStringOrTranslation(data.pageSubtitle); + if (data.pageSubtitle !== undefined) { + this.pageSubtitle = + data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null; } - if (data.pageIcon) { - this.pageIcon = data.pageIcon; + if (data.pageIcon !== undefined) { + this.pageIcon = data.pageIcon !== null ? data.pageIcon : null; } - if (data.showReadonlyHostname != null) { + if (data.showReadonlyHostname !== undefined) { this.showReadonlyHostname = data.showReadonlyHostname; } diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index 33d7907fa88..4831d290698 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,10 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { + isEligibleForSelfHost: boolean; isOnSecretsManagerStandalone: boolean; constructor(response: any) { super(response); + this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index fd3833d10e3..905d7299489 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -20,7 +20,7 @@ export enum FeatureFlag { EnableTimeThreshold = "PM-5864-dollar-threshold", InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", - AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page", + VaultBulkManagementAction = "vault-bulk-management-action", IdpAutoSubmitLogin = "idp-auto-submit-login", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", @@ -67,7 +67,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableTimeThreshold]: FALSE, [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, - [FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE, + [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.IdpAutoSubmitLogin]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts new file mode 100644 index 00000000000..3155825d4d0 --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts @@ -0,0 +1,200 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { CipherView } from "../models/view/cipher.view"; + +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "./cipher-authorization.service"; + +describe("CipherAuthorizationService", () => { + let cipherAuthorizationService: CipherAuthorizationService; + + const mockCollectionService = mock(); + const mockOrganizationService = mock(); + + // Mock factories + const createMockCipher = ( + organizationId: string | null, + collectionIds: string[], + edit: boolean = true, + ) => ({ + organizationId, + collectionIds, + edit, + }); + + const createMockCollection = (id: string, manage: boolean) => ({ + id, + manage, + }); + + const createMockOrganization = ({ + allowAdminAccessToAllCollectionItems = false, + canEditAllCiphers = false, + canEditUnassignedCiphers = false, + } = {}) => ({ + allowAdminAccessToAllCollectionItems, + canEditAllCiphers, + canEditUnassignedCiphers, + }); + + beforeEach(() => { + jest.clearAllMocks(); + cipherAuthorizationService = new DefaultCipherAuthorizationService( + mockCollectionService, + mockOrganizationService, + ); + }); + + describe("canDeleteCipher$", () => { + it("should return true if cipher has no organizationId", (done) => { + const cipher = createMockCipher(null, []) as CipherView; + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => { + const cipher = createMockCipher("org1", ["col1"]) as CipherView; + const organization = createMockOrganization({ canEditAllCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + expect(mockOrganizationService.get$).toHaveBeenCalledWith("org1"); + done(); + }); + }); + + it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: false }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return true if activeCollectionId is provided and has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", true), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if activeCollectionId is provided and manage permission is not present", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return true if any collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2", "col3"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + createMockCollection("col3", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + "col3", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if no collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts new file mode 100644 index 00000000000..00c7c412d61 --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -0,0 +1,86 @@ +import { map, Observable, of, switchMap } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { Cipher } from "../models/domain/cipher"; +import { CipherView } from "../models/view/cipher.view"; + +/** + * Represents either a cipher or a cipher view. + */ +type CipherLike = Cipher | CipherView; + +/** + * Service for managing user cipher authorization. + */ +export abstract class CipherAuthorizationService { + /** + * Determines if the user can delete the specified cipher. + * + * @param {CipherLike} cipher - The cipher object to evaluate for deletion permissions. + * @param {CollectionId[]} [allowedCollections] - Optional. The selected collection id from the vault filter. + * @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console. + * + * @returns {Observable} - An observable that emits a boolean value indicating if the user can delete the cipher. + */ + canDeleteCipher$: ( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ) => Observable; +} + +/** + * {@link CipherAuthorizationService} + */ +export class DefaultCipherAuthorizationService implements CipherAuthorizationService { + constructor( + private collectionService: CollectionService, + private organizationService: OrganizationService, + ) {} + + /** + * + * {@link CipherAuthorizationService.canDeleteCipher$} + */ + canDeleteCipher$( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ): Observable { + if (cipher.organizationId == null) { + return of(true); + } + + return this.organizationService.get$(cipher.organizationId).pipe( + switchMap((organization) => { + if (isAdminConsoleAction) { + // If the user is an admin, they can delete an unassigned cipher + if (!cipher.collectionIds || cipher.collectionIds.length === 0) { + return of(organization?.canEditUnassignedCiphers === true); + } + + if (organization?.canEditAllCiphers) { + return of(true); + } + } + + return this.collectionService + .decryptedCollectionViews$(cipher.collectionIds as CollectionId[]) + .pipe( + map((allCollections) => { + const shouldFilter = allowedCollections?.some(Boolean); + + const collections = shouldFilter + ? allCollections.filter((c) => allowedCollections.includes(c.id as CollectionId)) + : allCollections; + + return collections.some((collection) => collection.manage); + }), + ); + }), + ); + } +} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index 265016ad1b1..98da24b5188 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -12,33 +12,44 @@ > - {{ "password" | i18n }} - {{ "newPassword" | i18n }} + {{ "password" | i18n }} + + + + + - - {{ "sendPasswordDescV3" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index c8122dd4315..48ab78465c1 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -7,14 +7,20 @@ import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + AsyncActionsModule, + ButtonModule, CardComponent, CheckboxModule, + DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, + ToastService, TypographyModule, } from "@bitwarden/components"; import { CredentialGeneratorService, Generators } from "@bitwarden/generator-core"; @@ -27,6 +33,8 @@ import { SendFormContainer } from "../../send-form-container"; templateUrl: "./send-options.component.html", standalone: true, imports: [ + AsyncActionsModule, + ButtonModule, CardComponent, CheckboxModule, CommonModule, @@ -53,7 +61,7 @@ export class SendOptionsComponent implements OnInit { hideEmail: [false as boolean], }); - get shouldShowNewPassword(): boolean { + get hasPassword(): boolean { return this.originalSendView && this.originalSendView.password !== null; } @@ -71,8 +79,12 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, + private dialogService: DialogService, + private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, + private i18nService: I18nService, + private toastService: ToastService, private generatorService: CredentialGeneratorService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -110,16 +122,49 @@ export class SendOptionsComponent implements OnInit { }); }; + removePassword = async () => { + if (!this.originalSendView || !this.originalSendView.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendOptionsForm.patchValue({ + password: null, + }); + this.sendOptionsForm.get("password")?.enable(); + }; + ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: null, + password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } + if (this.hasPassword) { + this.sendOptionsForm.get("password")?.disable(); + } + if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index d4c253303bd..93db4df3187 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + =16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/array-each": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", @@ -20884,47 +20870,6 @@ "object.assign": "^4.1.0" } }, - "node_modules/gulp-filter": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/gulp-filter/-/gulp-filter-9.0.1.tgz", - "integrity": "sha512-knVYL8h9bfYIeft3VokVTkuaWJkQJMrFCS3yVjZQC6BGg+1dZFoeUY++B9D2X4eFpeNTx9StWK0qnDby3NO3PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "multimatch": "^7.0.0", - "plugin-error": "^2.0.1", - "slash": "^5.1.0", - "streamfilter": "^3.0.0", - "to-absolute-glob": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - }, - "peerDependencies": { - "gulp": ">=4" - }, - "peerDependenciesMeta": { - "gulp": { - "optional": true - } - } - }, - "node_modules/gulp-filter/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gulp-if": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/gulp-if/-/gulp-if-3.0.0.tgz", @@ -28422,37 +28367,6 @@ "multicast-dns": "cli.js" } }, - "node_modules/multimatch": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-7.0.0.tgz", - "integrity": "sha512-SYU3HBAdF4psHEL/+jXDKHO95/m5P2RvboHT2Y0WtTttvJLP4H/2WS9WlQPFvF6C8d6SpLw8vjCnQOnVIVOSJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-differ": "^4.0.0", - "array-union": "^3.0.1", - "minimatch": "^9.0.3" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/multimatch/node_modules/array-union": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-3.0.1.tgz", - "integrity": "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/multistream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", @@ -35367,19 +35281,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamfilter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/streamfilter/-/streamfilter-3.0.0.tgz", - "integrity": "sha512-kvKNfXCmUyC8lAXSSHCIXBUlo/lhsLcCU/OmzACZYpRUdtKIH68xYhm/+HI15jFJYtNJGYtCgn2wmIiExY1VwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^3.0.6" - }, - "engines": { - "node": ">=8.12.0" - } - }, "node_modules/streaming-json-stringify": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/streaming-json-stringify/-/streaming-json-stringify-3.1.0.tgz", @@ -36573,20 +36474,6 @@ "license": "BSD-3-Clause", "peer": true }, - "node_modules/to-absolute-glob": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-3.0.0.tgz", - "integrity": "sha512-loO/XEWTRqpfcpI7+Jr2RR2Umaaozx1t6OSVWtMi0oy5F/Fxg3IC+D/TToDnxyAGs7uZBGT/6XmyDUxgsObJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-absolute": "^1.0.0", - "is-negated-glob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/package.json b/package.json index 38440adf92f..75e91f64936 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "storybook": "ng run components:storybook", "build-storybook": "ng run components:build-storybook", "build-storybook:ci": "ng run components:build-storybook --webpack-stats-json", - "postinstall": "patch-package" + "postinstall": "patch-package && rimraf ./node_modules/@types/glob && rimraf ./node_modules/@types/minimatch" }, "workspaces": [ "apps/*", @@ -75,7 +75,7 @@ "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/node-ipc": "9.2.3", - "@types/papaparse": "5.3.14", + "@types/papaparse": "5.3.15", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", @@ -107,7 +107,6 @@ "eslint-plugin-storybook": "0.8.0", "eslint-plugin-tailwindcss": "3.17.4", "gulp": "4.0.2", - "gulp-filter": "9.0.1", "gulp-if": "3.0.0", "gulp-json-editor": "2.6.0", "gulp-replace": "1.1.4", @@ -212,11 +211,7 @@ "@storybook/angular": { "zone.js": "$zone.js" }, - "replacestream": "4.0.3", - "@types/minimatch": "3.0.5", - "@electron/asar": { - "@types/glob": "7.1.3" - } + "replacestream": "4.0.3" }, "lint-staged": { "*": "prettier --cache --ignore-unknown --write",