diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 555e067d6db..cee2040285d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -97,12 +97,15 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev .github/workflows/scan.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev .github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev +# ESLint custom rules +libs/eslint @bitwarden/team-platform-dev ## Autofill team files ## apps/browser/src/autofill @bitwarden/team-autofill-dev apps/desktop/src/autofill @bitwarden/team-autofill-dev libs/common/src/autofill @bitwarden/team-autofill-dev apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev +apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev @@ -128,6 +131,7 @@ apps/web/src/app/key-management @bitwarden/team-key-management-dev apps/browser/src/key-management @bitwarden/team-key-management-dev apps/cli/src/key-management @bitwarden/team-key-management-dev libs/key-management @bitwarden/team-key-management-dev +libs/key-management-ui @bitwarden/team-key-management-dev libs/common/src/key-management @bitwarden/team-key-management-dev apps/desktop/destkop_native/core/src/biometric/ @bitwarden/team-key-management-dev diff --git a/.github/renovate.json b/.github/renovate.json index 8e2ef2cf80c..f1efcbaffbe 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -211,6 +211,8 @@ "@storybook/angular", "@storybook/manager-api", "@storybook/theming", + "@typescript-eslint/utils", + "@typescript-eslint/rule-tester", "@types/react", "autoprefixer", "bootstrap", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b297e9344f..8c214b99ed3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,7 +88,7 @@ jobs: uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 - name: Upload results to codecov.io - uses: codecov/test-results-action@9739113ad922ea0a9abb4b2c0f8bf6a4aa8ef820 # v1.0.1 + uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2 if: ${{ needs.check-test-secrets.outputs.available == 'true' }} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -103,15 +103,15 @@ jobs: matrix: os: - ubuntu-22.04 - - macos-latest - - windows-latest + - macos-14 + - windows-2022 steps: - name: Check Rust version run: rustup --version - name: Install gnome-keyring - if: ${{ matrix.os=='ubuntu-latest' }} + if: ${{ matrix.os=='ubuntu-22.04' }} run: | sudo apt-get update sudo apt-get install -y gnome-keyring dbus-x11 @@ -124,7 +124,7 @@ jobs: run: cargo build - name: Test Ubuntu - if: ${{ matrix.os=='ubuntu-latest' }} + if: ${{ matrix.os=='ubuntu-22.04' }} working-directory: ./apps/desktop/desktop_native run: | eval "$(dbus-launch --sh-syntax)" @@ -135,11 +135,41 @@ jobs: cargo test -- --test-threads=1 - name: Test macOS - if: ${{ matrix.os=='macos-latest' }} + if: ${{ matrix.os=='macos-14' }} working-directory: ./apps/desktop/desktop_native run: cargo test -- --test-threads=1 - name: Test Windows - if: ${{ matrix.os=='windows-latest'}} + if: ${{ matrix.os=='windows-2022'}} working-directory: ./apps/desktop/desktop_native/core run: cargo test -- --test-threads=1 + + rust-coverage: + name: Rust Coverage + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install rust + uses: dtolnay/rust-toolchain@a54c7afa936fefeb4456b2dd8068152669aa8203 # stable + with: + toolchain: stable + components: llvm-tools + + - name: Cache cargo registry + uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5 + with: + workspaces: "apps/desktop/desktop_native -> target" + + - name: Install cargo-llvm-cov + run: cargo install cargo-llvm-cov --version 0.6.16 + + - name: Generate coverage + working-directory: ./apps/desktop/desktop_native + run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage + + - name: Upload to codecov.io + uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 + with: + files: ./apps/desktop/desktop_native/lcov.info diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 10efba9422f..8698315b57c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4155,15 +4155,6 @@ "itemName": { "message": "Item name" }, - "cannotRemoveViewOnlyCollections": { - "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", - "placeholders": { - "collections": { - "content": "$1", - "example": "Work, Personal" - } - } - }, "organizationIsDeactivated": { "message": "Organization is deactivated" }, @@ -4896,6 +4887,15 @@ "extraWide": { "message": "Extra wide" }, + "cannotRemoveViewOnlyCollections": { + "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "placeholders": { + "collections": { + "content": "$1", + "example": "Work, Personal" + } + } + }, "updateDesktopAppOrDisableFingerprintDialogTitle": { "message": "Please update your desktop application" }, diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index acf0dedde27..6757972e839 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -424,6 +424,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ } await this.setupSubmitListenerOnFormlessField(formFieldElement); + return; } /** @@ -439,15 +440,16 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ this.formElements.add(formElement); formElement.addEventListener(EVENTS.SUBMIT, this.handleFormFieldSubmitEvent); - const closesSubmitButton = await this.findSubmitButton(formElement); + const closestSubmitButton = await this.findSubmitButton(formElement); // If we cannot find a submit button within the form, check for a submit button outside the form. - if (!closesSubmitButton) { + if (!closestSubmitButton) { await this.setupSubmitListenerOnFormlessField(formFieldElement); return; } - this.setupSubmitButtonEventListeners(closesSubmitButton); + this.setupSubmitButtonEventListeners(closestSubmitButton); + return; } } @@ -459,9 +461,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private async setupSubmitListenerOnFormlessField(formFieldElement: FillableFormFieldElement) { if (formFieldElement && !this.fieldsWithSubmitElements.has(formFieldElement)) { - const closesSubmitButton = await this.findClosestFormlessSubmitButton(formFieldElement); - this.setupSubmitButtonEventListeners(closesSubmitButton); + const closestSubmitButton = await this.findClosestFormlessSubmitButton(formFieldElement); + + this.setupSubmitButtonEventListeners(closestSubmitButton); } + return; } /** diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 16b11b98866..378521cfc42 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -747,7 +747,7 @@ describe("AutofillService", () => { jest.spyOn(autofillService as any, "generateFillScript"); jest.spyOn(autofillService as any, "generateLoginFillScript"); jest.spyOn(logService, "info"); - jest.spyOn(cipherService, "updateLastUsedDate"); + jest.spyOn(chrome.runtime, "sendMessage"); jest.spyOn(eventCollectionService, "collect"); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -769,7 +769,10 @@ describe("AutofillService", () => { ); expect(autofillService["generateLoginFillScript"]).toHaveBeenCalled(); expect(logService.info).not.toHaveBeenCalled(); - expect(cipherService.updateLastUsedDate).toHaveBeenCalledWith(autofillOptions.cipher.id); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + cipherId: autofillOptions.cipher.id, + command: "updateLastUsedDate", + }); expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( autofillOptions.pageDetails[0].tab.id, { @@ -890,11 +893,11 @@ describe("AutofillService", () => { it("skips updating the cipher's last used date if the passed options indicate that we should skip the last used cipher", async () => { autofillOptions.skipLastUsed = true; - jest.spyOn(cipherService, "updateLastUsedDate"); + jest.spyOn(chrome.runtime, "sendMessage"); await autofillService.doAutoFill(autofillOptions); - expect(cipherService.updateLastUsedDate).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); }); it("returns early if the fillScript cannot be generated", async () => { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 6d0e9954ade..c35b19990cb 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -463,8 +463,13 @@ export default class AutofillService implements AutofillServiceInterface { fillScript.properties.delay_between_operations = 20; didAutofill = true; + if (!options.skipLastUsed) { - await this.cipherService.updateLastUsedDate(options.cipher.id); + // In order to prevent a UI update send message to background script to update last used date + await chrome.runtime.sendMessage({ + command: "updateLastUsedDate", + cipherId: options.cipher.id, + }); } // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 7471c298917..5f3bc4dfff9 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -317,6 +317,7 @@ describe("CollectAutofillContentService", () => { __form__0: { opid: "__form__0", htmlAction: formAction, + htmlClass: null, htmlName: formName, htmlID: formId, htmlMethod: formMethod, @@ -544,6 +545,7 @@ describe("CollectAutofillContentService", () => { __form__0: { opid: "__form__0", htmlAction: formAction1, + htmlClass: null, htmlName: formName1, htmlID: formId1, htmlMethod: formMethod1, @@ -551,6 +553,7 @@ describe("CollectAutofillContentService", () => { __form__1: { opid: "__form__1", htmlAction: formAction2, + htmlClass: null, htmlName: formName2, htmlID: formId2, htmlMethod: formMethod2, diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index b858af25fae..0f9c8993014 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -228,6 +228,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ opid: formElement.opid, htmlAction: this.getFormActionAttribute(formElement), htmlName: this.getPropertyOrAttribute(formElement, "name"), + htmlClass: this.getPropertyOrAttribute(formElement, "class"), htmlID: this.getPropertyOrAttribute(formElement, "id"), htmlMethod: this.getPropertyOrAttribute(formElement, "method"), }); @@ -982,8 +983,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ const queueLength = this.mutationsQueue.length; if (!this.domQueryService.pageContainsShadowDomElements()) { - // Checking if a page contains shadowDOM elements is a heavy operation and doesn't have to be done immediately, so we can call this within an idle moment on the event loop. - requestIdleCallbackPolyfill(this.checkPageContainsShadowDom, { timeout: 500 }); + this.checkPageContainsShadowDom(); } for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 43efd338c6e..dff40a3ab93 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -56,6 +56,7 @@ export class InlineMenuFieldQualificationService "neuer benutzer", "neues passwort", "neue e-mail", + "pwdcheck", ]; private updatePasswordFieldKeywords = [ "update password", diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b75847dbbfe..49a29ab8581 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -809,7 +809,7 @@ export default class MainBackground { this.apiService, ); - this.ssoLoginService = new SsoLoginService(this.stateProvider); + this.ssoLoginService = new SsoLoginService(this.stateProvider, this.logService); this.userVerificationApiService = new UserVerificationApiService(this.apiService); @@ -1142,6 +1142,7 @@ export default class MainBackground { this.accountService, lockService, this.billingAccountProfileStateService, + this.cipherService, ); this.nativeMessagingBackground = new NativeMessagingBackground( this.keyService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2a756293070..b9622e82005 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -16,6 +16,7 @@ import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/m import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { BiometricsCommands } from "@bitwarden/key-management"; @@ -53,6 +54,7 @@ export default class RuntimeBackground { private accountService: AccountService, private readonly lockService: LockService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private cipherService: CipherService, ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor chrome.runtime.onInstalled.addListener((details: any) => { @@ -200,6 +202,9 @@ export default class RuntimeBackground { case BiometricsCommands.GetBiometricsStatusForUser: { return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId); } + case "updateLastUsedDate": { + return await this.cipherService.updateLastUsedDate(msg.cipherId); + } case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": { return await this.configService.getFeatureFlag( FeatureFlag.UseTreeWalkerApiForPageDetailsCollection, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index eae8e2cc980..071873b40c9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -7,4 +7,5 @@ [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null" showAutofillButton [primaryActionAutofill]="clickItemsToAutofillVaultView" + [groupByType]="groupByType()" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index d2dd21be6d8..03d84120785 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, OnInit } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -48,6 +49,10 @@ export class AutofillVaultListItemsComponent implements OnInit { clickItemsToAutofillVaultView = false; + protected groupByType = toSignal( + this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)), + ); + /** * Observable that determines whether the empty autofill tip should be shown. * The tip is shown when there are no login ciphers to autofill, no filter is applied, and autofill is allowed in diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 4c7067df53a..6e6e30b359b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -27,7 +27,7 @@ - + {{ "clone" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 8634d680052..94b4c2b855b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -97,6 +97,9 @@ export class ItemMoreOptionsComponent implements OnInit { return this.cipher.edit; } + get canViewPassword() { + return this.cipher.viewPassword; + } /** * Determines if the cipher can be autofilled. */ diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index d57b1d2fe36..db3fff04bbb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -11,11 +11,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; +import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; -import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component"; export interface NewItemInitialValues { folderId?: string; @@ -72,6 +72,6 @@ export class NewItemDropdownV2Component implements OnInit { } openFolderDialog() { - this.dialogService.open(AddEditFolderDialogComponent); + AddEditFolderDialogComponent.open(this.dialogService); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts index 3b9dc9a1647..bcea2e76190 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts @@ -6,11 +6,12 @@ import { combineLatest, map, take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DisclosureTriggerForDirective, IconButtonModule } from "@bitwarden/components"; +import { + DisclosureComponent, + DisclosureTriggerForDirective, + IconButtonModule, +} from "@bitwarden/components"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { DisclosureComponent } from "../../../../../../../../libs/components/src/disclosure/disclosure.component"; import { runInsideAngular } from "../../../../../platform/browser/run-inside-angular.operator"; import { VaultPopupListFiltersService } from "../../../../../vault/popup/services/vault-popup-list-filters.service"; import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 1593c747f7d..2272d3fbd6c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,9 +1,13 @@ - + - - - - - - - - - - - - + + +

+ {{ group.subHeaderKey | i18n }} +

+
+ + + + + + + + + + + + + + + + + +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 725aaac4666..f95790cda5f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -9,11 +9,14 @@ import { EventEmitter, inject, Input, - OnInit, Output, Signal, signal, ViewChild, + computed, + OnInit, + ChangeDetectionStrategy, + input, } from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom, Observable, map } from "rxjs"; @@ -23,6 +26,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeModule, @@ -73,6 +77,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class VaultListItemsContainerComponent implements OnInit, AfterViewInit { private compactModeService = inject(CompactModeService); @@ -110,11 +115,51 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit { */ private viewCipherTimeout: number | null; + ciphers = input([]); + /** - * The list of ciphers to display. + * If true, we will group ciphers by type (Login, Card, Identity) + * within subheadings in a single container, converted to a WritableSignal. */ - @Input() - ciphers: PopupCipherView[] = []; + groupByType = input(false); + + /** + * Computed signal for a grouped list of ciphers with an optional header + */ + cipherGroups$ = computed< + { + subHeaderKey?: string | null; + ciphers: PopupCipherView[]; + }[] + >(() => { + const groups: { [key: string]: CipherView[] } = {}; + + this.ciphers().forEach((cipher) => { + let groupKey; + + if (this.groupByType()) { + switch (cipher.type) { + case CipherType.Card: + groupKey = "cards"; + break; + case CipherType.Identity: + groupKey = "identities"; + break; + } + } + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + + groups[groupKey].push(cipher); + }); + + return Object.keys(groups).map((key) => ({ + subHeaderKey: this.groupByType ? key : "", + ciphers: groups[key], + })); + }); /** * Title for the vault list item section. diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts index 67e069d388a..e20bb1f1bcd 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -11,10 +11,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PasswordHistoryViewComponent } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { PasswordHistoryViewComponent } from "../../../../../../../../libs/vault/src/components/password-history-view/password-history-view.component"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 43471e56e7b..6e017042711 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -69,13 +69,13 @@ { return hasSearchText || Object.values(filters).some((filter) => filter !== null); }), + shareReplay({ bufferSize: 1, refCount: true }), ); /** diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts index 3aab9f935e4..deddbd444fc 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts @@ -14,17 +14,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -import { BadgeModule, CheckboxModule, Option } from "@bitwarden/components"; +import { + BadgeModule, + CardComponent, + CheckboxModule, + FormFieldModule, + Option, + SelectModule, +} from "@bitwarden/components"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { CardComponent } from "../../../../../../libs/components/src/card/card.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FormFieldModule } from "../../../../../../libs/components/src/form-field/form-field.module"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { SelectModule } from "../../../../../../libs/components/src/select/select.module"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupCompactModeService } from "../../../platform/popup/layout/popup-compact-mode.service"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts index 9c202e26fef..6689f5a6c6d 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts @@ -14,10 +14,10 @@ import { UserId } from "@bitwarden/common/types/guid"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DialogService } from "@bitwarden/components"; +import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; -import { AddEditFolderDialogComponent } from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component"; import { FoldersV2Component } from "./folders-v2.component"; @@ -27,8 +27,8 @@ import { FoldersV2Component } from "./folders-v2.component"; template: ``, }) class MockPopupHeaderComponent { - @Input() pageTitle: string; - @Input() backAction: () => void; + @Input() pageTitle: string = ""; + @Input() backAction: () => void = () => {}; } @Component({ @@ -37,14 +37,15 @@ class MockPopupHeaderComponent { template: ``, }) class MockPopupFooterComponent { - @Input() pageTitle: string; + @Input() pageTitle: string = ""; } describe("FoldersV2Component", () => { let component: FoldersV2Component; let fixture: ComponentFixture; const folderViews$ = new BehaviorSubject([]); - const open = jest.fn(); + const open = jest.spyOn(AddEditFolderDialogComponent, "open"); + const mockDialogService = { open: jest.fn() }; beforeEach(async () => { open.mockClear(); @@ -68,7 +69,7 @@ describe("FoldersV2Component", () => { imports: [MockPopupHeaderComponent, MockPopupFooterComponent], }, }) - .overrideProvider(DialogService, { useValue: { open } }) + .overrideProvider(DialogService, { useValue: mockDialogService }) .compileComponents(); fixture = TestBed.createComponent(FoldersV2Component); @@ -101,9 +102,7 @@ describe("FoldersV2Component", () => { editButton.triggerEventHandler("click"); - expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { - data: { editFolderConfig: { folder } }, - }); + expect(open).toHaveBeenCalledWith(mockDialogService, { editFolderConfig: { folder } }); }); it("opens add dialog for new folder when there are no folders", () => { @@ -114,6 +113,6 @@ describe("FoldersV2Component", () => { addButton.triggerEventHandler("click"); - expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { data: {} }); + expect(open).toHaveBeenCalledWith(mockDialogService, {}); }); }); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.ts index 8abc3f906c0..f71374e5305 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.ts @@ -12,25 +12,14 @@ import { ButtonModule, DialogService, IconButtonModule, + ItemModule, + NoItemsModule, } from "@bitwarden/components"; -import { VaultIcons } from "@bitwarden/vault"; +import { AddEditFolderDialogComponent, VaultIcons } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ItemGroupComponent } from "../../../../../../libs/components/src/item/item-group.component"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ItemModule } from "../../../../../../libs/components/src/item/item.module"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { NoItemsModule } from "../../../../../../libs/components/src/no-items/no-items.module"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { - AddEditFolderDialogComponent, - AddEditFolderDialogData, -} from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component"; @Component({ standalone: true, @@ -42,7 +31,6 @@ import { PopupPageComponent, PopupHeaderComponent, ItemModule, - ItemGroupComponent, NoItemsModule, IconButtonModule, ButtonModule, @@ -78,8 +66,6 @@ export class FoldersV2Component { // If a folder is provided, the edit variant should be shown const editFolderConfig = folder ? { folder } : undefined; - this.dialogService.open(AddEditFolderDialogComponent, { - data: { editFolderConfig }, - }); + AddEditFolderDialogComponent.open(this.dialogService, { editFolderConfig }); } } diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 13152f3d29e..6a7506a450d 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -123,6 +123,9 @@ export class EditCommand { "Item does not belong to an organization. Consider moving it first.", ); } + if (!cipher.viewPassword) { + return Response.noEditPermission(); + } cipher.collectionIds = req; try { diff --git a/apps/cli/src/models/response.ts b/apps/cli/src/models/response.ts index 76d9509226d..ac0977182f4 100644 --- a/apps/cli/src/models/response.ts +++ b/apps/cli/src/models/response.ts @@ -39,6 +39,10 @@ export class Response { return Response.error("Not found."); } + static noEditPermission(): Response { + return Response.error("You do not have permission to edit this item"); + } + static badRequest(message: string): Response { return Response.error(message); } diff --git a/apps/desktop/desktop_native/.gitignore b/apps/desktop/desktop_native/.gitignore index 1cfa7dafc20..a0a01b28f50 100644 --- a/apps/desktop/desktop_native/.gitignore +++ b/apps/desktop/desktop_native/.gitignore @@ -5,3 +5,4 @@ index.node npm-debug.log* *.node dist +windows_pluginauthenticator_bindings.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 95fbd0850b1..1e1af53d531 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -410,6 +410,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "2.8.0" @@ -553,6 +573,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -593,6 +622,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.27" @@ -1458,6 +1498,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -2259,6 +2308,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483f8c21f64f3ea09fe0f30f5d48c3e8eefe5dac9129f0075f76593b4c1da705" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.2.0" @@ -2437,6 +2496,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3447,6 +3512,13 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-plugin-authenticator" +version = "0.0.0" +dependencies = [ + "bindgen", +] + [[package]] name = "windows-registry" version = "0.4.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 8f966e2188e..78142d618ed 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["napi", "core", "proxy", "macos_provider"] +members = ["napi", "core", "proxy", "macos_provider", "windows-plugin-authenticator"] [workspace.dependencies] anyhow = "=1.0.94" diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml b/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml new file mode 100644 index 00000000000..b8759cfca3f --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "windows-plugin-authenticator" +version = "0.0.0" +edition = "2021" +license = "GPL-3.0" +publish = false + +[target.'cfg(target_os = "windows")'.build-dependencies] +bindgen = "0.71.1" diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/README.md b/apps/desktop/desktop_native/windows-plugin-authenticator/README.md new file mode 100644 index 00000000000..6dc72ceed46 --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/README.md @@ -0,0 +1,23 @@ +# windows-plugin-authenticator + +This is an internal crate that's meant to be a safe abstraction layer over the generated Rust bindings for the Windows WebAuthn Plugin Authenticator API's. + +You can find more information about the Windows WebAuthn API's [here](https://github.com/microsoft/webauthn). + +## Building + +To build this crate, set the following environment variables: + +- `LIBCLANG_PATH` -> the path to the `bin` directory of your LLVM install ([more info](https://rust-lang.github.io/rust-bindgen/requirements.html?highlight=libclang_path#installing-clang)) + +### Bash Example + +``` +export LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin' +``` + +### PowerShell Example + +``` +$env:LIBCLANG_PATH = 'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin' +``` diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/build.rs b/apps/desktop/desktop_native/windows-plugin-authenticator/build.rs new file mode 100644 index 00000000000..7bc311fb12d --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/build.rs @@ -0,0 +1,22 @@ +fn main() { + #[cfg(target_os = "windows")] + windows(); +} + +#[cfg(target_os = "windows")] +fn windows() { + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + + let bindings = bindgen::Builder::default() + .header("pluginauthenticator.hpp") + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) + .generate() + .expect("Unable to generate bindings."); + + bindings + .write_to_file(format!( + "{}\\windows_pluginauthenticator_bindings.rs", + out_dir + )) + .expect("Couldn't write bindings."); +} diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/pluginauthenticator.hpp b/apps/desktop/desktop_native/windows-plugin-authenticator/pluginauthenticator.hpp new file mode 100644 index 00000000000..c800266a3e6 --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/pluginauthenticator.hpp @@ -0,0 +1,231 @@ +/* + Bitwarden's pluginauthenticator.hpp + + Source: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h + + This is a C++ header file, so the extension has been manually + changed from `.h` to `.hpp`, so bindgen will automatically + generate the correct C++ bindings. + + More Info: https://rust-lang.github.io/rust-bindgen/cpp.html +*/ + +/* this ALWAYS GENERATED file contains the definitions for the interfaces */ + +/* File created by MIDL compiler version 8.01.0628 */ +/* @@MIDL_FILE_HEADING( ) */ + +/* verify that the version is high enough to compile this file*/ +#ifndef __REQUIRED_RPCNDR_H_VERSION__ +#define __REQUIRED_RPCNDR_H_VERSION__ 501 +#endif + +/* verify that the version is high enough to compile this file*/ +#ifndef __REQUIRED_RPCSAL_H_VERSION__ +#define __REQUIRED_RPCSAL_H_VERSION__ 100 +#endif + +#include "rpc.h" +#include "rpcndr.h" + +#ifndef __RPCNDR_H_VERSION__ +#error this stub requires an updated version of +#endif /* __RPCNDR_H_VERSION__ */ + +#ifndef COM_NO_WINDOWS_H +#include "windows.h" +#include "ole2.h" +#endif /*COM_NO_WINDOWS_H*/ + +#ifndef __pluginauthenticator_h__ +#define __pluginauthenticator_h__ + +#if defined(_MSC_VER) && (_MSC_VER >= 1020) +#pragma once +#endif + +#ifndef DECLSPEC_XFGVIRT +#if defined(_CONTROL_FLOW_GUARD_XFG) +#define DECLSPEC_XFGVIRT(base, func) __declspec(xfg_virtual(base, func)) +#else +#define DECLSPEC_XFGVIRT(base, func) +#endif +#endif + +/* Forward Declarations */ + +#ifndef __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ +#define __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ +typedef interface EXPERIMENTAL_IPluginAuthenticator EXPERIMENTAL_IPluginAuthenticator; + +#endif /* __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ */ + +/* header files for imported files */ +#include "oaidl.h" +#include "webauthn.h" + +#ifdef __cplusplus +extern "C"{ +#endif + +/* interface __MIDL_itf_pluginauthenticator_0000_0000 */ +/* [local] */ + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST + { + HWND hWnd; + GUID transactionId; + DWORD cbRequestSignature; + /* [size_is] */ byte *pbRequestSignature; + DWORD cbEncodedRequest; + /* [size_is] */ byte *pbEncodedRequest; + } EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST; + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_REQUEST; + +typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST; + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE + { + DWORD cbEncodedResponse; + /* [size_is] */ byte *pbEncodedResponse; + } EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE; + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE; + +typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE; + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST + { + GUID transactionId; + DWORD cbRequestSignature; + /* [size_is] */ byte *pbRequestSignature; + } EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST; + +typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST; + +typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST; + +extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_c_ifspec; +extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_s_ifspec; + +#ifndef __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ +#define __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ + +/* interface EXPERIMENTAL_IPluginAuthenticator */ +/* [unique][version][uuid][object] */ + +EXTERN_C const IID IID_EXPERIMENTAL_IPluginAuthenticator; + +#if defined(__cplusplus) && !defined(CINTERFACE) + + MIDL_INTERFACE("e6466e9a-b2f3-47c5-b88d-89bc14a8d998") + EXPERIMENTAL_IPluginAuthenticator : public IUnknown + { + public: + virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginMakeCredential( + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request, + /* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0; + + virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginGetAssertion( + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request, + /* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0; + + virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginCancelOperation( + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request) = 0; + + }; + +#else /* C style interface */ + + typedef struct EXPERIMENTAL_IPluginAuthenticatorVtbl + { + BEGIN_INTERFACE + + DECLSPEC_XFGVIRT(IUnknown, QueryInterface) + HRESULT ( STDMETHODCALLTYPE *QueryInterface )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This, + /* [in] */ __RPC__in REFIID riid, + /* [annotation][iid_is][out] */ + _COM_Outptr_ void **ppvObject); + + DECLSPEC_XFGVIRT(IUnknown, AddRef) + ULONG ( STDMETHODCALLTYPE *AddRef )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This); + + DECLSPEC_XFGVIRT(IUnknown, Release) + ULONG ( STDMETHODCALLTYPE *Release )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This); + + DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginMakeCredential) + HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginMakeCredential )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This, + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request, + /* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response); + + DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginGetAssertion) + HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginGetAssertion )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This, + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request, + /* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response); + + DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginCancelOperation) + HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginCancelOperation )( + __RPC__in EXPERIMENTAL_IPluginAuthenticator * This, + /* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request); + + END_INTERFACE + } EXPERIMENTAL_IPluginAuthenticatorVtbl; + + interface EXPERIMENTAL_IPluginAuthenticator + { + CONST_VTBL struct EXPERIMENTAL_IPluginAuthenticatorVtbl *lpVtbl; + }; + +#ifdef COBJMACROS + + +#define EXPERIMENTAL_IPluginAuthenticator_QueryInterface(This,riid,ppvObject) \ + ( (This)->lpVtbl -> QueryInterface(This,riid,ppvObject) ) + +#define EXPERIMENTAL_IPluginAuthenticator_AddRef(This) \ + ( (This)->lpVtbl -> AddRef(This) ) + +#define EXPERIMENTAL_IPluginAuthenticator_Release(This) \ + ( (This)->lpVtbl -> Release(This) ) + + +#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginMakeCredential(This,request,response) \ + ( (This)->lpVtbl -> EXPERIMENTAL_PluginMakeCredential(This,request,response) ) + +#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginGetAssertion(This,request,response) \ + ( (This)->lpVtbl -> EXPERIMENTAL_PluginGetAssertion(This,request,response) ) + +#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginCancelOperation(This,request) \ + ( (This)->lpVtbl -> EXPERIMENTAL_PluginCancelOperation(This,request) ) + +#endif /* COBJMACROS */ + +#endif /* C style interface */ + +#endif /* __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ */ + +/* Additional Prototypes for ALL interfaces */ + +unsigned long __RPC_USER HWND_UserSize( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * ); +unsigned char * __RPC_USER HWND_UserMarshal( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * ); +unsigned char * __RPC_USER HWND_UserUnmarshal(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * ); +void __RPC_USER HWND_UserFree( __RPC__in unsigned long *, __RPC__in HWND * ); + +unsigned long __RPC_USER HWND_UserSize64( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * ); +unsigned char * __RPC_USER HWND_UserMarshal64( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * ); +unsigned char * __RPC_USER HWND_UserUnmarshal64(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * ); +void __RPC_USER HWND_UserFree64( __RPC__in unsigned long *, __RPC__in HWND * ); + +/* end of Additional Prototypes */ + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/src/lib.rs b/apps/desktop/desktop_native/windows-plugin-authenticator/src/lib.rs new file mode 100644 index 00000000000..e226000e6fa --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/src/lib.rs @@ -0,0 +1,11 @@ +#![cfg(target_os = "windows")] + +mod pa; + +pub fn get_version_number() -> u64 { + unsafe { pa::WebAuthNGetApiVersionNumber() }.into() +} + +pub fn add_authenticator() { + unimplemented!(); +} diff --git a/apps/desktop/desktop_native/windows-plugin-authenticator/src/pa.rs b/apps/desktop/desktop_native/windows-plugin-authenticator/src/pa.rs new file mode 100644 index 00000000000..3da5a77a243 --- /dev/null +++ b/apps/desktop/desktop_native/windows-plugin-authenticator/src/pa.rs @@ -0,0 +1,15 @@ +/* + The 'pa' (plugin authenticator) module will contain the generated + bindgen code. + + The attributes below will suppress warnings from the generated code. +*/ + +#![cfg(target_os = "windows")] +#![allow(clippy::all)] +#![allow(warnings)] + +include!(concat!( + env!("OUT_DIR"), + "/windows_pluginauthenticator_bindings.rs" +)); diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 0450111bebd..bb06ae10431 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -16,6 +16,8 @@ com.apple.security.files.user-selected.read-write + com.apple.security.device.usb + - -
-
-
- - {{ "emailAddress" | i18n }} - - {{ "emailAddressDesc" | i18n }} - -
- -
- - {{ "name" | i18n }} - - {{ "yourNameDesc" | i18n }} - -
- -
- - - - {{ "masterPass" | i18n }} - - - - {{ "important" | i18n }} - {{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }} - - - - -
- -
- - {{ "reTypeMasterPass" | i18n }} - - - -
- -
- - {{ "masterPassHintLabel" | i18n }} - - {{ "masterPassHintDesc" | i18n }} - -
- -
- -
-
- - {{ "checkForBreaches" | i18n }} -
-
- - - - {{ "acceptPolicies" | i18n }}
- {{ - "termsOfService" | i18n - }}, - {{ - "privacyPolicy" | i18n - }} -
-
- -
- - - - - - -
-

- {{ "alreadyHaveAccount" | i18n }} - {{ "logIn" | i18n }} -

- -
-
diff --git a/apps/web/src/app/auth/register-form/register-form.component.ts b/apps/web/src/app/auth/register-form/register-form.component.ts deleted file mode 100644 index e8c9f0291a5..00000000000 --- a/apps/web/src/app/auth/register-form/register-form.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnInit } from "@angular/core"; -import { UntypedFormBuilder } from "@angular/forms"; -import { Router } from "@angular/router"; - -import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component"; -import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; -import { RegisterRequest } from "@bitwarden/common/models/request/register.request"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.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 { KeyService } from "@bitwarden/key-management"; - -import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; - -@Component({ - selector: "app-register-form", - templateUrl: "./register-form.component.html", -}) -export class RegisterFormComponent extends BaseRegisterComponent implements OnInit { - @Input() queryParamEmail: string; - @Input() queryParamFromOrgInvite: boolean; - @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; - @Input() referenceDataValue: ReferenceEventRequest; - - showErrorSummary = false; - characterMinimumMessage: string; - - constructor( - formValidationErrorService: FormValidationErrorsService, - formBuilder: UntypedFormBuilder, - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - keyService: KeyService, - apiService: ApiService, - platformUtilsService: PlatformUtilsService, - private policyService: PolicyService, - environmentService: EnvironmentService, - logService: LogService, - auditService: AuditService, - dialogService: DialogService, - acceptOrgInviteService: AcceptOrganizationInviteService, - toastService: ToastService, - ) { - super( - formValidationErrorService, - formBuilder, - loginStrategyService, - router, - i18nService, - keyService, - apiService, - platformUtilsService, - environmentService, - logService, - auditService, - dialogService, - toastService, - ); - this.modifyRegisterRequest = async (request: RegisterRequest) => { - // Org invites are deep linked. Non-existent accounts are redirected to the register page. - // Org user id and token are included here only for validation and two factor purposes. - const orgInvite = await acceptOrgInviteService.getOrganizationInvite(); - if (orgInvite != null) { - request.organizationUserId = orgInvite.organizationUserId; - request.token = orgInvite.token; - } - // Invite is accepted after login (on deep link redirect). - }; - } - - async ngOnInit() { - await super.ngOnInit(); - this.referenceData = this.referenceDataValue; - if (this.queryParamEmail) { - this.formGroup.get("email")?.setValue(this.queryParamEmail); - } - - if (this.enforcedPolicyOptions != null && this.enforcedPolicyOptions.minLength > 0) { - this.characterMinimumMessage = ""; - } else { - this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength); - } - } - - async submit() { - if ( - this.enforcedPolicyOptions != null && - !this.policyService.evaluateMasterPassword( - this.passwordStrengthResult.score, - this.formGroup.value.masterPassword, - this.enforcedPolicyOptions, - ) - ) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"), - }); - return; - } - - await super.submit(false); - } -} diff --git a/apps/web/src/app/auth/register-form/register-form.module.ts b/apps/web/src/app/auth/register-form/register-form.module.ts deleted file mode 100644 index b63cb18506d..00000000000 --- a/apps/web/src/app/auth/register-form/register-form.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; - -import { SharedModule } from "../../shared"; - -import { RegisterFormComponent } from "./register-form.component"; - -@NgModule({ - imports: [SharedModule, PasswordCalloutComponent], - declarations: [RegisterFormComponent], - exports: [RegisterFormComponent], -}) -export class RegisterFormModule {} diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index b5471a90fd5..ca1b9245c0b 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -118,7 +118,13 @@ ) | currency: "$" }} - /{{ "monthPerMember" | i18n }} + + /{{ + selectableProduct.productTier === productTypes.Families + ? "month" + : ("monthPerMember" | i18n) + }} { + const translatedMessage = this.i18nService.t(error.message); this.toastService.showToast({ title: "", variant: "error", - message: this.i18nService.t(error.message), + message: + !translatedMessage || translatedMessage === "" ? error.message : translatedMessage, }); }); } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 2566250c823..0a4eea57f92 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -433,7 +433,11 @@

{{ paymentDesc }}

- + + + +

{{ "manageSubscription" | i18n }}

+

{{ resellerSeatsRemainingMessage }}

+

{{ "selfHostingTitleProper" | 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 003f816ac30..50c755af63b 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 @@ -4,13 +4,17 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { firstValueFrom, lastValueFrom, Observable, Subject } from "rxjs"; +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums"; +import { + OrganizationApiKeyType, + OrganizationUserStatusType, +} from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -61,12 +65,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy showSubscription = true; showSelfHost = false; organizationIsManagedByConsolidatedBillingMSP = false; + resellerSeatsRemainingMessage: string; protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon; protected readonly teamsStarter = ProductTierType.TeamsStarter; private destroy$ = new Subject(); + private seatsRemainingMessage: string; + constructor( private apiService: ApiService, private i18nService: I18nService, @@ -79,6 +86,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private configService: ConfigService, private toastService: ToastService, private billingApiService: BillingApiServiceAbstraction, + private organizationUserApiService: OrganizationUserApiService, ) {} async ngOnInit() { @@ -104,6 +112,28 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } } } + + if (this.userOrg.hasReseller) { + const allUsers = await this.organizationUserApiService.getAllUsers(this.userOrg.id); + + const userCount = allUsers.data.filter((user) => + [ + OrganizationUserStatusType.Invited, + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed, + ].includes(user.status), + ).length; + + const remainingSeats = this.userOrg.seats - userCount; + + const seatsRemaining = this.i18nService.t( + "seatsRemaining", + remainingSeats.toString(), + this.userOrg.seats.toString(), + ); + + this.resellerSeatsRemainingMessage = seatsRemaining; + } } ngOnDestroy() { diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html index 0b6e44d4eb6..dddac598a46 100644 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html +++ b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html @@ -1,17 +1,4 @@ - - - - - - - - - - -
-

{{ "createAccount" | i18n }}

-
- -
-
-
-
-
-
- Bitwarden - -
- - - - - - - - - - - - - - -
-
-
-
-
- -
-
-
-
-
-

- {{ freeTrialText }} -

- -
- - - - - - - - - - - - - - -
- - -
-
-
-
-
-
-
-
-
diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.component.spec.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.component.spec.ts deleted file mode 100644 index c8d4d35fb7d..00000000000 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.component.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { StepperSelectionEvent } from "@angular/cdk/stepper"; -import { TitleCasePipe } from "@angular/common"; -import { NO_ERRORS_SCHEMA } from "@angular/core"; -import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; -import { FormBuilder, UntypedFormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { RouterTestingModule } from "@angular/router/testing"; -import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; - -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PlanType } from "@bitwarden/common/billing/enums"; -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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; - -import { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service"; -import { OrganizationInvite } from "../../auth/organization-invite/organization-invite"; -import { RouterService } from "../../core"; -import { SharedModule } from "../../shared"; - -import { TrialInitiationComponent } from "./trial-initiation.component"; -import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; - -describe("TrialInitiationComponent", () => { - let component: TrialInitiationComponent; - let fixture: ComponentFixture; - const mockQueryParams = new BehaviorSubject({ org: "enterprise" }); - const testOrgId = "91329456-5b9f-44b3-9279-6bb9ee6a0974"; - const formBuilder: FormBuilder = new FormBuilder(); - let routerSpy: jest.SpyInstance; - - let stateServiceMock: MockProxy; - let policyApiServiceMock: MockProxy; - let policyServiceMock: MockProxy; - let routerServiceMock: MockProxy; - let acceptOrgInviteServiceMock: MockProxy; - let organizationBillingServiceMock: MockProxy; - let configServiceMock: MockProxy; - - beforeEach(() => { - // only define services directly that we want to mock return values in this component - stateServiceMock = mock(); - policyApiServiceMock = mock(); - policyServiceMock = mock(); - routerServiceMock = mock(); - acceptOrgInviteServiceMock = mock(); - organizationBillingServiceMock = mock(); - configServiceMock = mock(); - - // 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 - TestBed.configureTestingModule({ - imports: [ - SharedModule, - RouterTestingModule.withRoutes([ - { path: "trial", component: TrialInitiationComponent }, - { - path: `organizations/${testOrgId}/vault`, - component: BlankComponent, - }, - { - path: `organizations/${testOrgId}/members`, - component: BlankComponent, - }, - ]), - ], - declarations: [TrialInitiationComponent, I18nPipe], - providers: [ - UntypedFormBuilder, - { - provide: ActivatedRoute, - useValue: { - queryParams: mockQueryParams.asObservable(), - }, - }, - { provide: StateService, useValue: stateServiceMock }, - { provide: PolicyService, useValue: policyServiceMock }, - { provide: PolicyApiServiceAbstraction, useValue: policyApiServiceMock }, - { provide: LogService, useValue: mock() }, - { provide: I18nService, useValue: mock() }, - { provide: TitleCasePipe, useValue: mock() }, - { - provide: VerticalStepperComponent, - useClass: VerticalStepperStubComponent, - }, - { - provide: RouterService, - useValue: routerServiceMock, - }, - { - provide: AcceptOrganizationInviteService, - useValue: acceptOrgInviteServiceMock, - }, - { - provide: OrganizationBillingService, - useValue: organizationBillingServiceMock, - }, - { - provide: ConfigService, - useValue: configServiceMock, - }, - ], - schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - // These tests demonstrate mocking service calls - describe("onInit() enforcedPolicyOptions", () => { - it("should not set enforcedPolicyOptions if there isn't an org invite in deep linked url", async () => { - acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(null); - // Need to recreate component with new service mock - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - await component.ngOnInit(); - - expect(component.enforcedPolicyOptions).toBe(undefined); - }); - it("should set enforcedPolicyOptions if the deep linked url has an org invite", async () => { - // Set up service method mocks - acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({ - organizationId: testOrgId, - token: "token", - email: "testEmail", - organizationUserId: "123", - } as OrganizationInvite); - policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce( - Promise.resolve([ - { - id: "345", - organizationId: testOrgId, - type: 1, - data: { - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - }, - enabled: true, - }, - ] as Policy[]), - ); - policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue( - of({ - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - } as MasterPasswordPolicyOptions), - ); - - // Need to recreate component with new service mocks - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - await component.ngOnInit(); - expect(component.enforcedPolicyOptions).toMatchObject({ - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - }); - }); - }); - - // These tests demonstrate route params - describe("Route params", () => { - it("should set org variable to be enterprise and plan to EnterpriseAnnually if org param is enterprise", fakeAsync(() => { - mockQueryParams.next({ org: "enterprise" }); - tick(); // wait for resolution - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(component.org).toBe("enterprise"); - expect(component.plan).toBe(PlanType.EnterpriseAnnually); - })); - it("should not set org variable if no org param is provided", fakeAsync(() => { - mockQueryParams.next({}); - tick(); // wait for resolution - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(component.org).toBe(""); - expect(component.accountCreateOnly).toBe(true); - })); - it("should not set the org if org param is invalid ", fakeAsync(async () => { - mockQueryParams.next({ org: "hahahaha" }); - tick(); // wait for resolution - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(component.org).toBe(""); - expect(component.accountCreateOnly).toBe(true); - })); - it("should set the layout variable if layout param is valid ", fakeAsync(async () => { - mockQueryParams.next({ layout: "teams1" }); - tick(); // wait for resolution - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - expect(component.layout).toBe("teams1"); - expect(component.accountCreateOnly).toBe(false); - })); - it("should not set the layout variable and leave as 'default' if layout param is invalid ", fakeAsync(async () => { - mockQueryParams.next({ layout: "asdfasdf" }); - tick(); // wait for resolution - fixture = TestBed.createComponent(TrialInitiationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - // 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 - component.ngOnInit(); - expect(component.layout).toBe("default"); - expect(component.accountCreateOnly).toBe(true); - })); - }); - - // These tests demonstrate the use of a stub component - describe("createAccount()", () => { - beforeEach(() => { - component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent) - .componentInstance as VerticalStepperComponent; - }); - - it("should set email and call verticalStepper.next()", fakeAsync(() => { - const verticalStepperNext = jest.spyOn(component.verticalStepper, "next"); - component.createdAccount("test@email.com"); - expect(verticalStepperNext).toHaveBeenCalled(); - expect(component.email).toBe("test@email.com"); - })); - }); - - describe("billingSuccess()", () => { - beforeEach(() => { - component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent) - .componentInstance as VerticalStepperComponent; - }); - - it("should set orgId and call verticalStepper.next()", () => { - const verticalStepperNext = jest.spyOn(component.verticalStepper, "next"); - component.billingSuccess({ orgId: testOrgId }); - expect(verticalStepperNext).toHaveBeenCalled(); - expect(component.orgId).toBe(testOrgId); - }); - }); - - describe("stepSelectionChange()", () => { - beforeEach(() => { - component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent) - .componentInstance as VerticalStepperComponent; - }); - - it("on step 2 should show organization copy text", () => { - component.stepSelectionChange({ - selectedIndex: 1, - previouslySelectedIndex: 0, - } as StepperSelectionEvent); - - expect(component.orgInfoSubLabel).toContain("Enter your"); - expect(component.orgInfoSubLabel).toContain(" organization information"); - }); - it("going from step 2 to 3 should set the orgInforSubLabel to be the Org name from orgInfoFormGroup", () => { - component.orgInfoFormGroup = formBuilder.group({ - name: ["Hooli"], - email: [""], - }); - component.stepSelectionChange({ - selectedIndex: 2, - previouslySelectedIndex: 1, - } as StepperSelectionEvent); - - expect(component.orgInfoSubLabel).toContain("Hooli"); - }); - }); - - describe("previousStep()", () => { - beforeEach(() => { - component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent) - .componentInstance as VerticalStepperComponent; - }); - - it("should call verticalStepper.previous()", fakeAsync(() => { - const verticalStepperPrevious = jest.spyOn(component.verticalStepper, "previous"); - component.previousStep(); - expect(verticalStepperPrevious).toHaveBeenCalled(); - })); - }); - - // These tests demonstrate router navigation - describe("navigation methods", () => { - beforeEach(() => { - component.orgId = testOrgId; - const router = TestBed.inject(Router); - fixture.detectChanges(); - routerSpy = jest.spyOn(router, "navigate"); - }); - describe("navigateToOrgVault", () => { - it("should call verticalStepper.previous()", fakeAsync(() => { - component.navigateToOrgVault(); - expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "vault"]); - })); - }); - describe("navigateToOrgVault", () => { - it("should call verticalStepper.previous()", fakeAsync(() => { - component.navigateToOrgInvite(); - expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "members"]); - })); - }); - }); -}); - -export class VerticalStepperStubComponent extends VerticalStepperComponent {} -export class BlankComponent {} // For router tests diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.component.ts deleted file mode 100644 index 2403c28d267..00000000000 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.component.ts +++ /dev/null @@ -1,353 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { StepperSelectionEvent } from "@angular/cdk/stepper"; -import { TitleCasePipe } from "@angular/common"; -import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { UntypedFormBuilder, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; - -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { - OrganizationInformation, - PlanInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, -} from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; -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 { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service"; -import { OrganizationInvite } from "../../auth/organization-invite/organization-invite"; -import { - OrganizationCreatedEvent, - SubscriptionProduct, - TrialOrganizationType, -} from "../../billing/accounts/trial-initiation/trial-billing-step.component"; - -import { RouterService } from "./../../core/router.service"; -import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; - -export enum ValidOrgParams { - families = "families", - enterprise = "enterprise", - teams = "teams", - teamsStarter = "teamsStarter", - individual = "individual", - premium = "premium", - free = "free", -} - -enum ValidLayoutParams { - default = "default", - teams = "teams", - teams1 = "teams1", - teams2 = "teams2", - teams3 = "teams3", - enterprise = "enterprise", - enterprise1 = "enterprise1", - enterprise2 = "enterprise2", - cnetcmpgnent = "cnetcmpgnent", - cnetcmpgnind = "cnetcmpgnind", - cnetcmpgnteams = "cnetcmpgnteams", - abmenterprise = "abmenterprise", - abmteams = "abmteams", - secretsManager = "secretsManager", -} - -@Component({ - selector: "app-trial", - templateUrl: "trial-initiation.component.html", -}) -export class TrialInitiationComponent implements OnInit, OnDestroy { - email = ""; - fromOrgInvite = false; - org = ""; - orgInfoSubLabel = ""; - orgId = ""; - orgLabel = ""; - billingSubLabel = ""; - layout = "default"; - plan: PlanType; - productTier: ProductTierType; - accountCreateOnly = true; - useTrialStepper = false; - loading = false; - policies: Policy[]; - enforcedPolicyOptions: MasterPasswordPolicyOptions; - trialFlowOrgs: string[] = [ - ValidOrgParams.teams, - ValidOrgParams.teamsStarter, - ValidOrgParams.enterprise, - ValidOrgParams.families, - ]; - routeFlowOrgs: string[] = [ - ValidOrgParams.free, - ValidOrgParams.premium, - ValidOrgParams.individual, - ]; - layouts = ValidLayoutParams; - referenceData: ReferenceEventRequest; - @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; - - orgInfoFormGroup = this.formBuilder.group({ - name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }], - email: [""], - }); - - private set referenceDataId(referenceId: string) { - if (referenceId != null) { - this.referenceData.id = referenceId; - } else { - this.referenceData.id = ("; " + document.cookie) - .split("; reference=") - .pop() - .split(";") - .shift(); - } - - if (this.referenceData.id === "") { - this.referenceData.id = null; - } else { - // Matches "_ga_QBRN562QQQ=value1.value2.session" and captures values and session. - const regex = /_ga_QBRN562QQQ=([^.]+)\.([^.]+)\.(\d+)/; - const match = document.cookie.match(regex); - if (match) { - this.referenceData.session = match[3]; - } - } - } - - private destroy$ = new Subject(); - protected enableTrialPayment$ = this.configService.getFeatureFlag$( - FeatureFlag.TrialPaymentOptional, - ); - - constructor( - private route: ActivatedRoute, - protected router: Router, - private formBuilder: UntypedFormBuilder, - private titleCasePipe: TitleCasePipe, - private logService: LogService, - private policyApiService: PolicyApiServiceAbstraction, - private policyService: PolicyService, - private i18nService: I18nService, - private routerService: RouterService, - private acceptOrgInviteService: AcceptOrganizationInviteService, - private organizationBillingService: OrganizationBillingService, - private configService: ConfigService, - ) {} - - async ngOnInit(): Promise { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { - this.referenceData = new ReferenceEventRequest(); - if (qParams.email != null && qParams.email.indexOf("@") > -1) { - this.email = qParams.email; - this.fromOrgInvite = qParams.fromOrgInvite === "true"; - } - - this.referenceDataId = qParams.reference; - - if (Object.values(ValidLayoutParams).includes(qParams.layout)) { - this.layout = qParams.layout; - this.accountCreateOnly = false; - } - - if (this.trialFlowOrgs.includes(qParams.org)) { - this.org = qParams.org; - this.orgLabel = this.titleCasePipe.transform(this.orgDisplayName); - this.useTrialStepper = true; - this.referenceData.flow = qParams.org; - - if (this.org === ValidOrgParams.families) { - this.plan = PlanType.FamiliesAnnually; - this.productTier = ProductTierType.Families; - } else if (this.org === ValidOrgParams.teamsStarter) { - this.plan = PlanType.TeamsStarter; - this.productTier = ProductTierType.TeamsStarter; - } else if (this.org === ValidOrgParams.teams) { - this.plan = PlanType.TeamsAnnually; - this.productTier = ProductTierType.Teams; - } else if (this.org === ValidOrgParams.enterprise) { - this.plan = PlanType.EnterpriseAnnually; - this.productTier = ProductTierType.Enterprise; - } - } else if (this.routeFlowOrgs.includes(qParams.org)) { - this.referenceData.flow = qParams.org; - const route = this.router.createUrlTree(["create-organization"], { - queryParams: { plan: qParams.org }, - }); - this.routerService.setPreviousUrl(route.toString()); - } - - // Are they coming from an email for sponsoring a families organization - // After logging in redirect them to setup the families sponsorship - this.setupFamilySponsorship(qParams.sponsorshipToken); - - this.referenceData.initiationPath = this.accountCreateOnly - ? "Registration form" - : "Password Manager trial from marketing website"; - }); - - // If there's a deep linked org invite, use it to get the password policies - const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); - if (orgInvite != null) { - await this.initPasswordPolicies(orgInvite); - } - - this.orgInfoFormGroup.controls.name.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.orgInfoFormGroup.controls.name.markAsTouched(); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - stepSelectionChange(event: StepperSelectionEvent) { - // Set org info sub label - if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") { - this.orgInfoSubLabel = - "Enter your " + - this.titleCasePipe.transform(this.orgDisplayName) + - " organization information"; - } else if (event.previouslySelectedIndex === 1) { - this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value; - } - - //set billing sub label - if (event.selectedIndex === 2) { - this.billingSubLabel = this.i18nService.t("billingTrialSubLabel"); - } - } - - async createOrganizationOnTrial() { - this.loading = true; - const organization: OrganizationInformation = { - name: this.orgInfoFormGroup.get("name").value, - billingEmail: this.orgInfoFormGroup.get("email").value, - initiationPath: "Password Manager trial from marketing website", - }; - - const plan: PlanInformation = { - type: this.plan, - passwordManagerSeats: 1, - }; - - const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ - organization, - plan, - }); - - this.orgId = response?.id; - this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`; - this.loading = false; - this.verticalStepper.next(); - } - - createdAccount(email: string) { - this.email = email; - this.orgInfoFormGroup.get("email")?.setValue(email); - this.verticalStepper.next(); - } - - billingSuccess(event: any) { - this.orgId = event?.orgId; - this.billingSubLabel = event?.subLabelText; - this.verticalStepper.next(); - } - - createdOrganization(event: OrganizationCreatedEvent) { - this.orgId = event.organizationId; - this.billingSubLabel = event.planDescription; - this.verticalStepper.next(); - } - - navigateToOrgVault() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["organizations", this.orgId, "vault"]); - } - - navigateToOrgInvite() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["organizations", this.orgId, "members"]); - } - - previousStep() { - this.verticalStepper.previous(); - } - - get orgDisplayName() { - if (this.org === "teamsStarter") { - return "Teams Starter"; - } - - return this.org; - } - - get freeTrialText() { - const translationKey = - this.layout === this.layouts.secretsManager - ? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" - : "startYour7DayFreeTrialOfBitwardenFor"; - - return this.i18nService.t(translationKey, this.org); - } - - get trialOrganizationType(): TrialOrganizationType { - switch (this.productTier) { - case ProductTierType.Free: - return null; - default: - return this.productTier; - } - } - - private setupFamilySponsorship(sponsorshipToken: string) { - if (sponsorshipToken != null) { - const route = this.router.createUrlTree(["setup/families-for-enterprise"], { - queryParams: { plan: sponsorshipToken }, - }); - this.routerService.setPreviousUrl(route.toString()); - } - } - - private async initPasswordPolicies(invite: OrganizationInvite): Promise { - if (invite == null) { - return; - } - - try { - this.policies = await this.policyApiService.getPoliciesByToken( - invite.organizationId, - invite.token, - invite.email, - invite.organizationUserId, - ); - } catch (e) { - this.logService.error(e); - } - - if (this.policies != null) { - this.policyService - .masterPasswordPolicyOptions$(this.policies) - .pipe(takeUntil(this.destroy$)) - .subscribe((enforcedPasswordPolicyOptions) => { - this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; - }); - } - } - - protected readonly SubscriptionProduct = SubscriptionProduct; -} diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts index 7b81f57e33b..2c6481ec1bf 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts @@ -6,7 +6,6 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular"; import { FormFieldModule } from "@bitwarden/components"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; -import { RegisterFormModule } from "../../auth/register-form/register-form.module"; import { TaxInfoComponent } from "../../billing"; import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component"; import { SecretsManagerTrialFreeStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component"; @@ -39,7 +38,6 @@ import { TeamsContentComponent } from "./content/teams-content.component"; import { Teams1ContentComponent } from "./content/teams1-content.component"; import { Teams2ContentComponent } from "./content/teams2-content.component"; import { Teams3ContentComponent } from "./content/teams3-content.component"; -import { TrialInitiationComponent } from "./trial-initiation.component"; import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module"; @NgModule({ @@ -48,7 +46,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul CdkStepperModule, VerticalStepperModule, FormFieldModule, - RegisterFormModule, OrganizationCreateModule, EnvironmentSelectorModule, TaxInfoComponent, @@ -56,7 +53,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul InputPasswordComponent, ], declarations: [ - TrialInitiationComponent, CompleteTrialInitiationComponent, EnterpriseContentComponent, TeamsContentComponent, @@ -87,7 +83,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul SecretsManagerTrialFreeStepperComponent, SecretsManagerTrialPaidStepperComponent, ], - exports: [TrialInitiationComponent, CompleteTrialInitiationComponent], + exports: [CompleteTrialInitiationComponent], providers: [TitleCasePipe], }) export class TrialInitiationModule {} diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 3176ac81c1a..d6ba5a1990c 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -20,7 +20,6 @@ import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from import { HintComponent } from "../auth/hint.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; -import { RegisterFormModule } from "../auth/register-form/register-form.module"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { AccountComponent } from "../auth/settings/account/account.component"; @@ -90,7 +89,6 @@ import { SharedModule } from "./shared.module"; @NgModule({ imports: [ SharedModule, - RegisterFormModule, ProductSwitcherModule, UserVerificationModule, ChangeKdfModule, diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 19dad4e9da0..4af08e19d74 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -42,6 +42,7 @@ export class VaultCipherRowComponent implements OnInit { @Input() collections: CollectionView[]; @Input() viewingOrgVault: boolean; @Input() canEditCipher: boolean; + @Input() canAssignCollections: boolean; @Input() canManageCollection: boolean; @Output() onEvent = new EventEmitter(); @@ -101,7 +102,7 @@ export class VaultCipherRowComponent implements OnInit { } protected get showAssignToCollections() { - return this.organizations?.length && this.canEditCipher && !this.cipher.isDeleted; + return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted; } protected get showClone() { @@ -208,6 +209,6 @@ export class VaultCipherRowComponent implements OnInit { return true; // Always show checkbox in individual vault or for non-org items } - return this.organization.canEditAllCiphers || this.cipher.edit; + return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword); } } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 653d05ef129..a32def5fc0c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -144,6 +144,7 @@ [collections]="allCollections" [checked]="selection.isSelected(item)" [canEditCipher]="canEditCipher(item.cipher)" + [canAssignCollections]="canAssignCollections(item.cipher)" [canManageCollection]="canManageCollection(item.cipher)" (checkedToggled)="selection.toggle(item)" (onEvent)="event($event)" diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 3e1cf173a47..a641c5b5908 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -236,6 +236,13 @@ export class VaultItemsComponent { return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; } + protected canAssignCollections(cipher: CipherView) { + const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); + return ( + (organization?.canEditAllCiphers && this.viewingOrgVault) || cipher.canAssignToCollections + ); + } + protected canManageCollection(cipher: CipherView) { // If the cipher is not part of an organization (personal item), user can manage it if (cipher.organizationId == null) { @@ -461,7 +468,7 @@ export class VaultItemsComponent { private allCiphersHaveEditAccess(): boolean { return this.selection.selected .filter(({ cipher }) => cipher) - .every(({ cipher }) => cipher?.edit); + .every(({ cipher }) => cipher?.edit && cipher?.viewPassword); } private getUniqueOrganizationIds(): Set { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts index 7e6f7eb8588..b8494c8aa54 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts @@ -4,10 +4,8 @@ import { Observable } from "rxjs"; import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CipherTypeFilter, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index 9b24f95e2ea..03dfa92d0b5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -274,6 +274,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { folderCopy.id = f.id; folderCopy.revisionDate = f.revisionDate; folderCopy.icon = "bwi-folder"; + folderCopy.fullName = f.name; // save full folder name before separating it into parts const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts index 10888aea13e..9259dd08114 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts @@ -1,10 +1,8 @@ import { CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; export type CipherStatus = "all" | "favorites" | "trash" | CipherType; @@ -12,5 +10,13 @@ export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: str export type CollectionFilter = CollectionAdminView & { icon: string; }; -export type FolderFilter = FolderView & { icon: string }; +export type FolderFilter = FolderView & { + icon: string; + /** + * Full folder name. + * + * Used for when the folder `name` property is be separated into parts. + */ + fullName?: string; +}; export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean }; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 7215c980206..950c1d77731 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -77,6 +77,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { DialogService, Icons, ToastService } from "@bitwarden/components"; import { + AddEditFolderDialogComponent, + AddEditFolderDialogResult, CipherFormConfig, CollectionAssignmentResult, DecryptionFailureDialogComponent, @@ -118,7 +120,6 @@ import { BulkMoveDialogResult, openBulkMoveDialog, } from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; -import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component"; import { VaultBannersComponent } from "./vault-banners/vault-banners.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; @@ -607,20 +608,24 @@ export class VaultComponent implements OnInit, OnDestroy { await this.filterComponent.filters?.organizationFilter?.action(orgNode); } - addFolder = async (): Promise => { - openFolderAddEditDialog(this.dialogService); + addFolder = (): void => { + AddEditFolderDialogComponent.open(this.dialogService); }; editFolder = async (folder: FolderFilter): Promise => { - const dialog = openFolderAddEditDialog(this.dialogService, { - data: { - folderId: folder.id, + const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, { + editFolderConfig: { + // Shallow copy is used so the original folder object is not modified + folder: { + ...folder, + name: folder.fullName ?? folder.name, // If the filter has a fullName populated, use that as the editable name + }, }, }); - const result = await lastValueFrom(dialog.closed); + const result = await lastValueFrom(dialogRef.closed); - if (result === FolderAddEditDialogResult.Deleted) { + if (result === AddEditFolderDialogResult.Deleted) { await this.router.navigate([], { queryParams: { folderId: null }, queryParamsHandling: "merge", 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 9aa92f3995c..9b6d15c581d 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -15,6 +15,7 @@ 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 { 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/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -25,13 +26,8 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { CipherViewComponent } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; import { SharedModule } from "../../shared/shared.module"; import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service"; diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index d10f83fd42b..5ccddeee4bb 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -7,13 +7,10 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { Account } from "../../../../../../../libs/importer/src/importers/lastpass/access/models"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "./admin-console-cipher-form-config.service"; @@ -85,7 +82,14 @@ describe("AdminConsoleCipherFormConfigService", () => { { provide: CipherService, useValue: { get: getCipher } }, { provide: AccountService, - useValue: { activeAccount$: new BehaviorSubject(new Account()) }, + useValue: { + activeAccount$: new BehaviorSubject({ + id: "123-456-789" as UserId, + email: "test@email.com", + emailVerified: true, + name: "Test User", + }), + }, }, ], }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 348037fdbea..32e75644d09 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -15,14 +15,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherFormConfig, CipherFormConfigService, CipherFormMode } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { - CipherFormConfig, - CipherFormConfigService, - CipherFormMode, -} from "../../../../../../../libs/vault/src/cipher-form/abstractions/cipher-form-config.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; /** Admin Console implementation of the `CipherFormConfigService`. */ diff --git a/apps/web/src/app/vault/services/web-view-password-history.service.ts b/apps/web/src/app/vault/services/web-view-password-history.service.ts index 728a3b7e245..b1451b268de 100644 --- a/apps/web/src/app/vault/services/web-view-password-history.service.ts +++ b/apps/web/src/app/vault/services/web-view-password-history.service.ts @@ -1,11 +1,9 @@ import { Injectable } from "@angular/core"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ViewPasswordHistoryService } from "../../../../../../libs/common/src/vault/abstractions/view-password-history.service"; import { openPasswordHistoryDialog } from "../individual-vault/password-history.component"; /** diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c6e8b492c1c..ef199163239 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -485,6 +485,18 @@ "editFolder": { "message": "Edit folder" }, + "newFolder": { + "message": "New folder" + }, + "folderName": { + "message": "Folder name" + }, + "folderHintText": { + "message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums" + }, + "deleteFolderPermanently": { + "message": "Are you sure you want to permanently delete this folder?" + }, "baseDomain": { "message": "Base domain", "description": "Domain name. Example: website.com" @@ -749,15 +761,6 @@ "itemName": { "message": "Item name" }, - "cannotRemoveViewOnlyCollections": { - "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", - "placeholders": { - "collections": { - "content": "$1", - "example": "Work, Personal" - } - } - }, "ex": { "message": "ex.", "description": "Short abbreviation for 'example'." @@ -10143,6 +10146,15 @@ "descriptorCode": { "message": "Descriptor code" }, + "cannotRemoveViewOnlyCollections": { + "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "placeholders": { + "collections": { + "content": "$1", + "example": "Work, Personal" + } + } + }, "importantNotice": { "message": "Important notice" }, @@ -10333,5 +10345,45 @@ "example": "Acme c" } } + }, + "seatsRemaining": { + "message": "You have $REMAINING$ seats remaining out of $TOTAL$ seats assigned to this organization. Contact your provider to manage your subscription.", + "placeholders": { + "remaining": { + "content": "$1", + "example": "5" + }, + "total": { + "content": "$2", + "example": "10" + } + } + }, + "existingOrganization": { + "message": "Existing organization" + }, + "selectOrganizationProviderPortal": { + "message": "Select an organization to add to your Provider Portal." + }, + "noOrganizations": { + "message": "There are no organizations to list" + }, + "yourProviderSubscriptionCredit": { + "message": "Your provider subscription will receive a credit for any remaining time in the organization's subscription." + }, + "doYouWantToAddThisOrg": { + "message": "Do you want to add this organization to $PROVIDER$?", + "placeholders": { + "provider": { + "content": "$1", + "example": "Cool MSP" + } + } + }, + "addedExistingOrganization": { + "message": "Added existing organization" + }, + "assignedExceedsAvailable": { + "message": "Assigned seats exceed available seats." } } 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 37cb9618b60..3310be7ba36 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 @@ -17,6 +17,7 @@ import { ProviderSubscriptionComponent, ProviderSubscriptionStatusComponent, } from "../../billing/providers"; +import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component"; import { AddOrganizationComponent } from "./clients/add-organization.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; @@ -63,6 +64,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr SetupProviderComponent, UserAddEditComponent, AddEditMemberDialogComponent, + AddExistingOrganizationDialogComponent, CreateClientDialogComponent, ManageClientNameDialogComponent, ManageClientSubscriptionDialogComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts index 264b43aee9d..6b9765e9e05 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; +import { switchMap } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; @@ -10,6 +13,8 @@ import { PlanType } from "@bitwarden/common/billing/enums"; import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { KeyService } from "@bitwarden/key-management"; @@ -23,6 +28,8 @@ export class WebProviderService { private i18nService: I18nService, private encryptService: EncryptService, private billingApiService: BillingApiServiceAbstraction, + private stateProvider: StateProvider, + private providerApiService: ProviderApiServiceAbstraction, ) {} async addOrganizationToProvider(providerId: string, organizationId: string) { @@ -40,6 +47,22 @@ export class WebProviderService { return response; } + async addOrganizationToProviderVNext(providerId: string, organizationId: string): Promise { + const orgKey = await firstValueFrom( + this.stateProvider.activeUserId$.pipe( + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]), + ), + ); + const providerKey = await this.keyService.getProviderKey(providerId); + const encryptedOrgKey = await this.encryptService.encrypt(orgKey.key, providerKey); + await this.providerApiService.addOrganizationToProvider(providerId, { + key: encryptedOrgKey.encryptedString, + organizationId, + }); + await this.syncService.fullSync(true); + } + async createClientOrganization( providerId: string, name: string, diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html new file mode 100644 index 00000000000..a22484ed92d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.html @@ -0,0 +1,73 @@ + + + {{ "addExistingOrganization" | i18n }} + + + +

{{ "selectOrganizationProviderPortal" | i18n }}

+ + + + {{ "name" | i18n }} + {{ "assigned" | i18n }} + + + + + + + + + {{ addable.name }} +
+ {{ "assignedExceedsAvailable" | i18n }} +
+ + {{ addable.seats }} + + + + +
+
+

+ {{ "noOrganizations" | i18n }} +

+
+ +

{{ "yourProviderSubscriptionCredit" | i18n }}

+

{{ "doYouWantToAddThisOrg" | i18n: dialogParams.provider.name }}

+
+
{{ "organization" | i18n }}: {{ selectedOrganization.name }}
+
{{ "billingPlan" | i18n }}: {{ selectedOrganization.plan }}
+
{{ "assignedSeats" | i18n }}: {{ selectedOrganization.seats }}
+
+
+
+ + + + +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts new file mode 100644 index 00000000000..3df0693d091 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/add-existing-organization-dialog.component.ts @@ -0,0 +1,82 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +export type AddExistingOrganizationDialogParams = { + provider: Provider; +}; + +export enum AddExistingOrganizationDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +@Component({ + templateUrl: "./add-existing-organization-dialog.component.html", +}) +export class AddExistingOrganizationDialogComponent implements OnInit { + protected loading: boolean = true; + + addableOrganizations: AddableOrganizationResponse[] = []; + selectedOrganization?: AddableOrganizationResponse; + + protected readonly ResultType = AddExistingOrganizationDialogResultType; + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: AddExistingOrganizationDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private providerApiService: ProviderApiServiceAbstraction, + private toastService: ToastService, + private webProviderService: WebProviderService, + ) {} + + async ngOnInit() { + this.addableOrganizations = await this.providerApiService.getProviderAddableOrganizations( + this.dialogParams.provider.id, + ); + this.loading = false; + } + + addExistingOrganization = async (): Promise => { + if (this.selectedOrganization) { + await this.webProviderService.addOrganizationToProviderVNext( + this.dialogParams.provider.id, + this.selectedOrganization.id, + ); + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("addedExistingOrganization"), + }); + + this.dialogRef.close(this.ResultType.Submitted); + } + }; + + selectOrganization(organizationId: string) { + this.selectedOrganization = this.addableOrganizations.find( + (organization) => organization.id === organizationId, + ); + } + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig< + AddExistingOrganizationDialogParams, + DialogRef + >, + ) => + dialogService.open< + AddExistingOrganizationDialogResultType, + AddExistingOrganizationDialogParams + >(AddExistingOrganizationDialogComponent, dialogConfig); +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html index 7c560e49579..077aeb6c124 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.html @@ -1,9 +1,39 @@ - - - {{ "addNewOrganization" | i18n }} - + + + + + + + + + + + {{ "addNewOrganization" | i18n }} + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts index ee2c541e72f..07434369122 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts @@ -11,6 +11,8 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -25,6 +27,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; +import { + AddExistingOrganizationDialogComponent, + AddExistingOrganizationDialogResultType, +} from "./add-existing-organization-dialog.component"; import { CreateClientDialogResultType, openCreateClientDialog, @@ -62,6 +68,9 @@ export class ManageClientsComponent { protected searchControl = new FormControl("", { nonNullable: true }); protected plans: PlanResponse[] = []; + protected addExistingOrgsFromProviderPortal$ = this.configService.getFeatureFlag$( + FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal, + ); constructor( private billingApiService: BillingApiServiceAbstraction, @@ -73,6 +82,7 @@ export class ManageClientsComponent { private toastService: ToastService, private validationService: ValidationService, private webProviderService: WebProviderService, + private configService: ConfigService, ) { this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { this.searchControl.setValue(queryParams.search); @@ -111,19 +121,30 @@ export class ManageClientsComponent { async load() { this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); - this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin; - - const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId)) - .data; - - this.dataSource.data = clients; - + this.dataSource.data = ( + await this.billingApiService.getProviderClientOrganizations(this.providerId) + ).data; this.plans = (await this.billingApiService.getPlans()).data; - this.loading = false; } + addExistingOrganization = async () => { + if (this.provider) { + const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, { + data: { + provider: this.provider, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === AddExistingOrganizationDialogResultType.Submitted) { + await this.load(); + } + } + }; + createClient = async () => { const reference = openCreateClientDialog(this.dialogService, { data: { diff --git a/bitwarden_license/bit-web/src/app/vault/services/abstractions/admin-task.abstraction.ts b/bitwarden_license/bit-web/src/app/vault/services/abstractions/admin-task.abstraction.ts new file mode 100644 index 00000000000..014c9daa783 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/vault/services/abstractions/admin-task.abstraction.ts @@ -0,0 +1,34 @@ +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault"; + +/** + * Request type for creating tasks. + * @property cipherId - Optional. The ID of the cipher to create the task for. + * @property type - The type of task to create. Currently defined as "updateAtRiskCredential". + */ +export type CreateTasksRequest = Readonly<{ + cipherId?: CipherId; + type: SecurityTaskType.UpdateAtRiskCredential; +}>; + +export abstract class AdminTaskService { + /** + * Retrieves all tasks for a given organization. + * @param organizationId - The ID of the organization to retrieve tasks for. + * @param status - Optional. The status of the tasks to retrieve. + */ + abstract getAllTasks( + organizationId: OrganizationId, + status?: SecurityTaskStatus | undefined, + ): Promise; + + /** + * Creates multiple tasks for a given organization and sends out notifications to applicable users. + * @param organizationId - The ID of the organization to create tasks for. + * @param tasks - The tasks to create. + */ + abstract bulkCreateTasks( + organizationId: OrganizationId, + tasks: CreateTasksRequest[], + ): Promise; +} diff --git a/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.spec.ts b/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.spec.ts new file mode 100644 index 00000000000..d6a686a071a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.spec.ts @@ -0,0 +1,65 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault"; + +import { CreateTasksRequest } from "./abstractions/admin-task.abstraction"; +import { DefaultAdminTaskService } from "./default-admin-task.service"; + +describe("DefaultAdminTaskService", () => { + let defaultAdminTaskService: DefaultAdminTaskService; + let apiService: MockProxy; + + beforeEach(() => { + apiService = mock(); + defaultAdminTaskService = new DefaultAdminTaskService(apiService); + }); + + describe("getAllTasks", () => { + it("should call the api service with the correct parameters with status", async () => { + const organizationId = "orgId" as OrganizationId; + const status = SecurityTaskStatus.Pending; + const expectedUrl = `/tasks/organization?organizationId=${organizationId}&status=0`; + + await defaultAdminTaskService.getAllTasks(organizationId, status); + + expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true); + }); + + it("should call the api service with the correct parameters without status", async () => { + const organizationId = "orgId" as OrganizationId; + const expectedUrl = `/tasks/organization?organizationId=${organizationId}`; + + await defaultAdminTaskService.getAllTasks(organizationId); + + expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true); + }); + }); + + describe("bulkCreateTasks", () => { + it("should call the api service with the correct parameters", async () => { + const organizationId = "orgId" as OrganizationId; + const tasks: CreateTasksRequest[] = [ + { + cipherId: "cipherId-1" as CipherId, + type: SecurityTaskType.UpdateAtRiskCredential, + }, + { + cipherId: "cipherId-2" as CipherId, + type: SecurityTaskType.UpdateAtRiskCredential, + }, + ]; + + await defaultAdminTaskService.bulkCreateTasks(organizationId, tasks); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + `/tasks/${organizationId}/bulk-create`, + tasks, + true, + true, + ); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts b/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts new file mode 100644 index 00000000000..442fde9dbf6 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/vault/services/default-admin-task.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + SecurityTask, + SecurityTaskData, + SecurityTaskResponse, + SecurityTaskStatus, +} from "@bitwarden/vault"; + +import { AdminTaskService, CreateTasksRequest } from "./abstractions/admin-task.abstraction"; + +@Injectable() +export class DefaultAdminTaskService implements AdminTaskService { + constructor(private apiService: ApiService) {} + + async getAllTasks( + organizationId: OrganizationId, + status?: SecurityTaskStatus | undefined, + ): Promise { + const queryParams = new URLSearchParams(); + + queryParams.append("organizationId", organizationId); + if (status !== undefined) { + queryParams.append("status", status.toString()); + } + + const r = await this.apiService.send( + "GET", + `/tasks/organization?${queryParams.toString()}`, + null, + true, + true, + ); + const response = new ListResponse(r, SecurityTaskResponse); + + return response.data.map((d) => new SecurityTask(new SecurityTaskData(d))); + } + + async bulkCreateTasks( + organizationId: OrganizationId, + tasks: CreateTasksRequest[], + ): Promise { + await this.apiService.send("POST", `/tasks/${organizationId}/bulk-create`, tasks, true, true); + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 514d1ccf0be..2d7c91521f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,8 @@ import rxjs from "eslint-plugin-rxjs"; import angularRxjs from "eslint-plugin-rxjs-angular"; import storybook from "eslint-plugin-storybook"; +import platformPlugins from "./libs/eslint/platform/index.mjs"; + export default tseslint.config( ...storybook.configs["flat/recommended"], { @@ -28,6 +30,7 @@ export default tseslint.config( plugins: { rxjs: rxjs, "rxjs-angular": angularRxjs, + "@bitwarden/platform": platformPlugins, }, languageOptions: { parserOptions: { @@ -66,7 +69,7 @@ export default tseslint.config( "@angular-eslint/no-outputs-metadata-property": 0, "@angular-eslint/use-lifecycle-interface": "error", "@angular-eslint/use-pipe-transform-interface": 0, - + "@bitwarden/platform/required-using": "error", "@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }], "@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled "@typescript-eslint/no-floating-promises": "error", diff --git a/jest.config.js b/jest.config.js index ccde758dbc9..e8815f92ffb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,6 +30,7 @@ module.exports = { "/libs/billing/jest.config.js", "/libs/common/jest.config.js", "/libs/components/jest.config.js", + "/libs/eslint/jest.config.js", "/libs/tools/export/vault-export/vault-export-core/jest.config.js", "/libs/tools/generator/core/jest.config.js", "/libs/tools/generator/components/jest.config.js", diff --git a/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts index ca3906cead3..32396c878d9 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts @@ -195,7 +195,7 @@ export class BaseLoginDecryptionOptionsComponentV1 implements OnInit, OnDestroy async loadNewUserData() { const autoEnrollStatus$ = defer(() => - this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(), + this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId), ).pipe( switchMap((organizationIdentifier) => { if (organizationIdentifier == undefined) { diff --git a/libs/angular/src/auth/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts index 13d1a4f4131..70c103d972e 100644 --- a/libs/angular/src/auth/components/set-password.component.ts +++ b/libs/angular/src/auth/components/set-password.component.ts @@ -47,7 +47,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements resetPasswordAutoEnroll = false; onSuccessfulChangePassword: () => Promise; successRoute = "vault"; - userId: UserId; + activeUserId: UserId; forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None; ForceSetPasswordReason = ForceSetPasswordReason; @@ -96,10 +96,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements await this.syncService.fullSync(true); this.syncLoading = false; - this.userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; this.forceSetPasswordReason = await firstValueFrom( - this.masterPasswordService.forceSetPasswordReason$(this.userId), + this.masterPasswordService.forceSetPasswordReason$(this.activeUserId), ); this.route.queryParams @@ -111,7 +111,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements } else { // Try to get orgSsoId from state as fallback // Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario. - return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(); + return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId); } }), filter((orgSsoId) => orgSsoId != null), @@ -167,10 +167,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements // in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one const existingUserPrivateKey = (await firstValueFrom( - this.keyService.userPrivateKey$(this.userId), + this.keyService.userPrivateKey$(this.activeUserId), )) as Uint8Array; const existingUserPublicKey = await firstValueFrom( - this.keyService.userPublicKey$(this.userId), + this.keyService.userPublicKey$(this.activeUserId), ); if (existingUserPrivateKey != null && existingUserPublicKey != null) { const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey); @@ -217,7 +217,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment( this.orgId, - this.userId, + this.activeUserId, resetRequest, ); }); @@ -260,7 +260,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements // Clear force set password reason to allow navigation back to vault. await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.None, - this.userId, + this.activeUserId, ); // User now has a password so update account decryption options in state @@ -269,9 +269,9 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements ); userDecryptionOpts.hasMasterPassword = true; await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts); - await this.kdfConfigService.setKdfConfig(this.userId, this.kdfConfig); - await this.masterPasswordService.setMasterKey(masterKey, this.userId); - await this.keyService.setUserKey(userKey[0], this.userId); + await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig); + await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId); + await this.keyService.setUserKey(userKey[0], this.activeUserId); // Set private key only for new JIT provisioned users in MP encryption orgs // Existing TDE users will have private key set on sync or on login @@ -280,7 +280,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements this.forceSetPasswordReason != ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission ) { - await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.userId); + await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId); } const localMasterKeyHash = await this.keyService.hashMasterKey( @@ -288,6 +288,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements masterKey, HashPurpose.LocalAuthorization, ); - await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.userId); + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId); } } diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 6c13809566a..d0fc2140f06 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; @@ -27,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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; @@ -55,6 +57,7 @@ export class SsoComponent implements OnInit { protected redirectUri: string; protected state: string; protected codeChallenge: string; + protected activeUserId: UserId; constructor( protected ssoLoginService: SsoLoginServiceAbstraction, @@ -74,7 +77,11 @@ export class SsoComponent implements OnInit { protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected accountService: AccountService, protected toastService: ToastService, - ) {} + ) { + this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => { + this.activeUserId = account?.id; + }); + } async ngOnInit() { // eslint-disable-next-line rxjs/no-async-subscribe @@ -226,7 +233,10 @@ export class SsoComponent implements OnInit { // - TDE login decryption options component // - Browser SSO on extension open // Note: you cannot set this in state before 2FA b/c there won't be an account in state. - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier); + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + orgSsoIdentifier, + this.activeUserId, + ); // Users enrolled in admin acct recovery can be forced to set a new password after // having the admin set a temp password for them (affects TDE & standard users) diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html index 8462a18ac2e..087ecd2764e 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html @@ -69,7 +69,7 @@ diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts index 6aca189a79e..6afee461c42 100644 --- a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit, ViewChild } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { ActivatedRoute, NavigationExtras, Router, RouterLink } from "@angular/router"; import { Subject, takeUntil, lastValueFrom, first, firstValueFrom } from "rxjs"; @@ -31,6 +32,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi 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 { UserId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, @@ -126,6 +128,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements protected changePasswordRoute = "set-password"; protected forcePasswordResetRoute = "update-temp-password"; protected successRoute = "vault"; + protected activeUserId: UserId; constructor( protected loginStrategyService: LoginStrategyServiceAbstraction, @@ -148,6 +151,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements protected toastService: ToastService, ) { super(environmentService, i18nService, platformUtilsService, toastService); + + this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => { + this.activeUserId = account?.id; + }); } async ngOnInit() { @@ -214,7 +221,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements } } - async selectOtherTwofactorMethod() { + async selectOtherTwoFactorMethod() { const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed); if (response.result === TwoFactorOptionsDialogResult.Provider) { @@ -262,7 +269,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements // Save off the OrgSsoIdentifier for use in the TDE flows // - TDE login decryption options component // - Browser SSO on extension open - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier); + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + this.orgIdentifier, + this.activeUserId, + ); this.loginEmailService.clearValues(); // note: this flow affects both TDE & standard users diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index 3b3459f42fb..49af9d057f7 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -35,6 +35,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic 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 { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; @@ -73,6 +74,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected successRoute = "vault"; protected twoFactorTimeoutRoute = "authentication-timeout"; + protected activeUserId: UserId; + get isDuoProvider(): boolean { return ( this.selectedProviderType === TwoFactorProviderType.Duo || @@ -102,8 +105,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected toastService: ToastService, ) { super(environmentService, i18nService, platformUtilsService, toastService); + this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); + this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => { + this.activeUserId = account?.id; + }); + // Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired this.loginStrategyService.authenticationSessionTimeout$ .pipe(takeUntilDestroyed()) @@ -287,7 +295,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI // Save off the OrgSsoIdentifier for use in the TDE flows // - TDE login decryption options component // - Browser SSO on extension open - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier); + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + this.orgIdentifier, + this.activeUserId, + ); this.loginEmailService.clearValues(); // note: this flow affects both TDE & standard users diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 36082f879b9..94c2412652a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -296,7 +296,7 @@ import { DefaultUserAsymmetricKeysRegenerationApiService, } from "@bitwarden/key-management"; import { SafeInjectionToken } from "@bitwarden/ui-common"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault"; import { VaultExportService, VaultExportServiceAbstraction, @@ -306,9 +306,6 @@ import { IndividualVaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { NewDeviceVerificationNoticeService } from "../../../vault/src/services/new-device-verification-notice.service"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { ViewCacheService } from "../platform/abstractions/view-cache.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; @@ -799,7 +796,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SsoLoginServiceAbstraction, useClass: SsoLoginService, - deps: [StateProvider], + deps: [StateProvider, LogService], }), safeProvider({ provide: STATE_FACTORY, diff --git a/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts b/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts index 7ff3f567a00..960590dab53 100644 --- a/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts +++ b/libs/angular/src/vault/guards/new-device-verification-notice.guard.spec.ts @@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { NewDeviceVerificationNoticeService } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service"; import { VaultProfileService } from "../services/vault-profile.service"; import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard"; diff --git a/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts b/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts index 2a21279ec3b..09d6b3313c4 100644 --- a/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts +++ b/libs/angular/src/vault/guards/new-device-verification-notice.guard.ts @@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { NewDeviceVerificationNoticeService } from "@bitwarden/vault"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service"; import { VaultProfileService } from "../services/vault-profile.service"; export const NewDeviceVerificationNoticeGuard: CanActivateFn = async ( diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 96fb74ba96b..aab06a69add 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -16,14 +16,11 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "../../abstractions/deprecated-vault-filter.service"; import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { COLLAPSED_GROUPINGS } from "./../../../../../common/src/vault/services/key-state/collapsed-groupings.state"; - const NestingDelimiter = "/"; @Injectable() diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index a3f5e062e4f..4c93c79d6fe 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -202,7 +202,7 @@ export class LoginDecryptionOptionsComponent implements OnInit { }); const autoEnrollStatus$ = defer(() => - this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(), + this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId), ).pipe( switchMap((organizationIdentifier) => { if (organizationIdentifier == undefined) { diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index 4583332cb88..b4373bfe96e 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -36,6 +36,7 @@ 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, @@ -89,6 +90,7 @@ export class SsoComponent implements OnInit { protected state: string | undefined; protected codeChallenge: string | undefined; protected clientId: SsoClientType | undefined; + protected activeUserId: UserId | undefined; formPromise: Promise | undefined; initiateSsoFormPromise: Promise | undefined; @@ -130,6 +132,8 @@ export class SsoComponent implements OnInit { } async ngOnInit() { + this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const qParams: QueryParams = await firstValueFrom(this.route.queryParams); // This if statement will pass on the second portion of the SSO flow @@ -384,7 +388,10 @@ export class SsoComponent implements OnInit { // - TDE login decryption options component // - Browser SSO on extension open // Note: you cannot set this in state before 2FA b/c there won't be an account in state. - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier); + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + orgSsoIdentifier, + this.activeUserId, + ); // Users enrolled in admin acct recovery can be forced to set a new password after // having the admin set a temp password for them (affects TDE & standard users) diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html index f532a3b23fd..56bce040d2f 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -120,7 +120,7 @@
{{ "enterVerificationCodeSentToEmail" | i18n }} -

+

diff --git a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts index f348e7487de..ffe79f0ad3b 100644 --- a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; + import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request"; import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request"; import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request"; @@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction { request: ProviderVerifyRecoverDeleteRequest, ) => Promise; deleteProvider: (id: string) => Promise; + getProviderAddableOrganizations: (providerId: string) => Promise; + addOrganizationToProvider: ( + providerId: string, + request: { + key: string; + organizationId: string; + }, + ) => Promise; } diff --git a/libs/common/src/admin-console/models/response/addable-organization.response.ts b/libs/common/src/admin-console/models/response/addable-organization.response.ts new file mode 100644 index 00000000000..74ae5f45690 --- /dev/null +++ b/libs/common/src/admin-console/models/response/addable-organization.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class AddableOrganizationResponse extends BaseResponse { + id: string; + plan: string; + name: string; + seats: number; + disabled: boolean; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("id"); + this.plan = this.getResponseProperty("plan"); + this.name = this.getResponseProperty("name"); + this.seats = this.getResponseProperty("seats"); + this.disabled = this.getResponseProperty("disabled"); + } +} diff --git a/libs/common/src/admin-console/services/provider/provider-api.service.ts b/libs/common/src/admin-console/services/provider/provider-api.service.ts index 2ee921393ff..dc82ec011f4 100644 --- a/libs/common/src/admin-console/services/provider/provider-api.service.ts +++ b/libs/common/src/admin-console/services/provider/provider-api.service.ts @@ -1,3 +1,5 @@ +import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; + import { ApiService } from "../../../abstractions/api.service"; import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request"; @@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction { async deleteProvider(id: string): Promise { await this.apiService.send("DELETE", "/providers/" + id, null, true, false); } + + async getProviderAddableOrganizations( + providerId: string, + ): Promise { + const response = await this.apiService.send( + "GET", + "/providers/" + providerId + "/clients/addable", + null, + true, + true, + ); + + return response.map((data: any) => new AddableOrganizationResponse(data)); + } + + addOrganizationToProvider( + providerId: string, + request: { + key: string; + organizationId: string; + }, + ): Promise { + return this.apiService.send( + "POST", + "/providers/" + providerId + "/clients/existing", + request, + true, + false, + ); + } } diff --git a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts index 3f3731e0e1b..bf64dcafd69 100644 --- a/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/sso-login.service.abstraction.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { UserId } from "@bitwarden/common/types/guid"; + export abstract class SsoLoginServiceAbstraction { /** * Gets the code verifier used for SSO. @@ -74,12 +76,16 @@ export abstract class SsoLoginServiceAbstraction { * Gets the value of the active user's organization sso identifier. * * This should only be used post successful SSO login once the user is initialized. + * @param userId The user id for retrieving the org identifier state. */ - getActiveUserOrganizationSsoIdentifier: () => Promise; + getActiveUserOrganizationSsoIdentifier: (userId: UserId) => Promise; /** * Sets the value of the active user's organization sso identifier. * * This should only be used post successful SSO login once the user is initialized. */ - setActiveUserOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise; + setActiveUserOrganizationSsoIdentifier: ( + organizationIdentifier: string, + userId: UserId | undefined, + ) => Promise; } diff --git a/libs/common/src/auth/services/sso-login.service.spec.ts b/libs/common/src/auth/services/sso-login.service.spec.ts new file mode 100644 index 00000000000..9cf49a07834 --- /dev/null +++ b/libs/common/src/auth/services/sso-login.service.spec.ts @@ -0,0 +1,94 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { + CODE_VERIFIER, + GLOBAL_ORGANIZATION_SSO_IDENTIFIER, + SSO_EMAIL, + SSO_STATE, + SsoLoginService, + USER_ORGANIZATION_SSO_IDENTIFIER, +} from "@bitwarden/common/auth/services/sso-login.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; + +describe("SSOLoginService ", () => { + let sut: SsoLoginService; + + let accountService: FakeAccountService; + let mockSingleUserStateProvider: FakeStateProvider; + let mockLogService: MockProxy; + let userId: UserId; + + beforeEach(() => { + jest.clearAllMocks(); + + userId = Utils.newGuid() as UserId; + accountService = mockAccountServiceWith(userId); + mockSingleUserStateProvider = new FakeStateProvider(accountService); + mockLogService = mock(); + + sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService); + }); + + it("instantiates", () => { + expect(sut).not.toBeFalsy(); + }); + + it("gets and sets code verifier", async () => { + const codeVerifier = "test-code-verifier"; + await sut.setCodeVerifier(codeVerifier); + mockSingleUserStateProvider.getGlobal(CODE_VERIFIER); + + const result = await sut.getCodeVerifier(); + expect(result).toBe(codeVerifier); + }); + + it("gets and sets SSO state", async () => { + const ssoState = "test-sso-state"; + await sut.setSsoState(ssoState); + mockSingleUserStateProvider.getGlobal(SSO_STATE); + + const result = await sut.getSsoState(); + expect(result).toBe(ssoState); + }); + + it("gets and sets organization SSO identifier", async () => { + const orgIdentifier = "test-org-identifier"; + await sut.setOrganizationSsoIdentifier(orgIdentifier); + mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER); + + const result = await sut.getOrganizationSsoIdentifier(); + expect(result).toBe(orgIdentifier); + }); + + it("gets and sets SSO email", async () => { + const email = "test@example.com"; + await sut.setSsoEmail(email); + mockSingleUserStateProvider.getGlobal(SSO_EMAIL); + + const result = await sut.getSsoEmail(); + expect(result).toBe(email); + }); + + it("gets and sets active user organization SSO identifier", async () => { + const userId = Utils.newGuid() as UserId; + const orgIdentifier = "test-active-org-identifier"; + await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId); + mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER); + + const result = await sut.getActiveUserOrganizationSsoIdentifier(userId); + expect(result).toBe(orgIdentifier); + }); + + it("logs error when setting active user organization SSO identifier with undefined userId", async () => { + const orgIdentifier = "test-active-org-identifier"; + await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, undefined); + + expect(mockLogService.warning).toHaveBeenCalledWith( + "Tried to set a user organization sso identifier with an undefined user id.", + ); + }); +}); diff --git a/libs/common/src/auth/services/sso-login.service.ts b/libs/common/src/auth/services/sso-login.service.ts index 32019e8d568..c73be3630be 100644 --- a/libs/common/src/auth/services/sso-login.service.ts +++ b/libs/common/src/auth/services/sso-login.service.ts @@ -2,10 +2,13 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; + import { - ActiveUserState, GlobalState, KeyDefinition, + SingleUserState, SSO_DISK, StateProvider, UserKeyDefinition, @@ -15,21 +18,21 @@ import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.ab /** * Uses disk storage so that the code verifier can be persisted across sso redirects. */ -const CODE_VERIFIER = new KeyDefinition(SSO_DISK, "ssoCodeVerifier", { +export const CODE_VERIFIER = new KeyDefinition(SSO_DISK, "ssoCodeVerifier", { deserializer: (codeVerifier) => codeVerifier, }); /** * Uses disk storage so that the sso state can be persisted across sso redirects. */ -const SSO_STATE = new KeyDefinition(SSO_DISK, "ssoState", { +export const SSO_STATE = new KeyDefinition(SSO_DISK, "ssoState", { deserializer: (state) => state, }); /** * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. */ -const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition( +export const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition( SSO_DISK, "organizationSsoIdentifier", { @@ -41,7 +44,7 @@ const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition( /** * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. */ -const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( +export const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( SSO_DISK, "organizationSsoIdentifier", { @@ -52,7 +55,7 @@ const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( /** * Uses disk storage so that the user's email can be persisted across sso redirects. */ -const SSO_EMAIL = new KeyDefinition(SSO_DISK, "ssoEmail", { +export const SSO_EMAIL = new KeyDefinition(SSO_DISK, "ssoEmail", { deserializer: (state) => state, }); @@ -61,16 +64,15 @@ export class SsoLoginService implements SsoLoginServiceAbstraction { private ssoState: GlobalState; private orgSsoIdentifierState: GlobalState; private ssoEmailState: GlobalState; - private activeUserOrgSsoIdentifierState: ActiveUserState; - constructor(private stateProvider: StateProvider) { + constructor( + private stateProvider: StateProvider, + private logService: LogService, + ) { this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER); this.ssoState = this.stateProvider.getGlobal(SSO_STATE); this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER); this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL); - this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive( - USER_ORGANIZATION_SSO_IDENTIFIER, - ); } getCodeVerifier(): Promise { @@ -105,11 +107,24 @@ export class SsoLoginService implements SsoLoginServiceAbstraction { await this.ssoEmailState.update((_) => email); } - getActiveUserOrganizationSsoIdentifier(): Promise { - return firstValueFrom(this.activeUserOrgSsoIdentifierState.state$); + getActiveUserOrganizationSsoIdentifier(userId: UserId): Promise { + return firstValueFrom(this.userOrgSsoIdentifierState(userId).state$); } - async setActiveUserOrganizationSsoIdentifier(organizationIdentifier: string): Promise { - await this.activeUserOrgSsoIdentifierState.update((_) => organizationIdentifier); + async setActiveUserOrganizationSsoIdentifier( + organizationIdentifier: string, + userId: UserId | undefined, + ): Promise { + if (userId === undefined) { + this.logService.warning( + "Tried to set a user organization sso identifier with an undefined user id.", + ); + return; + } + await this.userOrgSsoIdentifierState(userId).update((_) => organizationIdentifier); + } + + private userOrgSsoIdentifierState(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 613572bb75b..e2e7450a2df 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -24,8 +24,12 @@ export enum FeatureFlag { NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", + /* Tools */ ItemShare = "item-share", GeneratorToolsModernization = "generator-tools-modernization", + CriticalApps = "pm-14466-risk-insights-critical-application", + EnableRiskInsightsNotifications = "enable-risk-insights-notifications", + AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", ExtensionRefresh = "extension-refresh", PersistPopupView = "persist-popup-view", @@ -37,7 +41,6 @@ export enum FeatureFlag { SSHAgent = "ssh-agent", CipherKeyEncryption = "cipher-key-encryption", PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader", - CriticalApps = "pm-14466-risk-insights-critical-application", TrialPaymentOptional = "PM-8163-trial-payment", SecurityTasks = "security-tasks", NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss", @@ -48,7 +51,7 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs", NewDeviceVerification = "new-device-verification", - EnableRiskInsightsNotifications = "enable-risk-insights-notifications", + PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -83,8 +86,12 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, + /* Tools */ [FeatureFlag.ItemShare]: FALSE, [FeatureFlag.GeneratorToolsModernization]: FALSE, + [FeatureFlag.CriticalApps]: FALSE, + [FeatureFlag.EnableRiskInsightsNotifications]: FALSE, + [FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE, [FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.PersistPopupView]: FALSE, @@ -96,7 +103,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.SSHAgent]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE, - [FeatureFlag.CriticalApps]: FALSE, [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.SecurityTasks]: FALSE, [FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE, @@ -107,7 +113,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.ResellerManagedOrgAlert]: FALSE, [FeatureFlag.NewDeviceVerification]: FALSE, - [FeatureFlag.EnableRiskInsightsNotifications]: FALSE, + [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/platform/abstractions/sdk/sdk.service.ts b/libs/common/src/platform/abstractions/sdk/sdk.service.ts index 78ec11c4022..22ad2b44ff9 100644 --- a/libs/common/src/platform/abstractions/sdk/sdk.service.ts +++ b/libs/common/src/platform/abstractions/sdk/sdk.service.ts @@ -3,6 +3,7 @@ import { Observable } from "rxjs"; import { BitwardenClient } from "@bitwarden/sdk-internal"; import { UserId } from "../../../types/guid"; +import { Rc } from "../../misc/reference-counting/rc"; export abstract class SdkService { /** @@ -27,5 +28,5 @@ export abstract class SdkService { * * @param userId */ - abstract userClient$(userId: UserId): Observable; + abstract userClient$(userId: UserId): Observable | undefined>; } diff --git a/libs/common/src/platform/misc/reference-counting/rc.spec.ts b/libs/common/src/platform/misc/reference-counting/rc.spec.ts new file mode 100644 index 00000000000..094abfe3615 --- /dev/null +++ b/libs/common/src/platform/misc/reference-counting/rc.spec.ts @@ -0,0 +1,93 @@ +// Temporary workaround for Symbol.dispose +// remove when https://github.com/jestjs/jest/issues/14874 is resolved and *released* +const disposeSymbol: unique symbol = Symbol("Symbol.dispose"); +const asyncDisposeSymbol: unique symbol = Symbol("Symbol.asyncDispose"); +(Symbol as any).asyncDispose ??= asyncDisposeSymbol as unknown as SymbolConstructor["asyncDispose"]; +(Symbol as any).dispose ??= disposeSymbol as unknown as SymbolConstructor["dispose"]; + +// Import needs to be after the workaround +import { Rc } from "./rc"; + +export class FreeableTestValue { + isFreed = false; + + free() { + this.isFreed = true; + } +} + +describe("Rc", () => { + let value: FreeableTestValue; + let rc: Rc; + + beforeEach(() => { + value = new FreeableTestValue(); + rc = new Rc(value); + }); + + it("should increase refCount when taken", () => { + rc.take(); + + expect(rc["refCount"]).toBe(1); + }); + + it("should return value on take", () => { + // eslint-disable-next-line @bitwarden/platform/required-using + const reference = rc.take(); + + expect(reference.value).toBe(value); + }); + + it("should decrease refCount when disposing reference", () => { + // eslint-disable-next-line @bitwarden/platform/required-using + const reference = rc.take(); + + reference[Symbol.dispose](); + + expect(rc["refCount"]).toBe(0); + }); + + it("should automatically decrease refCount when reference goes out of scope", () => { + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using reference = rc.take(); + } + + expect(rc["refCount"]).toBe(0); + }); + + it("should not free value when refCount reaches 0 if not marked for disposal", () => { + // eslint-disable-next-line @bitwarden/platform/required-using + const reference = rc.take(); + + reference[Symbol.dispose](); + + expect(value.isFreed).toBe(false); + }); + + it("should free value when refCount reaches 0 and rc is marked for disposal", () => { + // eslint-disable-next-line @bitwarden/platform/required-using + const reference = rc.take(); + rc.markForDisposal(); + + reference[Symbol.dispose](); + + expect(value.isFreed).toBe(true); + }); + + it("should free value when marked for disposal if refCount is 0", () => { + // eslint-disable-next-line @bitwarden/platform/required-using + const reference = rc.take(); + reference[Symbol.dispose](); + + rc.markForDisposal(); + + expect(value.isFreed).toBe(true); + }); + + it("should throw error when trying to take a disposed reference", () => { + rc.markForDisposal(); + + expect(() => rc.take()).toThrow(); + }); +}); diff --git a/libs/common/src/platform/misc/reference-counting/rc.ts b/libs/common/src/platform/misc/reference-counting/rc.ts new file mode 100644 index 00000000000..9be102b43d3 --- /dev/null +++ b/libs/common/src/platform/misc/reference-counting/rc.ts @@ -0,0 +1,76 @@ +import { UsingRequired } from "../using-required"; + +export type Freeable = { free: () => void }; + +/** + * Reference counted disposable value. + * This class is used to manage the lifetime of a value that needs to be + * freed of at a specific time but might still be in-use when that happens. + */ +export class Rc { + private markedForDisposal = false; + private refCount = 0; + private value: T; + + constructor(value: T) { + this.value = value; + } + + /** + * Use this function when you want to use the underlying object. + * This will guarantee that you have a reference to the object + * and that it won't be freed until your reference goes out of scope. + * + * This function must be used with the `using` keyword. + * + * @example + * ```typescript + * function someFunction(rc: Rc) { + * using reference = rc.take(); + * reference.value.doSomething(); + * // reference is automatically disposed here + * } + * ``` + * + * @returns The value. + */ + take(): Ref { + if (this.markedForDisposal) { + throw new Error("Cannot take a reference to a value marked for disposal"); + } + + this.refCount++; + return new Ref(() => this.release(), this.value); + } + + /** + * Mark this Rc for disposal. When the refCount reaches 0, the value + * will be freed. + */ + markForDisposal() { + this.markedForDisposal = true; + this.freeIfPossible(); + } + + private release() { + this.refCount--; + this.freeIfPossible(); + } + + private freeIfPossible() { + if (this.refCount === 0 && this.markedForDisposal) { + this.value.free(); + } + } +} + +export class Ref implements UsingRequired { + constructor( + private readonly release: () => void, + readonly value: T, + ) {} + + [Symbol.dispose]() { + this.release(); + } +} diff --git a/libs/common/src/platform/misc/using-required.ts b/libs/common/src/platform/misc/using-required.ts new file mode 100644 index 00000000000..f641b9f312f --- /dev/null +++ b/libs/common/src/platform/misc/using-required.ts @@ -0,0 +1,11 @@ +export type Disposable = { [Symbol.dispose]: () => void }; + +/** + * Types implementing this type must be used together with the `using` keyword + * + * @example using ref = rc.take(); + */ +// We want to use `interface` here because it creates a separate type. +// Type aliasing would not expose `UsingRequired` to the linter. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UsingRequired extends Disposable {} diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index c5917e0230f..e8dfde863ec 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -10,6 +10,7 @@ import { UserKey } from "../../../types/key"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; +import { Rc } from "../../misc/reference-counting/rc"; import { EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; @@ -75,15 +76,14 @@ describe("DefaultSdkService", () => { }); it("creates an SDK client when called the first time", async () => { - const result = await firstValueFrom(service.userClient$(userId)); + await firstValueFrom(service.userClient$(userId)); - expect(result).toBe(mockClient); expect(sdkClientFactory.createSdkClient).toHaveBeenCalled(); }); it("does not create an SDK client when called the second time with same userId", async () => { - const subject_1 = new BehaviorSubject(undefined); - const subject_2 = new BehaviorSubject(undefined); + const subject_1 = new BehaviorSubject | undefined>(undefined); + const subject_2 = new BehaviorSubject | undefined>(undefined); // Use subjects to ensure the subscription is kept alive service.userClient$(userId).subscribe(subject_1); @@ -92,14 +92,14 @@ describe("DefaultSdkService", () => { // Wait for the next tick to ensure all async operations are done await new Promise(process.nextTick); - expect(subject_1.value).toBe(mockClient); - expect(subject_2.value).toBe(mockClient); + expect(subject_1.value.take().value).toBe(mockClient); + expect(subject_2.value.take().value).toBe(mockClient); expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1); }); it("destroys the SDK client when all subscriptions are closed", async () => { - const subject_1 = new BehaviorSubject(undefined); - const subject_2 = new BehaviorSubject(undefined); + const subject_1 = new BehaviorSubject | undefined>(undefined); + const subject_2 = new BehaviorSubject | undefined>(undefined); const subscription_1 = service.userClient$(userId).subscribe(subject_1); const subscription_2 = service.userClient$(userId).subscribe(subject_2); await new Promise(process.nextTick); @@ -107,6 +107,7 @@ describe("DefaultSdkService", () => { subscription_1.unsubscribe(); subscription_2.unsubscribe(); + await new Promise(process.nextTick); expect(mockClient.free).toHaveBeenCalledTimes(1); }); @@ -114,7 +115,7 @@ describe("DefaultSdkService", () => { const userKey$ = new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey); keyService.userKey$.calledWith(userId).mockReturnValue(userKey$); - const subject = new BehaviorSubject(undefined); + const subject = new BehaviorSubject | undefined>(undefined); service.userClient$(userId).subscribe(subject); await new Promise(process.nextTick); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index e9cecbb15dc..516334c7fb4 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -30,10 +30,11 @@ import { PlatformUtilsService } from "../../abstractions/platform-utils.service" import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; import { SdkService } from "../../abstractions/sdk/sdk.service"; import { compareValues } from "../../misc/compare-values"; +import { Rc } from "../../misc/reference-counting/rc"; import { EncryptedString } from "../../models/domain/enc-string"; export class DefaultSdkService implements SdkService { - private sdkClientCache = new Map>(); + private sdkClientCache = new Map>>(); client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { @@ -58,7 +59,7 @@ export class DefaultSdkService implements SdkService { private userAgent: string = null, ) {} - userClient$(userId: UserId): Observable { + userClient$(userId: UserId): Observable | undefined> { // TODO: Figure out what happens when the user logs out if (this.sdkClientCache.has(userId)) { return this.sdkClientCache.get(userId); @@ -88,32 +89,31 @@ export class DefaultSdkService implements SdkService { // switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value. switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => { // Create our own observable to be able to implement clean-up logic - return new Observable((subscriber) => { - let client: BitwardenClient; - + return new Observable>((subscriber) => { const createAndInitializeClient = async () => { if (privateKey == null || userKey == null) { return undefined; } const settings = this.toSettings(env); - client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info); + const client = await this.sdkClientFactory.createSdkClient(settings, LogLevel.Info); await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys); return client; }; + let client: Rc; createAndInitializeClient() .then((c) => { - client = c; - subscriber.next(c); + client = c === undefined ? undefined : new Rc(c); + subscriber.next(client); }) .catch((e) => { subscriber.error(e); }); - return () => client?.free(); + return () => client?.markForDisposal(); }); }), tap({ diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts index b0f19c53faa..82a6e2b348c 100644 --- a/libs/common/src/platform/state/global-state.ts +++ b/libs/common/src/platform/state/global-state.ts @@ -9,7 +9,7 @@ import { StateUpdateOptions } from "./state-update-options"; export interface GlobalState { /** * Method for allowing you to manipulate state in an additive way. - * @param configureState callback for how you want manipulate this section of state + * @param configureState callback for how you want to manipulate this section of state * @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS} * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index 44bc8732544..22c255eb985 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -16,6 +16,7 @@ export interface UserState { } export const activeMarker: unique symbol = Symbol("active"); + export interface ActiveUserState extends UserState { readonly [activeMarker]: true; @@ -32,7 +33,7 @@ export interface ActiveUserState extends UserState { * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - + * * @returns A promise that must be awaited before your next action to ensure the update has been written to state. * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. */ @@ -41,6 +42,7 @@ export interface ActiveUserState extends UserState { options?: StateUpdateOptions, ) => Promise<[UserId, T]>; } + export interface SingleUserState extends UserState { readonly userId: UserId; @@ -51,7 +53,7 @@ export interface SingleUserState extends UserState { * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - + * * @returns A promise that must be awaited before your next action to ensure the update has been written to state. * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. */ diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index 8cae7170738..d6588d9acb1 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -1,8 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; +import { KeyService } from "@bitwarden/key-management"; + import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index dd79da3086e..65d7a24f9c4 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -1,9 +1,8 @@ import { mock } from "jest-mock-extended"; import { Jsonify } from "type-fest"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; +import { KeyService } from "@bitwarden/key-management"; + import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; import { UriMatchStrategy } from "../../../models/domain/domain-service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 20dbd23065c..650a1e9dc45 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -142,6 +142,13 @@ export class CipherView implements View, InitializerMetadata { ); } + get canAssignToCollections(): boolean { + if (this.organizationId == null) { + return true; + } + + return this.edit && this.viewPassword; + } /** * Determines if the cipher can be launched in a new browser tab. */ diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 0d6578f165d..770c3660c07 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,12 +1,8 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, map, of } from "rxjs"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { - CipherDecryptionKeys, - KeyService, -} from "../../../../key-management/src/abstractions/key.service"; +import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management"; + import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { makeStaticByteArray } from "../../../spec/utils"; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 18295453d9a..da205cb2b0e 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -14,9 +14,8 @@ import { } from "rxjs"; import { SemVer } from "semver"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { KeyService } from "../../../../key-management/src/abstractions/key.service"; +import { KeyService } from "@bitwarden/key-management"; + import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { AccountService } from "../../auth/abstractions/account.service"; diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index cc3aa1946ca..bd79d3abd1e 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -1,9 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; +import { KeyService } from "@bitwarden/key-management"; + import { makeEncString } from "../../../../spec"; import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service"; import { FakeSingleUserState } from "../../../../spec/fake-state"; diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index c21a92fd894..37dafc3e710 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -2,11 +2,10 @@ // @ts-strict-ignore import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs"; +import { KeyService } from "@bitwarden/key-management"; + import { EncryptService } from ".././../../platform/abstractions/encrypt.service"; import { Utils } from ".././../../platform/misc/utils"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { KeyService } from "../../../../../key-management/src/abstractions/key.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { StateProvider } from "../../../platform/state"; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 76ff702e88b..0e3dbd6f1b9 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf, NgClass } from "@angular/common"; +import { NgClass } from "@angular/common"; import { Component, Input, OnChanges } from "@angular/core"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; @@ -18,9 +18,11 @@ const SizeClasses: Record = { @Component({ selector: "bit-avatar", - template: ``, + template: `@if (src) { + + }`, standalone: true, - imports: [NgIf, NgClass], + imports: [NgClass], }) export class AvatarComponent implements OnChanges { @Input() border = false; diff --git a/libs/components/src/badge-list/badge-list.component.html b/libs/components/src/badge-list/badge-list.component.html index ebd63117d03..c8aa7b84680 100644 --- a/libs/components/src/badge-list/badge-list.component.html +++ b/libs/components/src/badge-list/badge-list.component.html @@ -1,11 +1,15 @@

- + @for (item of filteredItems; track item; let last = $last) { {{ item }} - , - - - {{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }} - + @if (!last || isFiltered) { + , + } + } + @if (isFiltered) { + + {{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }} + + }
diff --git a/libs/components/src/badge-list/badge-list.component.ts b/libs/components/src/badge-list/badge-list.component.ts index 7d152761ed0..86e9a84cb77 100644 --- a/libs/components/src/badge-list/badge-list.component.ts +++ b/libs/components/src/badge-list/badge-list.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CommonModule } from "@angular/common"; + import { Component, Input, OnChanges } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -11,7 +11,7 @@ import { BadgeModule, BadgeVariant } from "../badge"; selector: "bit-badge-list", templateUrl: "badge-list.component.html", standalone: true, - imports: [CommonModule, BadgeModule, I18nPipe], + imports: [BadgeModule, I18nPipe], }) export class BadgeListComponent implements OnChanges { private _maxItems: number; diff --git a/libs/components/src/banner/banner.component.html b/libs/components/src/banner/banner.component.html index 566494eb64a..1a9d58d342a 100644 --- a/libs/components/src/banner/banner.component.html +++ b/libs/components/src/banner/banner.component.html @@ -4,21 +4,24 @@ [attr.role]="useAlertRole ? 'status' : null" [attr.aria-live]="useAlertRole ? 'polite' : null" > - + @if (icon) { + + } - + @if (showClose) { + + }
diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.html b/libs/components/src/breadcrumbs/breadcrumb.component.html index dd5bac9beb4..bb4dc7cdffe 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.html +++ b/libs/components/src/breadcrumbs/breadcrumb.component.html @@ -1,3 +1,6 @@ - + @if (icon) { + + } + diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.ts b/libs/components/src/breadcrumbs/breadcrumb.component.ts index ce18bde171f..53c46a9b24a 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumb.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf } from "@angular/common"; + import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core"; import { QueryParamsHandling } from "@angular/router"; @@ -8,7 +8,6 @@ import { QueryParamsHandling } from "@angular/router"; selector: "bit-breadcrumb", templateUrl: "./breadcrumb.component.html", standalone: true, - imports: [NgIf], }) export class BreadcrumbComponent { @Input() diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index 502bb0bb8e7..5205e19cee5 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -1,5 +1,5 @@ - - +@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) { + @if (breadcrumb.route) { - - + } + @if (!breadcrumb.route) { - - - - - - + } + @if (!last) { + + } +} +@if (hasOverflow) { + @if (beforeOverflow.length > 0) { + + } - - - + @for (breadcrumb of overflow; track breadcrumb) { + @if (breadcrumb.route) { - - + } + @if (!breadcrumb.route) { - - + } + } - - - + @for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) { + @if (breadcrumb.route) { - - + } + @if (!breadcrumb.route) { - - - - + } + @if (!last) { + + } + } +} diff --git a/libs/components/src/button/button.stories.ts b/libs/components/src/button/button.stories.ts index 469c2d1b51b..6024b0559f2 100644 --- a/libs/components/src/button/button.stories.ts +++ b/libs/components/src/button/button.stories.ts @@ -86,16 +86,15 @@ export const DisabledWithAttribute: Story = { render: (args) => ({ props: args, template: ` - + @if (disabled) { - - + } @else { - + } `, }), args: { diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index f64e197b9ef..bb7f918df32 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -3,10 +3,14 @@ [ngClass]="calloutClass" [attr.aria-labelledby]="titleId" > -
- - {{ title }} -
+ @if (title) { +
+ @if (icon) { + + } + {{ title }} +
+ }
diff --git a/libs/components/src/card/card.component.ts b/libs/components/src/card/card.component.ts index 37756088e0d..fdb02f280da 100644 --- a/libs/components/src/card/card.component.ts +++ b/libs/components/src/card/card.component.ts @@ -1,10 +1,8 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; @Component({ selector: "bit-card", standalone: true, - imports: [CommonModule], template: ``, changeDetection: ChangeDetectionStrategy.OnPush, host: { diff --git a/libs/components/src/chip-select/chip-select.component.html b/libs/components/src/chip-select/chip-select.component.html index 81480f107f1..e88200b6e4f 100644 --- a/libs/components/src/chip-select/chip-select.component.html +++ b/libs/components/src/chip-select/chip-select.component.html @@ -30,78 +30,80 @@ {{ label }} - + @if (!selectedOption) { + + } - + @if (selectedOption) { + + } -
- - - - - - - -
+ @if (getParent(renderedOptions); as parent) { + + + } + @for (option of renderedOptions.children; track option) { + + } + + }
diff --git a/libs/components/src/chip-select/chip-select.component.ts b/libs/components/src/chip-select/chip-select.component.ts index a653d79f83f..39543db5ed5 100644 --- a/libs/components/src/chip-select/chip-select.component.ts +++ b/libs/components/src/chip-select/chip-select.component.ts @@ -46,6 +46,7 @@ export type ChipSelectOption = Option & { multi: true, }, ], + preserveWhitespaces: false, }) export class ChipSelectComponent implements ControlValueAccessor, AfterViewInit { @ViewChild(MenuComponent) menu: MenuComponent; diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index cbf746e9d73..e48758ca59a 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgFor, NgIf } from "@angular/common"; + import { Component, HostBinding, Input } from "@angular/core"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -14,18 +14,16 @@ enum CharacterType { @Component({ selector: "bit-color-password", - template: ` - {{ character }} - {{ - i + 1 - }} - `, + template: `@for (character of passwordArray; track character; let i = $index) { + + {{ character }} + @if (showCount) { + {{ i + 1 }} + } + + }`, preserveWhitespaces: false, standalone: true, - imports: [NgFor, NgIf], }) export class ColorPasswordComponent { @Input() password: string = null; diff --git a/libs/components/src/color-password/index.ts b/libs/components/src/color-password/index.ts index 86718f037f7..24870ca75d9 100644 --- a/libs/components/src/color-password/index.ts +++ b/libs/components/src/color-password/index.ts @@ -1 +1,2 @@ export * from "./color-password.module"; +export * from "./color-password.component"; diff --git a/libs/components/src/container/container.component.ts b/libs/components/src/container/container.component.ts index fbd9e40a7ca..1bcdb8f459b 100644 --- a/libs/components/src/container/container.component.ts +++ b/libs/components/src/container/container.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; /** @@ -7,7 +6,6 @@ import { Component } from "@angular/core"; @Component({ selector: "bit-container", templateUrl: "container.component.html", - imports: [CommonModule], standalone: true, }) export class ContainerComponent {} diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index ef9824471e7..01f05985127 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -13,9 +13,11 @@ class="tw-text-main tw-mb-0 tw-truncate" > {{ title }} - - {{ subtitle }} - + @if (subtitle) { + + {{ subtitle }} + + }

+ @if (showCancelButton) { + + }
diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts index 60b2e1c3a3f..00026209183 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts @@ -1,7 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; -import { NgIf } from "@angular/common"; import { Component, Inject } from "@angular/core"; import { FormGroup, ReactiveFormsModule } from "@angular/forms"; @@ -39,7 +38,6 @@ const DEFAULT_COLOR: Record = { IconDirective, ButtonComponent, BitFormButtonDirective, - NgIf, ], }) export class SimpleConfigurableDialogComponent { diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts index b4bf199358b..87d6eb9fbfc 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts @@ -12,23 +12,24 @@ import { DialogModule } from "../../dialog.module"; @Component({ template: ` -
-

{{ group.title }}

-
- + @for (group of dialogs; track group) { +
+

{{ group.title }}

+
+ @for (dialog of group.dialogs; track dialog) { + + } +
-
+ } - - {{ dialogCloseResult }} - + @if (showCallout) { + + {{ dialogCloseResult }} + + } `, }) class StoryDialogComponent { diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-dialog.component.html index 0b56c6287dc..1f154a8d543 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.component.html +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.component.html @@ -3,12 +3,11 @@ @fadeIn >
- + @if (hasIcon) { - - + } @else { - + }

- ({{ "required" | i18n }}) + @if (required) { + ({{ "required" | i18n }}) + } - + @if (!hasError) { + + } -
- {{ displayError }} -
+@if (hasError) { +
+ {{ displayError }} +
+} diff --git a/libs/components/src/form-control/form-control.component.ts b/libs/components/src/form-control/form-control.component.ts index d22d49ac03a..690c00a9dc0 100644 --- a/libs/components/src/form-control/form-control.component.ts +++ b/libs/components/src/form-control/form-control.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; -import { NgClass, NgIf } from "@angular/common"; +import { NgClass } from "@angular/common"; import { Component, ContentChild, HostBinding, Input } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -15,7 +15,7 @@ import { BitFormControlAbstraction } from "./form-control.abstraction"; selector: "bit-form-control", templateUrl: "form-control.component.html", standalone: true, - imports: [NgClass, TypographyDirective, NgIf, I18nPipe], + imports: [NgClass, TypographyDirective, I18nPipe], }) export class FormControlComponent { @Input() label: string; diff --git a/libs/components/src/form-control/label.component.html b/libs/components/src/form-control/label.component.html index 64ba1ce9501..2a0a57e35d8 100644 --- a/libs/components/src/form-control/label.component.html +++ b/libs/components/src/form-control/label.component.html @@ -5,10 +5,10 @@ - + @if (isInsideFormControl) { - + } - +@if (!isInsideFormControl) { - +} diff --git a/libs/components/src/form-field/error-summary.component.ts b/libs/components/src/form-field/error-summary.component.ts index f9325d8f82a..1709c3078fa 100644 --- a/libs/components/src/form-field/error-summary.component.ts +++ b/libs/components/src/form-field/error-summary.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf } from "@angular/common"; + import { Component, Input } from "@angular/core"; import { AbstractControl, UntypedFormGroup } from "@angular/forms"; @@ -8,15 +8,15 @@ import { I18nPipe } from "@bitwarden/ui-common"; @Component({ selector: "bit-error-summary", - template: ` + template: ` @if (errorCount > 0) { {{ "fieldsNeedAttention" | i18n: errorString }} - `, + }`, host: { class: "tw-block tw-text-danger tw-mt-2", "aria-live": "assertive", }, standalone: true, - imports: [NgIf, I18nPipe], + imports: [I18nPipe], }) export class BitErrorSummary { @Input() diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index f3c27aecafe..bd099859608 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -15,63 +15,65 @@ -
-
-
-
-
-
-
-
- -
-
- -
-
- -
-
-
- - +} @else {
- +} - - - - +@switch (input.hasError) { + @case (false) { + + } + @case (true) { + + } +} diff --git a/libs/components/src/form-field/index.ts b/libs/components/src/form-field/index.ts index 613ebaf9a9d..0c45f215ec9 100644 --- a/libs/components/src/form-field/index.ts +++ b/libs/components/src/form-field/index.ts @@ -1,4 +1,5 @@ export * from "./form-field.module"; export * from "./form-field.component"; export * from "./form-field-control"; +export * from "./password-input-toggle.directive"; export * as BitValidators from "./bit-validators"; diff --git a/libs/components/src/input/index.ts b/libs/components/src/input/index.ts index 9713d2b9192..6bd64495910 100644 --- a/libs/components/src/input/index.ts +++ b/libs/components/src/input/index.ts @@ -1,2 +1,3 @@ export * from "./input.module"; export * from "./autofocus.directive"; +export * from "./input.directive"; diff --git a/libs/components/src/item/item-content.component.ts b/libs/components/src/item/item-content.component.ts index fcd98bc55f6..2a6e06291fd 100644 --- a/libs/components/src/item/item-content.component.ts +++ b/libs/components/src/item/item-content.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CommonModule } from "@angular/common"; + +import { NgClass } from "@angular/common"; import { AfterContentChecked, ChangeDetectionStrategy, @@ -16,7 +17,7 @@ import { TypographyModule } from "../typography"; @Component({ selector: "bit-item-content, [bit-item-content]", standalone: true, - imports: [CommonModule, TypographyModule], + imports: [TypographyModule, NgClass], templateUrl: `item-content.component.html`, host: { class: diff --git a/libs/components/src/item/item.component.ts b/libs/components/src/item/item.component.ts index 97a80484373..1ef4a4af1fa 100644 --- a/libs/components/src/item/item.component.ts +++ b/libs/components/src/item/item.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -14,7 +13,7 @@ import { ItemActionComponent } from "./item-action.component"; @Component({ selector: "bit-item", standalone: true, - imports: [CommonModule, ItemActionComponent], + imports: [ItemActionComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "item.component.html", providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }], diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 489a0dc876f..33b8de81572 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -23,19 +23,21 @@ -
+ }; + as data + ) {
-
+ class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden" + [ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']" + > + @if (data.open) { +
+ } +

+ }
diff --git a/libs/components/src/multi-select/multi-select.component.html b/libs/components/src/multi-select/multi-select.component.html index 0c45e2d333f..e157871e17a 100644 --- a/libs/components/src/multi-select/multi-select.component.html +++ b/libs/components/src/multi-select/multi-select.component.html @@ -31,7 +31,9 @@ [disabled]="disabled" (click)="clear(item)" > - + @if (item.icon != null) { + + } {{ item.labelName }} @@ -41,10 +43,14 @@
- + @if (isSelected(item)) { + + }
- + @if (item.icon != null) { + + }
{{ item.listName }} diff --git a/libs/components/src/multi-select/multi-select.component.ts b/libs/components/src/multi-select/multi-select.component.ts index 71b00404cfb..cd92eb1d7ae 100644 --- a/libs/components/src/multi-select/multi-select.component.ts +++ b/libs/components/src/multi-select/multi-select.component.ts @@ -2,7 +2,6 @@ // @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { hasModifierKey } from "@angular/cdk/keycodes"; -import { NgIf } from "@angular/common"; import { Component, Input, @@ -39,7 +38,7 @@ let nextId = 0; templateUrl: "./multi-select.component.html", providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }], standalone: true, - imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, NgIf, I18nPipe], + imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe], }) /** * This component has been implemented to only support Multi-select list events diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 224f6ae0657..64e43aeab4e 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1 +1,3 @@ -
+@if (sideNavService.open$ | async) { +
+} diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 9f6d9ac034d..9752fe56eb1 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -1,5 +1,5 @@ - +@if (!hideIfEmpty || nestedNavComponents.length > 0) { - - - - - - - + @if (variant === "tree") { + + } + + + @if (variant !== "tree") { + + } - - -
- -
-
-
+ @if (sideNavService.open$ | async) { + @if (open) { +
+ +
+ } + } +} diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index 37244f37c8d..62bdee26740 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -29,6 +29,7 @@ import { SideNavService } from "./side-nav.service"; ], standalone: true, imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe], + preserveWhitespaces: false, }) export class NavGroupComponent extends NavBaseComponent implements AfterContentInit { @ContentChildren(NavBaseComponent, { diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 427e926f2d7..a6169315333 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,20 +1,23 @@ - - +@if (sideNavService.open) { +
+ + + +
+} +@if (!sideNavService.open) { + +} diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 8a84970500c..de9d801e553 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf } from "@angular/common"; + import { Component, Input } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; @@ -14,7 +14,7 @@ import { SideNavService } from "./side-nav.service"; selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", standalone: true, - imports: [NgIf, RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent], + imports: [RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent], }) export class NavLogoComponent { /** Icon that is displayed when the side nav is closed */ diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 05c99c7d64e..3b77c981be4 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -1,42 +1,46 @@ - + +} diff --git a/libs/components/src/progress/progress.component.html b/libs/components/src/progress/progress.component.html index 2637f23eee8..30b68d9d645 100644 --- a/libs/components/src/progress/progress.component.html +++ b/libs/components/src/progress/progress.component.html @@ -7,13 +7,12 @@ attr.aria-valuenow="{{ barWidth }}" [ngStyle]="{ width: barWidth + '%' }" > -
- -
 
-
{{ textContent }}
-
+ @if (displayText) { +
+ +
 
+
{{ textContent }}
+
+ }
diff --git a/libs/components/src/radio-button/radio-group.component.html b/libs/components/src/radio-button/radio-group.component.html index 128a723d461..b71abd9249c 100644 --- a/libs/components/src/radio-button/radio-group.component.html +++ b/libs/components/src/radio-button/radio-group.component.html @@ -1,16 +1,18 @@ - +@if (label) {
- ({{ "required" | i18n }}) + @if (required) { + ({{ "required" | i18n }}) + }
-
+} - +@if (!label) { - +}
diff --git a/libs/components/src/radio-button/radio-group.component.ts b/libs/components/src/radio-button/radio-group.component.ts index 4ab626f7964..895f769af50 100644 --- a/libs/components/src/radio-button/radio-group.component.ts +++ b/libs/components/src/radio-button/radio-group.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf, NgTemplateOutlet } from "@angular/common"; +import { NgTemplateOutlet } from "@angular/common"; import { Component, ContentChild, HostBinding, Input, Optional, Self } from "@angular/core"; import { ControlValueAccessor, NgControl, Validators } from "@angular/forms"; @@ -14,7 +14,7 @@ let nextId = 0; selector: "bit-radio-group", templateUrl: "radio-group.component.html", standalone: true, - imports: [NgIf, NgTemplateOutlet, I18nPipe], + imports: [NgTemplateOutlet, I18nPipe], }) export class RadioGroupComponent implements ControlValueAccessor { selected: unknown; diff --git a/libs/components/src/select/select.component.html b/libs/components/src/select/select.component.html index 848692526a1..dcca7ae195e 100644 --- a/libs/components/src/select/select.component.html +++ b/libs/components/src/select/select.component.html @@ -13,7 +13,9 @@
- + @if (item.icon != null) { + + }
{{ item.label }} diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts index 8f75c5be42b..a89eb87f54b 100644 --- a/libs/components/src/select/select.component.ts +++ b/libs/components/src/select/select.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgIf } from "@angular/common"; + import { Component, ContentChildren, @@ -36,7 +36,7 @@ let nextId = 0; templateUrl: "select.component.html", providers: [{ provide: BitFormFieldControl, useExisting: SelectComponent }], standalone: true, - imports: [NgSelectModule, ReactiveFormsModule, FormsModule, NgIf], + imports: [NgSelectModule, ReactiveFormsModule, FormsModule], }) export class SelectComponent implements BitFormFieldControl, ControlValueAccessor { @ViewChild(NgSelectComponent) select: NgSelectComponent; diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts index c63b36ea89c..5fc01d37d53 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts @@ -49,11 +49,9 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; Your favorite color - + @for (color of colors; track color) { + + } diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts index 568c78566f6..9c609300ed1 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts @@ -36,9 +36,11 @@ class KitchenSinkDialog {

- - {{ item.name }} - + @for (item of navItems; track item) { + + {{ item.name }} + + }

diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-toggle-list.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-toggle-list.component.ts index 6f0054912cf..c71140d8166 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-toggle-list.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-toggle-list.component.ts @@ -16,11 +16,15 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; Mid-sized 1
-
    -
  • - {{ company.name }} -
  • -
+ @for (company of companyList; track company) { +
    + @if (company.size === selectedToggle || selectedToggle === "all") { +
  • + {{ company.name }} +
  • + } +
+ } `, }) export class KitchenSinkToggleList { diff --git a/libs/components/src/tabs/tab-group/tab-group.component.html b/libs/components/src/tabs/tab-group/tab-group.component.html index 071f5c2259f..52fa193de96 100644 --- a/libs/components/src/tabs/tab-group/tab-group.component.html +++ b/libs/components/src/tabs/tab-group/tab-group.component.html @@ -5,40 +5,41 @@ [attr.aria-label]="label" (keydown)="keyManager.onKeydown($event)" > - + + }
- - + @for (tab of tabs; track tab; let i = $index) { + + + }
diff --git a/libs/components/src/tabs/tab-group/tab-group.component.ts b/libs/components/src/tabs/tab-group/tab-group.component.ts index 54d00343b38..b525b9b6723 100644 --- a/libs/components/src/tabs/tab-group/tab-group.component.ts +++ b/libs/components/src/tabs/tab-group/tab-group.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { FocusKeyManager } from "@angular/cdk/a11y"; import { coerceNumberProperty } from "@angular/cdk/coercion"; -import { CommonModule } from "@angular/common"; +import { NgTemplateOutlet } from "@angular/common"; import { AfterContentChecked, AfterContentInit, @@ -33,7 +33,7 @@ let nextId = 0; templateUrl: "./tab-group.component.html", standalone: true, imports: [ - CommonModule, + NgTemplateOutlet, TabHeaderComponent, TabListContainerDirective, TabListItemDirective, diff --git a/libs/components/src/toast/toast.component.html b/libs/components/src/toast/toast.component.html index 84154cba611..d78cc7783aa 100644 --- a/libs/components/src/toast/toast.component.html +++ b/libs/components/src/toast/toast.component.html @@ -7,15 +7,14 @@
{{ variant | i18n }} -

{{ title }}

-

- {{ m }} -

+ @if (title) { +

{{ title }}

+ } + @for (m of messageArray; track m) { +

+ {{ m }} +

+ }